11 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
34 changed files with 1459 additions and 619 deletions
+1
View File
@@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1
DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ----
# 健康检查间隔(秒)
+3
View File
@@ -12,7 +12,10 @@ __pycache__/
# 运行时数据
backend/data/*.bak
# 运行时配置:网页 UI 改配置、每次 App_Store 打包都会写盘,不纳入版本控制。
# 结构参见仓库根目录的 config.example.json;首次部署可 cp 一份作为初始种子。
config.json
backend/data/config.json
ReadoorBranches/
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/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
- `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
- 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)
- Chinese UI and documentation throughout
+74 -12
View File
@@ -13,6 +13,7 @@
- [Watchdog 健康监测](#watchdog-健康监测)
- [macOS 开机自启](#macos-开机自启)
- [开发模式](#开发模式)
- [推送代码到 Git 服务器](#推送代码到-git-服务器)
- [自动化测试](#自动化测试)
- [项目结构](#项目结构)
- [常见问题](#常见问题)
@@ -63,11 +64,24 @@ ADMIN_PASSWORD=your_secure_password
### 3. 构建与启动
推荐一键部署(构建 + 启动 + 健康监测,一步到位):
```bash
# 一键部署:build + start + watchdog(强制重来)
./deploy.sh up
```
`up` 会自动构建前端、(强制)重启后端并**轮询 `/api/health` 直到就绪**才返回,再拉起 watchdog,最后打印状态。适合首次部署和更新代码后重新上线。
> ⚠️ `up`/`start` 采用「强制重来」语义:若检测到旧服务会先停再起,正在进行的打包任务会被中断。
也可以分步执行:
```bash
# 一键构建(安装依赖 + 构建前端)
./deploy.sh build
# 启动服务
# 启动服务(等待就绪;端口被非本项目进程占用时会明确报错)
./deploy.sh start
```
@@ -156,19 +170,39 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
### 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 等 |
| Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表 |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码 |
| 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道 |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 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/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
@@ -179,6 +213,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|------|--------|------|
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
| `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` | 连续重启上限,超过后进入冷却 |
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
@@ -196,8 +233,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
| 命令 | 说明 |
|------|------|
| `up` | **一键部署**build + start + watchdog(强制重来),启动后等待就绪 |
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
| `start` | 后台启动服务 |
| `start` | 后台启动服务(强制重启并等待 `/api/health` 就绪;端口被非本项目进程占用会报错) |
| `stop` | 停止服务和 watchdog |
| `restart` | 重启服务 |
| `status` | 查看服务和 watchdog 运行状态 |
@@ -209,18 +247,17 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
### 常用操作
```bash
# 首次部署
./deploy.sh build && ./deploy.sh start
# 首次部署(一键:build + start + watchdog
./deploy.sh up
# 查看状态
./deploy.sh status
# 更新代码后重新部署
git pull
./deploy.sh build
./deploy.sh restart
./deploy.sh up
# 启用健康监测
# 单独启用健康监测
./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 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
---
## 自动化测试
### 运行测试
+2
View File
@@ -146,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_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
# 受控 IPA 下载链接的 OSS 签名有效期(秒)。
DOWNLOAD_URL_EXPIRE_SECONDS = int(os.getenv("DOWNLOAD_URL_EXPIRE_SECONDS", "600"))
def validate_production_security() -> None:
-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 = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"),
]
with engine.connect() as conn:
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 .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 .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user
@@ -48,6 +48,7 @@ app.include_router(users.router)
app.include_router(config.router)
app.include_router(apps.router)
app.include_router(tasks.router)
app.include_router(operations.router)
@app.websocket("/ws/tasks/{task_id}")
+21
View File
@@ -25,6 +25,8 @@ class Task(Base):
created_at = Column(TIMESTAMP, default=datetime.utcnow)
started_at = Column(TIMESTAMP)
completed_at = Column(TIMESTAMP)
# 同一 OSS 对象被后续成功打包覆盖时标记;默认历史列表不展示。
superseded_at = Column(TIMESTAMP)
# 产物
ipa_path = Column(String)
@@ -60,3 +62,22 @@ class User(Base):
password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
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)
+239 -25
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:
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/")
if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
or path == "/api/config/versions"):
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
return user
return require_admin(user)
@@ -90,10 +89,117 @@ DEFAULT_SERVERS = {
}
}
DEFAULT_VERSIONS = {
"app_ver": "2.180.0",
"build_ver": "2.180.0.0",
}
DEFAULT_APP_VER = "2.180.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 = {
@@ -148,6 +254,14 @@ def _apply_special_app_prefix_override(app: dict) -> None:
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")
@@ -247,13 +361,12 @@ def load_config() -> dict:
config["branches"] = ["main"]
if "upload" not in config:
config["upload"] = DEFAULT_UPLOAD
if "versions" not in config:
config["versions"] = DEFAULT_VERSIONS.copy()
versions_changed = _ensure_versions(config)
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
prefixes_changed = _ensure_server_id_prefixes(config)
overrides_changed = _ensure_special_app_prefix_overrides(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)
# 迁移旧式目录皮肤为 ZIP
if _migrate_old_themes(config):
@@ -307,6 +420,7 @@ async def create_app(app: dict):
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
_apply_special_app_prefix_override(app)
_normalize_tencent_app_id(app)
new_id = _next_app_id(apps, config.get("servers", {}), app)
# 自动生成 upload_key
@@ -316,7 +430,7 @@ async def create_app(app: dict):
apps[new_id] = app
config["apps"] = apps
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}")
@@ -329,6 +443,7 @@ async def update_app(app_id: str, app: dict):
if app_id not in apps:
raise HTTPException(status_code=404, detail="App 不存在")
_normalize_tencent_app_id(app)
apps[app_id] = app
config["apps"] = apps
save_config(config)
@@ -410,6 +525,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
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}")
async def delete_skin(upload_key: str, skin_name: str):
"""删除指定皮肤包"""
@@ -502,11 +644,15 @@ async def get_branches():
@router.post("/branches")
async def add_branch(data: dict):
"""新增分支"""
"""新增分支,可指定所属版本轨道(缺省按分支名推断)"""
name = data.get("name", "").strip()
if not name:
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:
config = load_config()
branches = config.get("branches", ["main"])
@@ -514,11 +660,32 @@ async def add_branch(data: dict):
raise HTTPException(status_code=400, detail="分支已存在")
branches.append(name)
config["branches"] = branches
config.setdefault("branch_track", {})[name] = track
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 with _config_lock:
@@ -528,6 +695,7 @@ async def delete_branch(branch_name: str):
raise HTTPException(status_code=404, detail="分支不存在")
branches.remove(branch_name)
config["branches"] = branches
config.get("branch_track", {}).pop(branch_name, None)
save_config(config)
return {"message": "分支已删除"}
@@ -579,32 +747,78 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
# 版本号配置 API
@router.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()
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 {
"app_ver": versions.get("app_ver", DEFAULT_VERSIONS["app_ver"]),
"build_ver": versions.get("build_ver", DEFAULT_VERSIONS["build_ver"]),
**tracks[TRACK_RELEASE],
"tracks": tracks,
"branch_track": {
branch: get_track_name(config, branch)
for branch in config.get("branches", []) or []
},
}
@router.put("/versions")
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()
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
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:
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
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)
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
+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"})
+55 -6
View File
@@ -6,7 +6,7 @@ import tempfile
import uuid
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(ge
@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
@@ -63,6 +63,9 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
status="pending",
)
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.refresh(db_task)
@@ -100,12 +103,19 @@ def _enrich_tasks(tasks):
@router.get("", response_model=List[TaskResponse])
async def list_tasks(
status: str = None,
include_superseded: bool = False,
limit: int = 20,
offset: int = 0,
user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务列表"""
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:
query = query.filter(Task.status == status)
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
@@ -122,7 +132,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
@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()
if not task:
@@ -132,6 +142,8 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="任务已完成或已取消")
task.status = "cancelled"
from ..services.audit_log import record_operation
record_operation(db, user, "build_cancelled", request=request, task=task)
db.commit()
# 尝试取消运行中的任务
@@ -153,7 +165,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
@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
from pathlib import Path
@@ -199,6 +211,8 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if build_path.exists():
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.commit()
@@ -243,8 +257,35 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
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")
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 文件"""
import shutil
import zipfile
@@ -255,6 +296,10 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
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
if not os.path.exists(dsym_path):
@@ -275,13 +320,17 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
@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()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.obfuscation_maps_path:
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
if not os.path.exists(maps_path):
+1
View File
@@ -27,6 +27,7 @@ class TaskResponse(BaseModel):
created_at: Optional[datetime]
started_at: Optional[datetime]
completed_at: Optional[datetime]
superseded_at: Optional[datetime] = None
ipa_path: Optional[str]
oss_url: 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,
))
+79 -19
View File
@@ -6,7 +6,7 @@ import plistlib
import shutil
import subprocess
import zipfile
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from typing import List
@@ -214,6 +214,7 @@ def _cleanup_old_builds(db):
base = BUILD_BASE_DIR
if not base.exists():
_cleanup_superseded_tasks(db)
return
for d in base.iterdir():
if d.is_dir() and d.name.startswith("build_readoor_"):
@@ -222,6 +223,50 @@ def _cleanup_old_builds(db):
shutil.rmtree(d)
except Exception:
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():
@@ -439,6 +484,7 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(),
current_step=None)
await asyncio.to_thread(_mark_superseded_tasks, db, task)
await log_streamer.emit_step(task_id, "打包完成")
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
@@ -555,35 +601,44 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
"""生成打包配置"""
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()
apps = config.get("apps", {})
schemes = config.get("schemes", {})
versions = config.get("versions", {})
app = apps.get(task.app_id, {})
scheme = schemes.get(task.scheme_id, {})
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
app_ver = versions.get("app_ver", "2.180.0")
build_ver = versions.get("build_ver", "2.180.0.0")
# 按分支所属轨道取版本号:master/develop 走 release 轨(上架用),feature 走 feature 轨(自测用),
# 两条轨道的 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 第四位版本号
if task.build_type == "App_Store":
# 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
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:
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["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track = tracks.setdefault(track_name, _new_track(app_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
save_config(config)
@@ -629,7 +684,12 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
skin_extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(skin_zip, "r") as zf:
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():
config_data["THEME"] = str(entries[0])
else:
+26
View File
@@ -9,6 +9,8 @@ from urllib.parse import quote, unquote, urlparse
import httpx
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
class DistributionError(Exception):
"""分发配置或上传过程失败。"""
@@ -167,6 +169,30 @@ def delete_published_artifacts(
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):
path = ""
for segment in remote_path.strip("/").split("/")[:-1]:
+3
View File
@@ -68,6 +68,9 @@ def _apply_theme(build_dir: Path, theme_dir: str, messages: list[str]):
for item in source.iterdir():
if not item.is_dir():
continue
# 跳过 macOS 压缩包内的 __MACOSX 及隐藏目录,仅复制真实皮肤资源
if item.name == "__MACOSX" or item.name.startswith("."):
continue
destination = target_assets / item.name
if destination.exists():
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" }
}
+114 -43
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# iOS 自动打包服务 — 部署与管理脚本
# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop}
# 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop}
set -e
@@ -23,6 +23,11 @@ WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
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'
@@ -64,6 +69,52 @@ get_server_pid() {
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() {
local level="$1"; shift
@@ -143,11 +194,23 @@ do_build() {
do_start() {
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
existing_pid=$(get_server_pid || true)
if [ -n "$existing_pid" ]; then
warn "服务已在运行 (PID: $existing_pid)"
return 0
warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..."
do_stop
fi
# 确保前端已构建
@@ -156,28 +219,15 @@ do_start() {
do_build
fi
setup_venv
mkdir -p "$LOG_DIR"
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
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 &
_spawn_server
echo $! > "$PID_FILE"
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
info "服务已启动 (PID: $(cat "$PID_FILE"))"
if _wait_ready; then
info "服务已就绪 (PID: $(cat "$PID_FILE"))"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else
error "启动失败,请查看日志: $LOG_DIR/server.log"
rm -f "$PID_FILE"
error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log"
tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /'
return 1
fi
}
@@ -252,12 +302,15 @@ check_health() {
# ========== Watchdog(前台模式,用于调试) ==========
do_watchdog_fg() {
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s"
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s"
info "按 Ctrl+C 停止"
local restart_count=0
local last_restart_time=0
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do
local result
result=$(check_health)
@@ -338,7 +391,10 @@ do_watchdog_loop() {
local restart_count=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
local result
@@ -366,9 +422,17 @@ do_watchdog_loop() {
restart_count=$((restart_count + 1))
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
# launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。
if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then
log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL"
else
log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL"
fi
else
# nohup 托管模式:停掉旧进程后重新拉起。
if [ -f "$PID_FILE" ]; then
local old_pid
old_pid=$(cat "$PID_FILE")
@@ -377,24 +441,14 @@ do_watchdog_loop() {
kill -9 "$old_pid" 2>/dev/null || true
rm -f "$PID_FILE"
fi
_spawn_server
fi
# 重新启动
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"
sleep 3
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))"
# 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。
if _wait_ready; then
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))"
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
@@ -457,8 +511,21 @@ do_test() {
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
up) do_up ;;
build) do_build ;;
start) do_start ;;
stop) do_stop; do_watchdog_stop ;;
@@ -470,10 +537,11 @@ case "${1:-}" in
watchdog-fg) do_watchdog_fg ;;
_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 " up 一键部署:build + start + watchdog(强制重来)"
echo " build 构建前端、安装依赖"
echo " start 启动服务(后台运行"
echo " start 启动服务(强制重启并等待就绪;端口被占用会报错"
echo " stop 停止服务(同时停止 watchdog"
echo " restart 重启服务"
echo " status 查看运行状态"
@@ -485,6 +553,9 @@ case "${1:-}" in
echo "环境变量(可在 .env 中配置):"
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
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 " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
exit 1
+2
View File
@@ -95,6 +95,8 @@ EOF
<dict>
<key>PATH</key>
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>WATCHDOG_RESTART_MODE</key>
<string>launchd</string>
</dict>
<key>RunAtLoad</key>
<true/>
+1
View File
@@ -7,6 +7,7 @@
<router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link>
<router-link to="/admin">管理</router-link>
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
</nav>
<div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button>
+71 -6
View File
@@ -24,7 +24,15 @@ function mockFetch(url) {
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'],
'/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'] },
}
return Promise.resolve({
@@ -168,19 +176,76 @@ describe('ConfigView.vue', () => {
expect(window.alert).toHaveBeenCalledWith('设置已保存')
})
it('普通账号可修改版本号但看不到打包参数', async () => {
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(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()
expect(wrapper.text()).toContain('应用版本号')
expect(wrapper.text()).not.toContain('打包参数')
expect(wrapper.findAll('input').length).toBe(1)
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' }])
})
})
@@ -159,6 +159,18 @@ describe('HistoryView.vue', () => {
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 () => {
fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
const router = createMockRouter()
@@ -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 HistoryView from '../views/HistoryView.vue'
import ConfigView from '../views/ConfigView.vue'
import OperationLogView from '../views/OperationLogView.vue'
const routes = [
{ path: '/', redirect: '/build' },
{ path: '/build', name: 'Build', component: BuildView },
{ path: '/history', name: 'History', component: HistoryView },
{ path: '/admin', name: 'Admin', component: ConfigView },
{ path: '/operations', name: 'Operations', component: OperationLogView },
]
const router = createRouter({
+181 -25
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 === 'branches' }" @click="tab = 'branches'">分支管理</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>
</ul>
</div>
@@ -151,7 +151,10 @@
<!-- 分支管理 -->
<div v-if="tab === 'branches'">
<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;">
<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>
@@ -160,18 +163,25 @@
<thead>
<tr>
<th>分支名称</th>
<th>版本轨道</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="b in branches" :key="b">
<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">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
</td>
</tr>
<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>
</tbody>
</table>
@@ -269,13 +279,36 @@
<div v-if="tab === 'build'">
<h2>打包设置</h2>
<div style="max-width: 500px;">
<h4 class="section-title">应用版本号</h4>
<h4 class="section-title">上架版本号master / develop</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</div>
<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;">格式主版本.次版本.修订号master develop 共用这一套版本号</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">
<h4 class="section-title">打包参数</h4>
@@ -458,7 +491,7 @@
<div class="form-row">
<div class="form-group">
<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 class="form-group">
<label>阿里云 License Key</label>
@@ -519,12 +552,21 @@
<div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</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>
</div>
</div>
<div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }">
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }}
<label class="btn btn-outline skin-upload-btn"
: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>
</label>
</div>
@@ -596,7 +638,32 @@ const servers = ref({})
const branches = ref([])
const newBranch = 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 jsonContent = ref('{}')
@@ -615,6 +682,7 @@ const editingAppId = ref(null)
const appForm = ref({})
const appSkins = ref([])
const skinUploading = ref(false)
const skinDragover = ref(false)
const showSchemeModal = ref(false)
const editingSchemeId = ref(null)
@@ -626,12 +694,9 @@ onMounted(async () => {
const loadData = async () => {
if (!isAdmin.value) {
const [appsRes, versionsRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/versions'),
])
// 普通用户只能管理 Apps,版本号等配置均为管理员专属
const appsRes = await authFetch('/api/config/apps')
if (appsRes.ok) apps.value = await appsRes.json()
if (versionsRes.ok) versions.value = await versionsRes.json()
return
}
@@ -653,7 +718,7 @@ const loadData = async () => {
buildSettings.value = await buildRes.json()
uploadConfig.value = await uploadRes.json()
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()
}
@@ -862,9 +927,9 @@ const loadSkins = async () => {
} catch { appSkins.value = [] }
}
const uploadSkin = async (event) => {
const file = event.target.files[0]
const uploadSkinFile = async (file) => {
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); return }
const uploadKey = appForm.value.upload_key
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
skinUploading.value = true
@@ -880,7 +945,12 @@ const uploadSkin = async (event) => {
throw new Error(err.detail || '上传失败')
}
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) {
alert('上传失败: ' + e.message)
} finally {
@@ -888,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) => {
if (!confirm(`确定删除皮肤包 ${skinName}`)) return
const uploadKey = appForm.value.upload_key
@@ -906,12 +1014,20 @@ const formatSize = (bytes) => {
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 () => {
if (!appForm.value.name || !appForm.value.AppGuid) {
alert('请填写必填字段:App 名称、AppGuid')
return
}
normalizeTencentAppId()
// 清理空证书
if (appForm.value.certificates) {
Object.keys(appForm.value.certificates).forEach(key => {
@@ -948,9 +1064,18 @@ const saveApp = async () => {
const err = await res.json().catch(() => ({ detail: '保存失败' }))
throw new Error(err.detail || '保存失败')
}
showAppModal.value = false
await loadData()
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) {
alert('保存失败: ' + e.message)
}
@@ -1026,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) => {
if (!confirm(`确定删除分支「${name}」?`)) return
try {
@@ -1047,16 +1190,25 @@ const saveBuildSettings = async () => {
alert('设置已保存')
}
const saveVersions = async () => {
const saveVersions = async (track = 'release') => {
try {
const res = await authFetch('/api/config/versions', {
method: 'PUT',
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) {
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('版本号已保存')
} else {
const err = await res.json()
@@ -1160,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-danger-icon { background: #fff1f0; color: #ff4d4f; }
.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:hover { background: #e6f7ff; }
.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; }
</style>
+21 -5
View File
@@ -20,6 +20,7 @@
<option value="failed">失败</option>
<option value="cancelled">已取消</option>
</select>
<label v-if="isAdmin" class="superseded-toggle"><input v-model="showSuperseded" type="checkbox" @change="loadTasks"> 显示已覆盖</label>
</div>
</div>
<table class="config-table">
@@ -55,7 +56,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb"
@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>
<span v-else class="text-muted">-</span>
</td>
@@ -180,6 +181,7 @@ const logLines = ref([])
const logContainer = ref(null)
const showVerboseLogs = ref(false)
const qrPreview = ref(null)
const showSuperseded = ref(false)
let logWs = null
const getAppVersion = (task) => {
@@ -210,11 +212,13 @@ const filteredTasks = computed(() => {
})
})
onMounted(async () => {
const res = await authFetch('/api/tasks?limit=100')
const loadTasks = async () => {
const res = await authFetch(`/api/tasks?limit=100${showSuperseded.value ? '&include_superseded=true' : ''}`)
const data = await res.json()
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
})
}
onMounted(loadTasks)
const viewLogs = async (taskId) => {
const task = tasks.value.find(t => t.id === taskId)
@@ -308,6 +312,17 @@ const downloadDsym = (taskId) => {
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) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
}
@@ -457,6 +472,7 @@ const errorCategoryLabel = (cat) => {
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
.version-filter { width: 150px; }
.superseded-toggle { display: flex; align-items: center; gap: 4px; color: #666; font-size: 13px; white-space: nowrap; }
/* 日志操作栏 */
.log-modal-actions {
@@ -506,7 +522,7 @@ const errorCategoryLabel = (cat) => {
transition: all 0.2s;
}
.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; }
.text-muted { color: #999; font-size: 13px; }
.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>
+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"]
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):
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
assert resp.status_code == 200
@@ -172,7 +197,8 @@ def test_update_build_settings(client, tmp_config):
# ---- 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"})
assert resp.status_code == 200
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"
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
+16
View File
@@ -214,3 +214,19 @@ def test_delete_task_keeps_shared_remote_artifact(client, tmp_config):
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"}
+120 -5
View File
@@ -20,6 +20,7 @@ from backend.services.build_service import (
run_pod_install,
_patch_afnetworking_private_headers,
_cleanup_old_builds,
_mark_superseded_tasks,
_resolve_provisioning_profile,
)
from backend.services.log_streamer import LogStreamer
@@ -314,6 +315,14 @@ def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
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):
"""从服务配置读取版本号,不依赖分支源码脚本"""
build_dir = tmp_path / "build"
@@ -324,12 +333,12 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
task.scheme_id = "1"
task.build_type = "Ad_Hoc"
task.obfuscation = False
task.branch = "master"
mock_config = {
"apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
"versions": {"app_ver": "3.0.1", "build_ver": "3.0.1.0"},
}
mock_config = _versions_config(
{"app_ver": "3.0.1", "build_map": {}},
{"app_ver": "9.9.9", "build_map": {}},
)
with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir)
@@ -338,6 +347,96 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
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):
"""Web 打包不读取分支源码中的 AutoPacking 目录。"""
build_dir = tmp_path / "build"
@@ -418,3 +517,19 @@ def test_cleanup_keeps_recent(tmp_path):
assert recent_dir.exists()
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()
+20
View File
@@ -9,6 +9,7 @@ from backend.services.distribution import (
_write_download_page,
_write_manifest,
publish_ipa,
get_published_download_url,
)
@@ -119,3 +120,22 @@ def test_delete_uses_saved_url_for_legacy_artifact_name():
"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
+9 -6
View File
@@ -37,8 +37,8 @@ def test_production_rejects_default_security_settings(monkeypatch):
config.validate_production_security()
def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config):
"""普通账号管理 Apps版本号,不能读取或修改管理员配置"""
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
"""普通账号只能管理 Apps版本号等其余配置均为管理员专属"""
response = client.post("/api/users", json={
"username": "builder",
"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={
"name": "普通用户 App", "server": "测试环境",
}, headers=headers).status_code == 200
assert client.get("/api/config/versions", headers=headers).status_code == 200
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers)
assert versions.status_code == 200
assert versions.json()["build_ver"] == "2.196.0.0"
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/servers", headers=headers).status_code == 403
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403