Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64715a2c9e | ||
|
|
21542c88d7 | ||
|
|
3cb4053ab5 | ||
|
|
91e9b027e8 | ||
|
|
b6e56f5f9a | ||
|
|
b2982ae8f7 | ||
|
|
9d766a0b58 | ||
|
|
605c09c88a | ||
|
|
5014376b81 | ||
|
|
bb8a0afeac | ||
|
|
4dccc4d96b |
@@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
|
|||||||
|
|
||||||
# 打包超时时间(小时),超时自动标记失败
|
# 打包超时时间(小时),超时自动标记失败
|
||||||
BUILD_TIMEOUT_HOURS=1
|
BUILD_TIMEOUT_HOURS=1
|
||||||
|
DOWNLOAD_URL_EXPIRE_SECONDS=600
|
||||||
|
|
||||||
# ---- Watchdog 健康监测 ----
|
# ---- Watchdog 健康监测 ----
|
||||||
# 健康检查间隔(秒)
|
# 健康检查间隔(秒)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ __pycache__/
|
|||||||
|
|
||||||
# 运行时数据
|
# 运行时数据
|
||||||
backend/data/*.bak
|
backend/data/*.bak
|
||||||
|
# 运行时配置:网页 UI 改配置、每次 App_Store 打包都会写盘,不纳入版本控制。
|
||||||
|
# 结构参见仓库根目录的 config.example.json;首次部署可 cp 一份作为初始种子。
|
||||||
config.json
|
config.json
|
||||||
|
backend/data/config.json
|
||||||
ReadoorBranches/
|
ReadoorBranches/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu
|
|||||||
- `backend/deps.py` — JWT auth dependency used by all protected routes
|
- `backend/deps.py` — JWT auth dependency used by all protected routes
|
||||||
- `backend/config.py` — loads .env, defines all config constants
|
- `backend/config.py` — loads .env, defines all config constants
|
||||||
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
|
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
|
||||||
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
|
- `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI
|
||||||
|
- Versions are per-track: `release` (master/develop, build number auto-increments on App_Store) and `feature` (self-test, never increments); each branch's track lives in `config["branch_track"]`
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
|
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
|
||||||
- Admin-only routes: user management (`/api/users/*`)
|
- Admin-only routes: user management (`/api/users/*`) and all of `/api/config/*` except `/api/config/apps*`
|
||||||
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
|
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
|
||||||
- Chinese UI and documentation throughout
|
- Chinese UI and documentation throughout
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- [Watchdog 健康监测](#watchdog-健康监测)
|
- [Watchdog 健康监测](#watchdog-健康监测)
|
||||||
- [macOS 开机自启](#macos-开机自启)
|
- [macOS 开机自启](#macos-开机自启)
|
||||||
- [开发模式](#开发模式)
|
- [开发模式](#开发模式)
|
||||||
|
- [推送代码到 Git 服务器](#推送代码到-git-服务器)
|
||||||
- [自动化测试](#自动化测试)
|
- [自动化测试](#自动化测试)
|
||||||
- [项目结构](#项目结构)
|
- [项目结构](#项目结构)
|
||||||
- [常见问题](#常见问题)
|
- [常见问题](#常见问题)
|
||||||
@@ -63,11 +64,24 @@ ADMIN_PASSWORD=your_secure_password
|
|||||||
|
|
||||||
### 3. 构建与启动
|
### 3. 构建与启动
|
||||||
|
|
||||||
|
推荐一键部署(构建 + 启动 + 健康监测,一步到位):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键部署:build + start + watchdog(强制重来)
|
||||||
|
./deploy.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
`up` 会自动构建前端、(强制)重启后端并**轮询 `/api/health` 直到就绪**才返回,再拉起 watchdog,最后打印状态。适合首次部署和更新代码后重新上线。
|
||||||
|
|
||||||
|
> ⚠️ `up`/`start` 采用「强制重来」语义:若检测到旧服务会先停再起,正在进行的打包任务会被中断。
|
||||||
|
|
||||||
|
也可以分步执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 一键构建(安装依赖 + 构建前端)
|
# 一键构建(安装依赖 + 构建前端)
|
||||||
./deploy.sh build
|
./deploy.sh build
|
||||||
|
|
||||||
# 启动服务
|
# 启动服务(等待就绪;端口被非本项目进程占用时会明确报错)
|
||||||
./deploy.sh start
|
./deploy.sh start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -156,19 +170,39 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|
|||||||
|
|
||||||
### Web 管理配置
|
### Web 管理配置
|
||||||
|
|
||||||
以下配置通过管理后台 Web 界面管理(存储在 `config.json`):
|
以下配置通过管理后台 Web 界面管理(存储在 `backend/data/config.json`):
|
||||||
|
|
||||||
首次部署时,如果项目根目录下存在 `config.json`,系统会自动导入其中的 `apps` 和 `schemes` 作为初始配置;导入后即可继续在后台增删改。
|
> ⚠️ `config.json` 是**运行时数据文件**(网页改配置、每次 App_Store 打包写入构建号都会改写它),**不纳入版本控制**,`git pull` 不会覆盖它。仓库仅提供结构模板 `config.example.json`。
|
||||||
|
>
|
||||||
|
> 首次部署时,若项目根目录下存在 `config.json`,系统会自动将其复制为 `backend/data/config.json` 作为初始配置。可从模板起步:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> cp config.example.json config.json # 按需填入真实的 apps / servers / upload 等,再启动
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 导入后即可继续在后台增删改。
|
||||||
|
|
||||||
| 配置项 | 说明 |
|
| 配置项 | 说明 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
|
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
|
||||||
| Scheme 管理 | 打包 Scheme 配置 |
|
| Scheme 管理 | 打包 Scheme 配置 |
|
||||||
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
|
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
|
||||||
| 分支管理 | 可打包的代码分支列表 |
|
| 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道 |
|
||||||
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码 |
|
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
|
||||||
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
|
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
|
||||||
|
|
||||||
|
### 版本号与版本轨道
|
||||||
|
|
||||||
|
版本号按 **轨道(track)** 分开管理,两条轨道的 App_Ver 各自独立填写、互不影响:
|
||||||
|
|
||||||
|
| 轨道 | 适用分支 | 构建号行为 |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| `release`(上架) | master、develop(共用同一套版本号与构建号序列) | App_Store 打包时按当前 App_Ver 自动 +1 |
|
||||||
|
| `feature`(自测) | feature/* 等其他分支 | 永不自增,始终使用配置页填写的值(默认 `.0`) |
|
||||||
|
|
||||||
|
- 分支归属默认按名字推断(master/main/develop/dev/release-*/hotfix-* → release,其余 → feature),可在「分支管理」页逐个改。
|
||||||
|
- 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。
|
||||||
|
|
||||||
### 服务端自动化资源
|
### 服务端自动化资源
|
||||||
|
|
||||||
项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
|
项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
|
||||||
@@ -179,6 +213,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
|
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
|
||||||
| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) |
|
| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) |
|
||||||
|
| `WATCHDOG_STARTUP_GRACE` | `10` | 首次检查前的宽限(秒),避开服务正常启动窗口,防止误重启 |
|
||||||
|
| `STARTUP_READY_TIMEOUT` | `20` | `start` 后等待 `/api/health` 就绪的最长时间(秒) |
|
||||||
|
| `WATCHDOG_RESTART_MODE` | `nohup` | 重启方式:`nohup`(PID 文件托管)或 `launchd`(用 `launchctl kickstart` 交回 launchd 托管,避免与 KeepAlive 抢端口) |
|
||||||
| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 |
|
| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 |
|
||||||
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
|
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
|
||||||
|
|
||||||
@@ -196,8 +233,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| `up` | **一键部署**:build + start + watchdog(强制重来),启动后等待就绪 |
|
||||||
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
|
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
|
||||||
| `start` | 后台启动服务 |
|
| `start` | 后台启动服务(强制重启并等待 `/api/health` 就绪;端口被非本项目进程占用会报错) |
|
||||||
| `stop` | 停止服务和 watchdog |
|
| `stop` | 停止服务和 watchdog |
|
||||||
| `restart` | 重启服务 |
|
| `restart` | 重启服务 |
|
||||||
| `status` | 查看服务和 watchdog 运行状态 |
|
| `status` | 查看服务和 watchdog 运行状态 |
|
||||||
@@ -209,18 +247,17 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|
|||||||
### 常用操作
|
### 常用操作
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 首次部署
|
# 首次部署(一键:build + start + watchdog)
|
||||||
./deploy.sh build && ./deploy.sh start
|
./deploy.sh up
|
||||||
|
|
||||||
# 查看状态
|
# 查看状态
|
||||||
./deploy.sh status
|
./deploy.sh status
|
||||||
|
|
||||||
# 更新代码后重新部署
|
# 更新代码后重新部署
|
||||||
git pull
|
git pull
|
||||||
./deploy.sh build
|
./deploy.sh up
|
||||||
./deploy.sh restart
|
|
||||||
|
|
||||||
# 启用健康监测
|
# 单独启用健康监测
|
||||||
./deploy.sh watchdog
|
./deploy.sh watchdog
|
||||||
|
|
||||||
# 停止所有服务
|
# 停止所有服务
|
||||||
@@ -252,7 +289,7 @@ logs/
|
|||||||
| 角色 | 权限 |
|
| 角色 | 权限 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) |
|
| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) |
|
||||||
| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) |
|
| 普通用户 | 打包、查看历史、管理 Apps 配置(不可修改版本号及其余配置、不可管理用户) |
|
||||||
|
|
||||||
### 管理用户
|
### 管理用户
|
||||||
|
|
||||||
@@ -370,6 +407,31 @@ launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 推送代码到 Git 服务器
|
||||||
|
|
||||||
|
本项目托管在自建 Git 服务(`origin` 走 HTTPS),推送需要账号凭据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 提交改动
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: 描述你的改动"
|
||||||
|
|
||||||
|
# 推送到远程 main
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
首次推送会提示输入用户名和密码/访问令牌。若不想每次都输入,可启用 macOS 钥匙串缓存凭据(只需配置一次):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global credential.helper osxkeychain
|
||||||
|
```
|
||||||
|
|
||||||
|
配置后,下一次 `git push` 输入的账号密码会被安全保存到钥匙串,后续免输入。
|
||||||
|
|
||||||
|
> 若 Git 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 自动化测试
|
## 自动化测试
|
||||||
|
|
||||||
### 运行测试
|
### 运行测试
|
||||||
|
|||||||
@@ -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_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRE_HOURS = 24
|
JWT_EXPIRE_HOURS = 24
|
||||||
|
# 受控 IPA 下载链接的 OSS 签名有效期(秒)。
|
||||||
|
DOWNLOAD_URL_EXPIRE_SECONDS = int(os.getenv("DOWNLOAD_URL_EXPIRE_SECONDS", "600"))
|
||||||
|
|
||||||
|
|
||||||
def validate_production_security() -> None:
|
def validate_production_security() -> None:
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ def _migrate_db():
|
|||||||
migrations = [
|
migrations = [
|
||||||
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
|
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
|
||||||
("tasks", "error_category", "VARCHAR"),
|
("tasks", "error_category", "VARCHAR"),
|
||||||
|
("tasks", "superseded_at", "TIMESTAMP"),
|
||||||
]
|
]
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import config, apps, tasks, auth, users
|
from .routers import config, apps, tasks, auth, users, operations
|
||||||
from .services.log_streamer import log_streamer
|
from .services.log_streamer import log_streamer
|
||||||
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
|
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
|
||||||
from .deps import decode_current_user
|
from .deps import decode_current_user
|
||||||
@@ -48,6 +48,7 @@ app.include_router(users.router)
|
|||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(apps.router)
|
app.include_router(apps.router)
|
||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
|
app.include_router(operations.router)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/tasks/{task_id}")
|
@app.websocket("/ws/tasks/{task_id}")
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class Task(Base):
|
|||||||
created_at = Column(TIMESTAMP, default=datetime.utcnow)
|
created_at = Column(TIMESTAMP, default=datetime.utcnow)
|
||||||
started_at = Column(TIMESTAMP)
|
started_at = Column(TIMESTAMP)
|
||||||
completed_at = Column(TIMESTAMP)
|
completed_at = Column(TIMESTAMP)
|
||||||
|
# 同一 OSS 对象被后续成功打包覆盖时标记;默认历史列表不展示。
|
||||||
|
superseded_at = Column(TIMESTAMP)
|
||||||
|
|
||||||
# 产物
|
# 产物
|
||||||
ipa_path = Column(String)
|
ipa_path = Column(String)
|
||||||
@@ -60,3 +62,22 @@ class User(Base):
|
|||||||
password_hash = Column(String, nullable=False)
|
password_hash = Column(String, nullable=False)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
created_at = Column(TIMESTAMP, default=datetime.utcnow)
|
created_at = Column(TIMESTAMP, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLog(Base):
|
||||||
|
"""用户关键操作的审计与统计记录。"""
|
||||||
|
__tablename__ = "operation_logs"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.utcnow, nullable=False)
|
||||||
|
username = Column(String, nullable=False)
|
||||||
|
is_admin = Column(Boolean, default=False, nullable=False)
|
||||||
|
action = Column(String, nullable=False)
|
||||||
|
task_id = Column(String)
|
||||||
|
app_id = Column(String)
|
||||||
|
app_name = Column(String)
|
||||||
|
resource_type = Column(String)
|
||||||
|
resource_name = Column(String)
|
||||||
|
detail_json = Column(Text)
|
||||||
|
client_ip = Column(String)
|
||||||
|
user_agent = Column(String)
|
||||||
|
|||||||
+239
-25
@@ -26,10 +26,9 @@ from ..deps import get_current_user, require_admin
|
|||||||
|
|
||||||
|
|
||||||
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
|
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
|
||||||
"""普通用户可管理 Apps 与版本号;其余配置及凭据只允许管理员访问。"""
|
"""普通用户只可管理 Apps;版本号、其余配置及凭据只允许管理员访问。"""
|
||||||
path = request.url.path.rstrip("/")
|
path = request.url.path.rstrip("/")
|
||||||
if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
|
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
|
||||||
or path == "/api/config/versions"):
|
|
||||||
return user
|
return user
|
||||||
return require_admin(user)
|
return require_admin(user)
|
||||||
|
|
||||||
@@ -90,10 +89,117 @@ DEFAULT_SERVERS = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_VERSIONS = {
|
DEFAULT_APP_VER = "2.180.0"
|
||||||
"app_ver": "2.180.0",
|
|
||||||
"build_ver": "2.180.0.0",
|
# 版本轨道:上架分支(master/develop)共用 release,自测分支(feature/*)用 feature。
|
||||||
}
|
# 两条轨道的 App_Ver 各自独立填写,互不影响。
|
||||||
|
TRACK_RELEASE = "release"
|
||||||
|
TRACK_FEATURE = "feature"
|
||||||
|
TRACKS = (TRACK_RELEASE, TRACK_FEATURE)
|
||||||
|
|
||||||
|
# 按名字推断轨道的默认规则;config["branch_track"] 中的显式配置优先。
|
||||||
|
RELEASE_BRANCH_NAMES = {"master", "main", "develop", "dev"}
|
||||||
|
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_track_for_branch(branch: str) -> str:
|
||||||
|
"""按分支名推断所属版本轨道。"""
|
||||||
|
name = (branch or "").strip().lower()
|
||||||
|
if name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES):
|
||||||
|
return TRACK_RELEASE
|
||||||
|
return TRACK_FEATURE
|
||||||
|
|
||||||
|
|
||||||
|
def _new_track(app_ver: str = DEFAULT_APP_VER) -> dict:
|
||||||
|
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
|
||||||
|
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
|
||||||
|
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
|
||||||
|
return {"app_ver": app_ver, "build_map": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_versions(config: dict) -> bool:
|
||||||
|
"""确保 versions 为双轨结构,并迁移旧式单轨/单值 build_ver 配置。
|
||||||
|
|
||||||
|
旧式 ``{"app_ver": ..., "build_map": ...}`` 整体迁入 release 轨;
|
||||||
|
feature 轨用同一个 App_Ver 初始化,构建号从 .0 起且永不自增。
|
||||||
|
"""
|
||||||
|
versions = config.get("versions")
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if not isinstance(versions, dict):
|
||||||
|
config["versions"] = {"tracks": {TRACK_RELEASE: _new_track(), TRACK_FEATURE: _new_track()}}
|
||||||
|
return True
|
||||||
|
|
||||||
|
tracks = versions.get("tracks")
|
||||||
|
if not isinstance(tracks, dict):
|
||||||
|
# 旧式单轨结构 → release 轨
|
||||||
|
legacy_app_ver = versions.get("app_ver") or DEFAULT_APP_VER
|
||||||
|
legacy_build_map = versions.get("build_map")
|
||||||
|
if not isinstance(legacy_build_map, dict):
|
||||||
|
legacy_build_map = {}
|
||||||
|
# 更旧的单值形式 "2.195.0.3" → {"2.195.0": 3}
|
||||||
|
old = versions.get("build_ver")
|
||||||
|
if isinstance(old, str):
|
||||||
|
parts = old.split(".")
|
||||||
|
if len(parts) >= 4 and parts[3].isdigit():
|
||||||
|
legacy_build_map[".".join(parts[:3])] = int(parts[3])
|
||||||
|
versions.pop("app_ver", None)
|
||||||
|
versions.pop("build_map", None)
|
||||||
|
versions.pop("build_ver", None)
|
||||||
|
tracks = {
|
||||||
|
TRACK_RELEASE: {"app_ver": legacy_app_ver, "build_map": legacy_build_map},
|
||||||
|
TRACK_FEATURE: _new_track(legacy_app_ver),
|
||||||
|
}
|
||||||
|
versions["tracks"] = tracks
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
for name in TRACKS:
|
||||||
|
track = tracks.get(name)
|
||||||
|
if not isinstance(track, dict):
|
||||||
|
tracks[name] = _new_track()
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
if not track.get("app_ver"):
|
||||||
|
track["app_ver"] = DEFAULT_APP_VER
|
||||||
|
changed = True
|
||||||
|
if not isinstance(track.get("build_map"), dict):
|
||||||
|
track["build_map"] = {}
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 分支归属:只存显式配置,缺省按名字规则推断
|
||||||
|
branch_track = config.get("branch_track")
|
||||||
|
if not isinstance(branch_track, dict):
|
||||||
|
branch_track = {}
|
||||||
|
config["branch_track"] = branch_track
|
||||||
|
changed = True
|
||||||
|
for branch in config.get("branches", []) or []:
|
||||||
|
if branch_track.get(branch) not in TRACKS:
|
||||||
|
branch_track[branch] = _default_track_for_branch(branch)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def get_track_name(config: dict, branch: str) -> str:
|
||||||
|
"""取分支所属的版本轨道名。"""
|
||||||
|
track = (config.get("branch_track") or {}).get(branch)
|
||||||
|
return track if track in TRACKS else _default_track_for_branch(branch)
|
||||||
|
|
||||||
|
|
||||||
|
def get_track(config: dict, track_name: str) -> dict:
|
||||||
|
"""取某条轨道的版本记录(不存在时返回默认值,不写回配置)。"""
|
||||||
|
tracks = (config.get("versions") or {}).get("tracks") or {}
|
||||||
|
track = tracks.get(track_name)
|
||||||
|
if not isinstance(track, dict):
|
||||||
|
return _new_track()
|
||||||
|
return track
|
||||||
|
|
||||||
|
|
||||||
|
def track_build_ver(track: dict) -> str:
|
||||||
|
"""该轨道当前 App_Ver 已用到的构建号,形如 2.195.0.5(尚未打包则 .0)。"""
|
||||||
|
app_ver = track.get("app_ver") or DEFAULT_APP_VER
|
||||||
|
build_map = track.get("build_map") or {}
|
||||||
|
return f"{app_ver}.{int(build_map.get(app_ver, 0))}"
|
||||||
|
|
||||||
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
|
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
|
||||||
LEGACY_SERVER_PREFIXES = {
|
LEGACY_SERVER_PREFIXES = {
|
||||||
@@ -148,6 +254,14 @@ def _apply_special_app_prefix_override(app: dict) -> None:
|
|||||||
app["app_id_prefix_override"] = 1
|
app["app_id_prefix_override"] = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tencent_app_id(app: dict) -> None:
|
||||||
|
"""腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)。"""
|
||||||
|
value = str(app.get("tencent") or "").strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
app["tencent"] = value if value.startswith("tencent") else f"tencent{value}"
|
||||||
|
|
||||||
|
|
||||||
def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
|
def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
|
||||||
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
|
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
|
||||||
prefix = app.get("app_id_prefix_override")
|
prefix = app.get("app_id_prefix_override")
|
||||||
@@ -247,13 +361,12 @@ def load_config() -> dict:
|
|||||||
config["branches"] = ["main"]
|
config["branches"] = ["main"]
|
||||||
if "upload" not in config:
|
if "upload" not in config:
|
||||||
config["upload"] = DEFAULT_UPLOAD
|
config["upload"] = DEFAULT_UPLOAD
|
||||||
if "versions" not in config:
|
versions_changed = _ensure_versions(config)
|
||||||
config["versions"] = DEFAULT_VERSIONS.copy()
|
|
||||||
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
|
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
|
||||||
prefixes_changed = _ensure_server_id_prefixes(config)
|
prefixes_changed = _ensure_server_id_prefixes(config)
|
||||||
overrides_changed = _ensure_special_app_prefix_overrides(config)
|
overrides_changed = _ensure_special_app_prefix_overrides(config)
|
||||||
keys_changed = _ensure_upload_keys(config)
|
keys_changed = _ensure_upload_keys(config)
|
||||||
if prefixes_changed or overrides_changed or keys_changed:
|
if versions_changed or prefixes_changed or overrides_changed or keys_changed:
|
||||||
save_config(config)
|
save_config(config)
|
||||||
# 迁移旧式目录皮肤为 ZIP
|
# 迁移旧式目录皮肤为 ZIP
|
||||||
if _migrate_old_themes(config):
|
if _migrate_old_themes(config):
|
||||||
@@ -307,6 +420,7 @@ async def create_app(app: dict):
|
|||||||
|
|
||||||
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
|
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
|
||||||
_apply_special_app_prefix_override(app)
|
_apply_special_app_prefix_override(app)
|
||||||
|
_normalize_tencent_app_id(app)
|
||||||
new_id = _next_app_id(apps, config.get("servers", {}), app)
|
new_id = _next_app_id(apps, config.get("servers", {}), app)
|
||||||
|
|
||||||
# 自动生成 upload_key
|
# 自动生成 upload_key
|
||||||
@@ -316,7 +430,7 @@ async def create_app(app: dict):
|
|||||||
apps[new_id] = app
|
apps[new_id] = app
|
||||||
config["apps"] = apps
|
config["apps"] = apps
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"id": new_id, "message": "App 已创建"}
|
return {"id": new_id, "upload_key": app["upload_key"], "message": "App 已创建"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/apps/{app_id}")
|
@router.put("/apps/{app_id}")
|
||||||
@@ -329,6 +443,7 @@ async def update_app(app_id: str, app: dict):
|
|||||||
if app_id not in apps:
|
if app_id not in apps:
|
||||||
raise HTTPException(status_code=404, detail="App 不存在")
|
raise HTTPException(status_code=404, detail="App 不存在")
|
||||||
|
|
||||||
|
_normalize_tencent_app_id(app)
|
||||||
apps[app_id] = app
|
apps[app_id] = app
|
||||||
config["apps"] = apps
|
config["apps"] = apps
|
||||||
save_config(config)
|
save_config(config)
|
||||||
@@ -410,6 +525,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
|
|||||||
return {"message": "皮肤包上传成功", "name": file.filename}
|
return {"message": "皮肤包上传成功", "name": file.filename}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/skins/{upload_key}/{skin_name}")
|
||||||
|
async def replace_skin(upload_key: str, skin_name: str, file: UploadFile = File(...)):
|
||||||
|
"""替换已有皮肤包内容,保持原文件名不变。
|
||||||
|
|
||||||
|
APP 配置的 theme 通过文件名(去掉 .zip)引用皮肤包,若上传新文件名会新增一个皮肤而非更新,
|
||||||
|
导致原引用失效。此接口就地覆盖指定皮肤,确保被引用的皮肤可以更新。
|
||||||
|
"""
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
app_id, _ = _find_app_by_upload_key(config, upload_key)
|
||||||
|
if not app_id:
|
||||||
|
raise HTTPException(status_code=404, detail="App 不存在")
|
||||||
|
|
||||||
|
app_skins_dir = SKINS_DIR / upload_key
|
||||||
|
skin_path = app_skins_dir / skin_name
|
||||||
|
if not skin_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="皮肤包不存在")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
with open(skin_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return {"message": "皮肤包已更新", "name": skin_name}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/skins/{upload_key}/{skin_name}")
|
@router.delete("/skins/{upload_key}/{skin_name}")
|
||||||
async def delete_skin(upload_key: str, skin_name: str):
|
async def delete_skin(upload_key: str, skin_name: str):
|
||||||
"""删除指定皮肤包"""
|
"""删除指定皮肤包"""
|
||||||
@@ -502,11 +644,15 @@ async def get_branches():
|
|||||||
|
|
||||||
@router.post("/branches")
|
@router.post("/branches")
|
||||||
async def add_branch(data: dict):
|
async def add_branch(data: dict):
|
||||||
"""新增分支"""
|
"""新增分支,可指定所属版本轨道(缺省按分支名推断)"""
|
||||||
name = data.get("name", "").strip()
|
name = data.get("name", "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="分支名称不能为空")
|
raise HTTPException(status_code=400, detail="分支名称不能为空")
|
||||||
|
|
||||||
|
track = (data.get("track") or "").strip() or _default_track_for_branch(name)
|
||||||
|
if track not in TRACKS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
|
||||||
|
|
||||||
async with _config_lock:
|
async with _config_lock:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
branches = config.get("branches", ["main"])
|
branches = config.get("branches", ["main"])
|
||||||
@@ -514,11 +660,32 @@ async def add_branch(data: dict):
|
|||||||
raise HTTPException(status_code=400, detail="分支已存在")
|
raise HTTPException(status_code=400, detail="分支已存在")
|
||||||
branches.append(name)
|
branches.append(name)
|
||||||
config["branches"] = branches
|
config["branches"] = branches
|
||||||
|
config.setdefault("branch_track", {})[name] = track
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"message": "分支已添加"}
|
return {"message": "分支已添加", "track": track}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/branches/{branch_name}")
|
@router.put("/branches/track")
|
||||||
|
async def update_branch_track(data: dict):
|
||||||
|
"""修改分支所属的版本轨道(release=上架,feature=自测)。
|
||||||
|
|
||||||
|
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
|
||||||
|
"""
|
||||||
|
branch = (data.get("branch") or "").strip()
|
||||||
|
track = (data.get("track") or "").strip()
|
||||||
|
if track not in TRACKS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
|
||||||
|
|
||||||
|
async with _config_lock:
|
||||||
|
config = load_config()
|
||||||
|
if branch not in config.get("branches", []):
|
||||||
|
raise HTTPException(status_code=404, detail="分支不存在")
|
||||||
|
config.setdefault("branch_track", {})[branch] = track
|
||||||
|
save_config(config)
|
||||||
|
return {"message": "分支版本轨道已更新", "branch": branch, "track": track}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/branches/{branch_name:path}")
|
||||||
async def delete_branch(branch_name: str):
|
async def delete_branch(branch_name: str):
|
||||||
"""删除分支"""
|
"""删除分支"""
|
||||||
async with _config_lock:
|
async with _config_lock:
|
||||||
@@ -528,6 +695,7 @@ async def delete_branch(branch_name: str):
|
|||||||
raise HTTPException(status_code=404, detail="分支不存在")
|
raise HTTPException(status_code=404, detail="分支不存在")
|
||||||
branches.remove(branch_name)
|
branches.remove(branch_name)
|
||||||
config["branches"] = branches
|
config["branches"] = branches
|
||||||
|
config.get("branch_track", {}).pop(branch_name, None)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"message": "分支已删除"}
|
return {"message": "分支已删除"}
|
||||||
|
|
||||||
@@ -579,32 +747,78 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
|
|||||||
# 版本号配置 API
|
# 版本号配置 API
|
||||||
@router.get("/versions")
|
@router.get("/versions")
|
||||||
async def get_versions():
|
async def get_versions():
|
||||||
"""获取服务端统一管理的 App_Ver 和 Build_Ver"""
|
"""获取两条版本轨道各自的 App_Ver 及其当前构建号。
|
||||||
|
|
||||||
|
build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
|
||||||
|
仅供展示;实际递增只发生在 release 轨的 App_Store 打包时,feature 轨永不自增。
|
||||||
|
顶层 app_ver / build_ver 为 release 轨的镜像,保留给旧客户端。
|
||||||
|
"""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
versions = config.get("versions", DEFAULT_VERSIONS.copy())
|
tracks = {}
|
||||||
|
for name in TRACKS:
|
||||||
|
track = get_track(config, name)
|
||||||
|
tracks[name] = {
|
||||||
|
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
|
||||||
|
"build_ver": track_build_ver(track),
|
||||||
|
"build_map": track.get("build_map") or {},
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"app_ver": versions.get("app_ver", DEFAULT_VERSIONS["app_ver"]),
|
**tracks[TRACK_RELEASE],
|
||||||
"build_ver": versions.get("build_ver", DEFAULT_VERSIONS["build_ver"]),
|
"tracks": tracks,
|
||||||
|
"branch_track": {
|
||||||
|
branch: get_track_name(config, branch)
|
||||||
|
for branch in config.get("branches", []) or []
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/versions")
|
@router.put("/versions")
|
||||||
async def update_versions(data: dict):
|
async def update_versions(data: dict):
|
||||||
"""更新 App_Ver,并将 Build_Ver 重置为对应的 .0。"""
|
"""更新某条轨道的 App_Ver,并可选地手动设置该版本号的构建号。
|
||||||
|
|
||||||
|
每个 App_Ver 的构建号在所属轨道内独立保存于 build_map 各自递增,因此来回切换
|
||||||
|
版本号(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
|
||||||
|
|
||||||
|
- track:release(master/develop,上架用)或 feature(自测用),缺省 release。
|
||||||
|
- 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。
|
||||||
|
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
|
||||||
|
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
|
||||||
|
"""
|
||||||
|
track_name = (data.get("track") or TRACK_RELEASE).strip()
|
||||||
|
if track_name not in TRACKS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track_name}")
|
||||||
|
|
||||||
app_ver = data.get("app_ver", "").strip()
|
app_ver = data.get("app_ver", "").strip()
|
||||||
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
|
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
|
||||||
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
|
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
|
||||||
|
|
||||||
build_ver = f"{app_ver}.0"
|
build_ver = data.get("build_ver")
|
||||||
|
build_no = None
|
||||||
|
if build_ver is not None and str(build_ver).strip():
|
||||||
|
build_ver = str(build_ver).strip()
|
||||||
|
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", build_ver):
|
||||||
|
raise HTTPException(status_code=400, detail="Build_Ver 必须为四段数字,例如 2.196.0.3")
|
||||||
|
prefix, _, last = build_ver.rpartition(".")
|
||||||
|
if prefix != app_ver:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Build_Ver 前三位须与 App_Ver 一致(应为 {app_ver}.x)")
|
||||||
|
build_no = int(last)
|
||||||
|
|
||||||
async with _config_lock:
|
async with _config_lock:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config["versions"] = {
|
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
|
||||||
"app_ver": app_ver,
|
track = tracks.setdefault(track_name, _new_track())
|
||||||
"build_ver": build_ver,
|
track["app_ver"] = app_ver
|
||||||
}
|
build_map = track.setdefault("build_map", {})
|
||||||
|
if build_no is not None:
|
||||||
|
build_map[app_ver] = build_no
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver}
|
return {
|
||||||
|
"message": "版本号已更新",
|
||||||
|
"track": track_name,
|
||||||
|
"app_ver": app_ver,
|
||||||
|
"build_ver": track_build_ver(track),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# 上传配置 API
|
# 上传配置 API
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -6,7 +6,7 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(ge
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TaskResponse)
|
@router.post("", response_model=TaskResponse)
|
||||||
async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""创建打包任务"""
|
"""创建打包任务"""
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
|
|
||||||
@@ -63,6 +63,9 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
|||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
db.add(db_task)
|
db.add(db_task)
|
||||||
|
from ..services.audit_log import record_operation
|
||||||
|
record_operation(db, user, "build_created", request=request, task=db_task,
|
||||||
|
detail={"branch": task.branch, "build_type": task.build_type})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_task)
|
db.refresh(db_task)
|
||||||
|
|
||||||
@@ -100,12 +103,19 @@ def _enrich_tasks(tasks):
|
|||||||
@router.get("", response_model=List[TaskResponse])
|
@router.get("", response_model=List[TaskResponse])
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
status: str = None,
|
status: str = None,
|
||||||
|
include_superseded: bool = False,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取任务列表"""
|
"""获取任务列表"""
|
||||||
query = db.query(Task)
|
query = db.query(Task)
|
||||||
|
if include_superseded:
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||||
|
else:
|
||||||
|
query = query.filter(Task.superseded_at.is_(None))
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Task.status == status)
|
query = query.filter(Task.status == status)
|
||||||
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
|
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
@@ -122,7 +132,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}")
|
@router.delete("/{task_id}")
|
||||||
async def cancel_task(task_id: str, db: Session = Depends(get_db)):
|
async def cancel_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""取消任务"""
|
"""取消任务"""
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
@@ -132,6 +142,8 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=400, detail="任务已完成或已取消")
|
raise HTTPException(status_code=400, detail="任务已完成或已取消")
|
||||||
|
|
||||||
task.status = "cancelled"
|
task.status = "cancelled"
|
||||||
|
from ..services.audit_log import record_operation
|
||||||
|
record_operation(db, user, "build_cancelled", request=request, task=task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 尝试取消运行中的任务
|
# 尝试取消运行中的任务
|
||||||
@@ -153,7 +165,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}/delete")
|
@router.delete("/{task_id}/delete")
|
||||||
async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
async def delete_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""删除打包记录"""
|
"""删除打包记录"""
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -199,6 +211,8 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
|||||||
if build_path.exists():
|
if build_path.exists():
|
||||||
shutil.rmtree(build_path, ignore_errors=True)
|
shutil.rmtree(build_path, ignore_errors=True)
|
||||||
|
|
||||||
|
from ..services.audit_log import record_operation
|
||||||
|
record_operation(db, user, "task_deleted", request=request, task=task)
|
||||||
db.delete(task)
|
db.delete(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -243,8 +257,35 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
|||||||
return {"log": ""}
|
return {"log": ""}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}/download-link")
|
||||||
|
async def get_download_link(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""记录已登录用户发起 IPA 下载,并返回 OSS 公开链接。"""
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
if not task.oss_url:
|
||||||
|
raise HTTPException(status_code=404, detail="下载文件不存在")
|
||||||
|
try:
|
||||||
|
from .config import load_config
|
||||||
|
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
|
||||||
|
from ..services.distribution import DistributionError, get_published_download_url
|
||||||
|
download_url = get_published_download_url(
|
||||||
|
json.loads(task.config_json or "{}"),
|
||||||
|
load_config().get("upload", {}),
|
||||||
|
task.oss_url,
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, DistributionError) as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
|
||||||
|
from ..services.audit_log import record_operation
|
||||||
|
record_operation(db, user, "ipa_download_requested", request=request, task=task,
|
||||||
|
resource_type="ipa", resource_name=task.oss_url,
|
||||||
|
detail={"controlled": True})
|
||||||
|
db.commit()
|
||||||
|
return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}/dsym")
|
@router.get("/{task_id}/dsym")
|
||||||
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""下载 dSYM 文件"""
|
"""下载 dSYM 文件"""
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -255,6 +296,10 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
if not task.dsym_path:
|
if not task.dsym_path:
|
||||||
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
|
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
|
||||||
|
from ..services.audit_log import record_operation
|
||||||
|
record_operation(db, user, "dsym_downloaded", request=request, task=task,
|
||||||
|
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
dsym_path = task.dsym_path
|
dsym_path = task.dsym_path
|
||||||
if not os.path.exists(dsym_path):
|
if not os.path.exists(dsym_path):
|
||||||
@@ -275,13 +320,17 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}/obfuscation-maps")
|
@router.get("/{task_id}/obfuscation-maps")
|
||||||
async def download_obfuscation_maps(task_id: str, db: Session = Depends(get_db)):
|
async def download_obfuscation_maps(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""下载混淆映射表"""
|
"""下载混淆映射表"""
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
if not task.obfuscation_maps_path:
|
if not task.obfuscation_maps_path:
|
||||||
raise HTTPException(status_code=404, detail="混淆映射表不存在")
|
raise HTTPException(status_code=404, detail="混淆映射表不存在")
|
||||||
|
from ..services.audit_log import record_operation
|
||||||
|
record_operation(db, user, "obfuscation_maps_downloaded", request=request, task=task,
|
||||||
|
resource_type="obfuscation_maps", resource_name=os.path.basename(task.obfuscation_maps_path))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
maps_path = task.obfuscation_maps_path
|
maps_path = task.obfuscation_maps_path
|
||||||
if not os.path.exists(maps_path):
|
if not os.path.exists(maps_path):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class TaskResponse(BaseModel):
|
|||||||
created_at: Optional[datetime]
|
created_at: Optional[datetime]
|
||||||
started_at: Optional[datetime]
|
started_at: Optional[datetime]
|
||||||
completed_at: Optional[datetime]
|
completed_at: Optional[datetime]
|
||||||
|
superseded_at: Optional[datetime] = None
|
||||||
ipa_path: Optional[str]
|
ipa_path: Optional[str]
|
||||||
oss_url: Optional[str]
|
oss_url: Optional[str]
|
||||||
dsym_path: Optional[str]
|
dsym_path: Optional[str]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
))
|
||||||
@@ -6,7 +6,7 @@ import plistlib
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@@ -214,6 +214,7 @@ def _cleanup_old_builds(db):
|
|||||||
|
|
||||||
base = BUILD_BASE_DIR
|
base = BUILD_BASE_DIR
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
|
_cleanup_superseded_tasks(db)
|
||||||
return
|
return
|
||||||
for d in base.iterdir():
|
for d in base.iterdir():
|
||||||
if d.is_dir() and d.name.startswith("build_readoor_"):
|
if d.is_dir() and d.name.startswith("build_readoor_"):
|
||||||
@@ -222,6 +223,50 @@ def _cleanup_old_builds(db):
|
|||||||
shutil.rmtree(d)
|
shutil.rmtree(d)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_cleanup_superseded_tasks(db)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_superseded_tasks(db):
|
||||||
|
"""7 天后删除已被新 OSS 包覆盖的历史记录及仅本地保留的文件。"""
|
||||||
|
from ..models import Task
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=7)
|
||||||
|
tasks = db.query(Task).filter(
|
||||||
|
Task.superseded_at.isnot(None), Task.superseded_at < cutoff,
|
||||||
|
).all()
|
||||||
|
for task in tasks:
|
||||||
|
for path_value in (task.build_dir, task.dsym_path, task.obfuscation_maps_path):
|
||||||
|
if not path_value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path = Path(path_value)
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
elif path.exists():
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
log_path = Path(__file__).parent.parent / "logs" / f"{task.id}.log"
|
||||||
|
try:
|
||||||
|
log_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
db.delete(task)
|
||||||
|
if tasks:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_superseded_tasks(db, latest_task):
|
||||||
|
"""标记同一 OSS 对象的旧成功记录;OSS 文件由最新任务覆盖。"""
|
||||||
|
if not latest_task.oss_url:
|
||||||
|
return
|
||||||
|
from ..models import Task
|
||||||
|
db.query(Task).filter(
|
||||||
|
Task.id != latest_task.id,
|
||||||
|
Task.status == "completed",
|
||||||
|
Task.oss_url == latest_task.oss_url,
|
||||||
|
Task.superseded_at.is_(None),
|
||||||
|
).update({Task.superseded_at: datetime.utcnow()}, synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_old_logs():
|
def _cleanup_old_logs():
|
||||||
@@ -439,6 +484,7 @@ async def run_build_task(task_id: str):
|
|||||||
await asyncio.to_thread(_db_update, db, task,
|
await asyncio.to_thread(_db_update, db, task,
|
||||||
status="completed", completed_at=datetime.utcnow(),
|
status="completed", completed_at=datetime.utcnow(),
|
||||||
current_step=None)
|
current_step=None)
|
||||||
|
await asyncio.to_thread(_mark_superseded_tasks, db, task)
|
||||||
|
|
||||||
await log_streamer.emit_step(task_id, "打包完成")
|
await log_streamer.emit_step(task_id, "打包完成")
|
||||||
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
|
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
|
||||||
@@ -555,35 +601,44 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
|||||||
"""生成打包配置"""
|
"""生成打包配置"""
|
||||||
await log_streamer.emit_step(task_id, "生成配置")
|
await log_streamer.emit_step(task_id, "生成配置")
|
||||||
|
|
||||||
from ..routers.config import load_config, _config_lock
|
from ..routers.config import (
|
||||||
|
load_config, _config_lock, get_track, get_track_name, track_build_ver,
|
||||||
|
TRACK_RELEASE, _new_track,
|
||||||
|
)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
apps = config.get("apps", {})
|
apps = config.get("apps", {})
|
||||||
schemes = config.get("schemes", {})
|
schemes = config.get("schemes", {})
|
||||||
versions = config.get("versions", {})
|
|
||||||
|
|
||||||
app = apps.get(task.app_id, {})
|
app = apps.get(task.app_id, {})
|
||||||
scheme = schemes.get(task.scheme_id, {})
|
scheme = schemes.get(task.scheme_id, {})
|
||||||
|
|
||||||
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
|
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
|
||||||
app_ver = versions.get("app_ver", "2.180.0")
|
# 按分支所属轨道取版本号:master/develop 走 release 轨(上架用),feature 走 feature 轨(自测用),
|
||||||
build_ver = versions.get("build_ver", "2.180.0.0")
|
# 两条轨道的 App_Ver 各自独立。
|
||||||
|
track_name = get_track_name(config, task.branch)
|
||||||
|
track = get_track(config, track_name)
|
||||||
|
app_ver = track.get("app_ver") or "2.180.0"
|
||||||
|
|
||||||
# App_Store 打包时自动递增 Build_Ver 第四位版本号
|
# 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
|
||||||
if task.build_type == "App_Store":
|
build_ver = track_build_ver(track)
|
||||||
|
await log_streamer.emit(
|
||||||
|
task_id,
|
||||||
|
f"版本轨道: {track_name}({'上架,App_Store 打包自增' if track_name == TRACK_RELEASE else '自测,构建号不自增'})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# release 轨的 App_Store 打包,在该版本号自己的构建号上 +1
|
||||||
|
# (回退到旧版本号也从它自己的最大值继续);feature 轨始终使用手动设定的构建号。
|
||||||
|
if track_name == TRACK_RELEASE and task.build_type == "App_Store":
|
||||||
async with _config_lock:
|
async with _config_lock:
|
||||||
parts = build_ver.split(".")
|
|
||||||
if len(parts) >= 4:
|
|
||||||
parts[3] = str(int(parts[3]) + 1)
|
|
||||||
else:
|
|
||||||
parts.append("1")
|
|
||||||
build_ver = ".".join(parts)
|
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config["versions"] = {
|
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
|
||||||
"app_ver": app_ver,
|
track = tracks.setdefault(track_name, _new_track(app_ver))
|
||||||
"build_ver": build_ver,
|
build_map = track.setdefault("build_map", {})
|
||||||
}
|
n = int(build_map.get(app_ver, 0)) + 1
|
||||||
|
build_map[app_ver] = n
|
||||||
|
build_ver = f"{app_ver}.{n}"
|
||||||
|
|
||||||
from ..routers.config import save_config
|
from ..routers.config import save_config
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
@@ -629,7 +684,12 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
|||||||
skin_extract_dir.mkdir(exist_ok=True)
|
skin_extract_dir.mkdir(exist_ok=True)
|
||||||
with zipfile.ZipFile(skin_zip, "r") as zf:
|
with zipfile.ZipFile(skin_zip, "r") as zf:
|
||||||
zf.extractall(skin_extract_dir)
|
zf.extractall(skin_extract_dir)
|
||||||
entries = list(skin_extract_dir.iterdir())
|
# 过滤掉 macOS 压缩产生的 __MACOSX 及隐藏文件,避免把它们
|
||||||
|
# 当成皮肤资源目录一起复制进 Images.xcassets。
|
||||||
|
entries = [
|
||||||
|
e for e in skin_extract_dir.iterdir()
|
||||||
|
if e.name != "__MACOSX" and not e.name.startswith(".")
|
||||||
|
]
|
||||||
if len(entries) == 1 and entries[0].is_dir():
|
if len(entries) == 1 and entries[0].is_dir():
|
||||||
config_data["THEME"] = str(entries[0])
|
config_data["THEME"] = str(entries[0])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from urllib.parse import quote, unquote, urlparse
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
class DistributionError(Exception):
|
class DistributionError(Exception):
|
||||||
"""分发配置或上传过程失败。"""
|
"""分发配置或上传过程失败。"""
|
||||||
@@ -167,6 +169,30 @@ def delete_published_artifacts(
|
|||||||
deleter(upload_config, remote_paths)
|
deleter(upload_config, remote_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
|
||||||
|
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
|
||||||
|
if upload_config.get("mode") != "oss":
|
||||||
|
return published_url
|
||||||
|
|
||||||
|
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
|
||||||
|
if not remote_paths:
|
||||||
|
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
|
||||||
|
oss = upload_config.get("oss", {})
|
||||||
|
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
|
||||||
|
if any(not oss.get(key) for key in required):
|
||||||
|
raise DistributionError("OSS 配置不完整")
|
||||||
|
try:
|
||||||
|
import oss2
|
||||||
|
except ImportError as exc:
|
||||||
|
raise DistributionError("未安装 oss2,请重新执行 ./deploy.sh build") from exc
|
||||||
|
|
||||||
|
bucket = oss2.Bucket(
|
||||||
|
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
|
||||||
|
oss["endpoint"], oss["bucket_name"], connect_timeout=30,
|
||||||
|
)
|
||||||
|
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
||||||
path = ""
|
path = ""
|
||||||
for segment in remote_path.strip("/").split("/")[:-1]:
|
for segment in remote_path.strip("/").split("/")[:-1]:
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ def _apply_theme(build_dir: Path, theme_dir: str, messages: list[str]):
|
|||||||
for item in source.iterdir():
|
for item in source.iterdir():
|
||||||
if not item.is_dir():
|
if not item.is_dir():
|
||||||
continue
|
continue
|
||||||
|
# 跳过 macOS 压缩包内的 __MACOSX 及隐藏目录,仅复制真实皮肤资源
|
||||||
|
if item.name == "__MACOSX" or item.name.startswith("."):
|
||||||
|
continue
|
||||||
destination = target_assets / item.name
|
destination = target_assets / item.name
|
||||||
if destination.exists():
|
if destination.exists():
|
||||||
shutil.rmtree(destination)
|
shutil.rmtree(destination)
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# iOS 自动打包服务 — 部署与管理脚本
|
# iOS 自动打包服务 — 部署与管理脚本
|
||||||
# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop}
|
# 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop}
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -23,6 +23,11 @@ WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
|
|||||||
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
||||||
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
||||||
COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒)
|
COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒)
|
||||||
|
WATCHDOG_STARTUP_GRACE="${WATCHDOG_STARTUP_GRACE:-10}" # watchdog 首次检查前的宽限(秒),避开正常启动窗口
|
||||||
|
STARTUP_READY_TIMEOUT="${STARTUP_READY_TIMEOUT:-20}" # 启动后等待 /api/health 就绪的最长时间(秒)
|
||||||
|
# 重启方式:nohup(默认,PID 文件托管)| launchd(交回 launchctl kickstart 托管,避免与 KeepAlive 抢端口)
|
||||||
|
WATCHDOG_RESTART_MODE="${WATCHDOG_RESTART_MODE:-nohup}"
|
||||||
|
APP_LAUNCHD_LABEL="${APP_LAUNCHD_LABEL:-com.readoor.buildserver}"
|
||||||
|
|
||||||
# ========== 颜色输出 ==========
|
# ========== 颜色输出 ==========
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -64,6 +69,52 @@ get_server_pid() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 返回占用目标端口、但命令不属于本项目的进程 PID(若有),用于区分“端口冲突”与“本服务在跑”。
|
||||||
|
foreign_port_holder() {
|
||||||
|
local pid
|
||||||
|
for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do
|
||||||
|
if ! is_server_process "$pid"; then
|
||||||
|
echo "$pid"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动 uvicorn 后端进程(单点维护启动参数,供 start / watchdog 复用),PID 写入 PID_FILE。
|
||||||
|
_spawn_server() {
|
||||||
|
setup_venv
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
||||||
|
--host "$BIND_HOST" \
|
||||||
|
--port "$BACKEND_PORT" \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips 127.0.0.1 \
|
||||||
|
--workers 1 \
|
||||||
|
>> "$LOG_DIR/server.log" 2>&1 &
|
||||||
|
echo $! > "$PID_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 轮询 /api/health 直到返回 200 或超时;就绪返回 0,否则返回 1。
|
||||||
|
# 健康检查固定走 127.0.0.1(与 check_health 一致),不受 BIND_HOST 影响。
|
||||||
|
_wait_ready() {
|
||||||
|
local timeout="${1:-$STARTUP_READY_TIMEOUT}"
|
||||||
|
local deadline=$(( $(date +%s) + timeout ))
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
|
# 进程若已退出则无需再等
|
||||||
|
if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--connect-timeout 2 --max-time 3 \
|
||||||
|
"http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000")
|
||||||
|
[ "$code" = "200" ] && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# 日志同时写文件和终端
|
# 日志同时写文件和终端
|
||||||
log_to_file() {
|
log_to_file() {
|
||||||
local level="$1"; shift
|
local level="$1"; shift
|
||||||
@@ -143,11 +194,23 @@ do_build() {
|
|||||||
do_start() {
|
do_start() {
|
||||||
check_env
|
check_env
|
||||||
|
|
||||||
|
# 端口被非本项目进程占用:明确报错,避免 uvicorn 静默绑定失败。
|
||||||
|
local foreign
|
||||||
|
foreign=$(foreign_port_holder || true)
|
||||||
|
if [ -n "$foreign" ]; then
|
||||||
|
error "端口 $BACKEND_PORT 被非本项目进程占用 (PID: $foreign):"
|
||||||
|
ps -p "$foreign" -o pid,command= 2>/dev/null | sed 's/^/ /'
|
||||||
|
error "请先释放该端口,或在 .env 修改 BACKEND_PORT"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 本项目实例已在运行:强制重来(用户选定语义)。
|
||||||
|
# 注意:这会中断正在进行的打包任务。
|
||||||
local existing_pid
|
local existing_pid
|
||||||
existing_pid=$(get_server_pid || true)
|
existing_pid=$(get_server_pid || true)
|
||||||
if [ -n "$existing_pid" ]; then
|
if [ -n "$existing_pid" ]; then
|
||||||
warn "服务已在运行 (PID: $existing_pid)"
|
warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..."
|
||||||
return 0
|
do_stop
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 确保前端已构建
|
# 确保前端已构建
|
||||||
@@ -156,28 +219,15 @@ do_start() {
|
|||||||
do_build
|
do_build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
setup_venv
|
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
|
|
||||||
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
|
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
|
||||||
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
_spawn_server
|
||||||
--host "$BIND_HOST" \
|
|
||||||
--port "$BACKEND_PORT" \
|
|
||||||
--proxy-headers \
|
|
||||||
--forwarded-allow-ips 127.0.0.1 \
|
|
||||||
--workers 1 \
|
|
||||||
> "$LOG_DIR/server.log" 2>&1 &
|
|
||||||
|
|
||||||
echo $! > "$PID_FILE"
|
if _wait_ready; then
|
||||||
sleep 1
|
info "服务已就绪 (PID: $(cat "$PID_FILE"))"
|
||||||
|
|
||||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
||||||
info "服务已启动 (PID: $(cat "$PID_FILE"))"
|
|
||||||
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
|
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
|
||||||
else
|
else
|
||||||
error "启动失败,请查看日志: $LOG_DIR/server.log"
|
error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log"
|
||||||
rm -f "$PID_FILE"
|
tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /'
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -252,12 +302,15 @@ check_health() {
|
|||||||
|
|
||||||
# ========== Watchdog(前台模式,用于调试) ==========
|
# ========== Watchdog(前台模式,用于调试) ==========
|
||||||
do_watchdog_fg() {
|
do_watchdog_fg() {
|
||||||
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s"
|
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s"
|
||||||
info "按 Ctrl+C 停止"
|
info "按 Ctrl+C 停止"
|
||||||
|
|
||||||
local restart_count=0
|
local restart_count=0
|
||||||
local last_restart_time=0
|
local last_restart_time=0
|
||||||
|
|
||||||
|
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
|
||||||
|
sleep "$WATCHDOG_STARTUP_GRACE"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
local result
|
local result
|
||||||
result=$(check_health)
|
result=$(check_health)
|
||||||
@@ -338,7 +391,10 @@ do_watchdog_loop() {
|
|||||||
local restart_count=0
|
local restart_count=0
|
||||||
local last_restart_time=0
|
local last_restart_time=0
|
||||||
|
|
||||||
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s"
|
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s, grace=${WATCHDOG_STARTUP_GRACE}s, mode=${WATCHDOG_RESTART_MODE}"
|
||||||
|
|
||||||
|
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
|
||||||
|
sleep "$WATCHDOG_STARTUP_GRACE"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
local result
|
local result
|
||||||
@@ -366,35 +422,33 @@ do_watchdog_loop() {
|
|||||||
|
|
||||||
restart_count=$((restart_count + 1))
|
restart_count=$((restart_count + 1))
|
||||||
last_restart_time=$(date +%s)
|
last_restart_time=$(date +%s)
|
||||||
log_to_file "WARN" "Restarting service (attempt: $restart_count)"
|
log_to_file "WARN" "Restarting service (attempt: $restart_count, mode: $WATCHDOG_RESTART_MODE)"
|
||||||
|
|
||||||
# 停止旧进程
|
if [ "$WATCHDOG_RESTART_MODE" = "launchd" ]; then
|
||||||
if [ -f "$PID_FILE" ]; then
|
# launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。
|
||||||
local old_pid
|
if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then
|
||||||
old_pid=$(cat "$PID_FILE")
|
log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL"
|
||||||
kill "$old_pid" 2>/dev/null
|
else
|
||||||
sleep 2
|
log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL"
|
||||||
kill -9 "$old_pid" 2>/dev/null || true
|
fi
|
||||||
rm -f "$PID_FILE"
|
else
|
||||||
|
# nohup 托管模式:停掉旧进程后重新拉起。
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
local old_pid
|
||||||
|
old_pid=$(cat "$PID_FILE")
|
||||||
|
kill "$old_pid" 2>/dev/null
|
||||||
|
sleep 2
|
||||||
|
kill -9 "$old_pid" 2>/dev/null || true
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
_spawn_server
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 重新启动
|
# 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。
|
||||||
setup_venv
|
if _wait_ready; then
|
||||||
mkdir -p "$LOG_DIR"
|
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))"
|
||||||
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
|
||||||
--host "$BIND_HOST" \
|
|
||||||
--port "$BACKEND_PORT" \
|
|
||||||
--proxy-headers \
|
|
||||||
--forwarded-allow-ips 127.0.0.1 \
|
|
||||||
--workers 1 \
|
|
||||||
>> "$LOG_DIR/server.log" 2>&1 &
|
|
||||||
echo $! > "$PID_FILE"
|
|
||||||
|
|
||||||
sleep 3
|
|
||||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
||||||
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))"
|
|
||||||
else
|
else
|
||||||
log_to_file "ERROR" "Service restart failed"
|
log_to_file "ERROR" "Service restart failed or not ready in ${STARTUP_READY_TIMEOUT}s"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -457,8 +511,21 @@ do_test() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ========== 一键部署(强制重来)==========
|
||||||
|
# build → start(强制重启并等待就绪)→ watchdog,最后打印状态。
|
||||||
|
do_up() {
|
||||||
|
info "一键部署(强制重来):build → start → watchdog"
|
||||||
|
do_watchdog_stop >/dev/null 2>&1 || true # 先停监听,避免它在重启窗口内干预
|
||||||
|
do_build
|
||||||
|
do_start # do_start 内部会强制停旧服务并等待 health 就绪
|
||||||
|
do_watchdog
|
||||||
|
echo ""
|
||||||
|
do_status
|
||||||
|
}
|
||||||
|
|
||||||
# ========== 主入口 ==========
|
# ========== 主入口 ==========
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
|
up) do_up ;;
|
||||||
build) do_build ;;
|
build) do_build ;;
|
||||||
start) do_start ;;
|
start) do_start ;;
|
||||||
stop) do_stop; do_watchdog_stop ;;
|
stop) do_stop; do_watchdog_stop ;;
|
||||||
@@ -470,10 +537,11 @@ case "${1:-}" in
|
|||||||
watchdog-fg) do_watchdog_fg ;;
|
watchdog-fg) do_watchdog_fg ;;
|
||||||
_watchdog_loop) do_watchdog_loop ;;
|
_watchdog_loop) do_watchdog_loop ;;
|
||||||
*)
|
*)
|
||||||
echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}"
|
echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " up 一键部署:build + start + watchdog(强制重来)"
|
||||||
echo " build 构建前端、安装依赖"
|
echo " build 构建前端、安装依赖"
|
||||||
echo " start 启动服务(后台运行)"
|
echo " start 启动服务(强制重启并等待就绪;端口被占用会报错)"
|
||||||
echo " stop 停止服务(同时停止 watchdog)"
|
echo " stop 停止服务(同时停止 watchdog)"
|
||||||
echo " restart 重启服务"
|
echo " restart 重启服务"
|
||||||
echo " status 查看运行状态"
|
echo " status 查看运行状态"
|
||||||
@@ -485,6 +553,9 @@ case "${1:-}" in
|
|||||||
echo "环境变量(可在 .env 中配置):"
|
echo "环境变量(可在 .env 中配置):"
|
||||||
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
|
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
|
||||||
echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)"
|
echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)"
|
||||||
|
echo " WATCHDOG_STARTUP_GRACE watchdog 首次检查前宽限(秒,默认 10)"
|
||||||
|
echo " STARTUP_READY_TIMEOUT 启动后等待 health 就绪的最长时间(秒,默认 20)"
|
||||||
|
echo " WATCHDOG_RESTART_MODE 重启方式 nohup|launchd(默认 nohup)"
|
||||||
echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)"
|
echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)"
|
||||||
echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
|
echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ EOF
|
|||||||
<dict>
|
<dict>
|
||||||
<key>PATH</key>
|
<key>PATH</key>
|
||||||
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
<key>WATCHDOG_RESTART_MODE</key>
|
||||||
|
<string>launchd</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<router-link to="/build">打包</router-link>
|
<router-link to="/build">打包</router-link>
|
||||||
<router-link to="/history">历史</router-link>
|
<router-link to="/history">历史</router-link>
|
||||||
<router-link to="/admin">管理</router-link>
|
<router-link to="/admin">管理</router-link>
|
||||||
|
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-if="!isLoggedIn" class="user-info">
|
<div v-if="!isLoggedIn" class="user-info">
|
||||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ function mockFetch(url) {
|
|||||||
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
||||||
'/api/config/branches': ['main', 'dev'],
|
'/api/config/branches': ['main', 'dev'],
|
||||||
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
||||||
'/api/config/versions': { app_ver: '2.195.0', build_ver: '2.195.0.0' },
|
'/api/config/versions': {
|
||||||
|
app_ver: '2.195.0',
|
||||||
|
build_ver: '2.195.0.0',
|
||||||
|
tracks: {
|
||||||
|
release: { app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} },
|
||||||
|
feature: { app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
|
||||||
|
},
|
||||||
|
branch_track: { main: 'release', dev: 'release' },
|
||||||
|
},
|
||||||
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
||||||
}
|
}
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -168,19 +176,76 @@ describe('ConfigView.vue', () => {
|
|||||||
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('普通账号可修改版本号但看不到打包参数', async () => {
|
it('普通账号只能管理 Apps,看不到打包设置与版本号', async () => {
|
||||||
const wrapper = mountConfigView({ admin: false })
|
const wrapper = mountConfigView({ admin: false })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
|
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
|
||||||
expect(menuTexts).toEqual(['Apps 配置', '打包设置'])
|
expect(menuTexts).toEqual(['Apps 配置'])
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('上架版本号')
|
||||||
|
expect(wrapper.text()).not.toContain('自测版本号')
|
||||||
|
expect(wrapper.text()).not.toContain('打包参数')
|
||||||
|
// 版本号接口为管理员专属,普通账号不再请求
|
||||||
|
expect(fetch).not.toHaveBeenCalledWith('/api/config/versions', expect.anything())
|
||||||
|
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('两条版本轨道各自独立展示与保存', async () => {
|
||||||
|
const calls = []
|
||||||
|
fetch.mockImplementation((url, opts) => {
|
||||||
|
if (url === '/api/config/versions' && opts?.method === 'PUT') {
|
||||||
|
calls.push(JSON.parse(opts.body))
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ app_ver: '2.101.0', build_ver: '2.101.0.0' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return mockFetch(url)
|
||||||
|
})
|
||||||
|
window.alert = vi.fn()
|
||||||
|
|
||||||
|
const wrapper = mountConfigView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||||
await buildMenu.trigger('click')
|
await buildMenu.trigger('click')
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('应用版本号')
|
const inputs = wrapper.findAll('input[type="text"]')
|
||||||
expect(wrapper.text()).not.toContain('打包参数')
|
expect(inputs[0].element.value).toBe('2.195.0') // release App_Ver
|
||||||
expect(wrapper.findAll('input').length).toBe(1)
|
expect(inputs[2].element.value).toBe('2.100.0') // feature App_Ver
|
||||||
|
|
||||||
|
await inputs[2].setValue('2.101.0')
|
||||||
|
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存自测版本号')
|
||||||
|
await saveBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ track: 'feature', app_ver: '2.101.0', build_ver: '2.101.0.0' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('分支管理可切换版本轨道', async () => {
|
||||||
|
const calls = []
|
||||||
|
fetch.mockImplementation((url, opts) => {
|
||||||
|
if (url === '/api/config/branches/track' && opts?.method === 'PUT') {
|
||||||
|
calls.push(JSON.parse(opts.body))
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||||
|
}
|
||||||
|
return mockFetch(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountConfigView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||||
|
await branchesMenu.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const select = wrapper.find('.config-table select')
|
||||||
|
expect(select.element.value).toBe('release')
|
||||||
|
await select.setValue('feature')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ branch: 'main', track: 'feature' }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -159,6 +159,18 @@ describe('HistoryView.vue', () => {
|
|||||||
expect(wrapper.text()).not.toContain('日志')
|
expect(wrapper.text()).not.toContain('日志')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('管理员可显示已覆盖记录', async () => {
|
||||||
|
const wrapper = mountHistory(true)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const toggle = wrapper.find('.superseded-toggle input')
|
||||||
|
expect(toggle.exists()).toBe(true)
|
||||||
|
await toggle.setValue(true)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenLastCalledWith('/api/tasks?limit=100&include_superseded=true', expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
it('空列表显示提示', async () => {
|
it('空列表显示提示', async () => {
|
||||||
fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
|
fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
|
||||||
const router = createMockRouter()
|
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,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import BuildView from '../views/BuildView.vue'
|
import BuildView from '../views/BuildView.vue'
|
||||||
import HistoryView from '../views/HistoryView.vue'
|
import HistoryView from '../views/HistoryView.vue'
|
||||||
import ConfigView from '../views/ConfigView.vue'
|
import ConfigView from '../views/ConfigView.vue'
|
||||||
|
import OperationLogView from '../views/OperationLogView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', redirect: '/build' },
|
{ path: '/', redirect: '/build' },
|
||||||
{ path: '/build', name: 'Build', component: BuildView },
|
{ path: '/build', name: 'Build', component: BuildView },
|
||||||
{ path: '/history', name: 'History', component: HistoryView },
|
{ path: '/history', name: 'History', component: HistoryView },
|
||||||
{ path: '/admin', name: 'Admin', component: ConfigView },
|
{ path: '/admin', name: 'Admin', component: ConfigView },
|
||||||
|
{ path: '/operations', name: 'Operations', component: OperationLogView },
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||||
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
||||||
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
||||||
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||||
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +151,10 @@
|
|||||||
<!-- 分支管理 -->
|
<!-- 分支管理 -->
|
||||||
<div v-if="tab === 'branches'">
|
<div v-if="tab === 'branches'">
|
||||||
<h2>分支管理</h2>
|
<h2>分支管理</h2>
|
||||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理可打包的代码分支,打包时从对应分支目录获取源码</p>
|
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">
|
||||||
|
管理可打包的代码分支,打包时从对应分支目录获取源码。
|
||||||
|
版本轨道决定该分支使用哪一套版本号:上架(master/develop 共用,App_Store 打包构建号自增)、自测(feature 用,构建号不自增)。
|
||||||
|
</p>
|
||||||
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
|
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
|
||||||
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
|
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
|
||||||
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
|
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
|
||||||
@@ -160,18 +163,25 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>分支名称</th>
|
<th>分支名称</th>
|
||||||
|
<th>版本轨道</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="b in branches" :key="b">
|
<tr v-for="b in branches" :key="b">
|
||||||
<td><strong>{{ b }}</strong></td>
|
<td><strong>{{ b }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<select :value="branchTrack[b] || 'feature'" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)">
|
||||||
|
<option value="release">上架(master/develop)</option>
|
||||||
|
<option value="feature">自测(feature)</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td class="action-btns">
|
<td class="action-btns">
|
||||||
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
|
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!branches.length">
|
<tr v-if="!branches.length">
|
||||||
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td>
|
<td colspan="3" style="text-align: center; color: #999;">暂无分支配置</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -269,13 +279,36 @@
|
|||||||
<div v-if="tab === 'build'">
|
<div v-if="tab === 'build'">
|
||||||
<h2>打包设置</h2>
|
<h2>打包设置</h2>
|
||||||
<div style="max-width: 500px;">
|
<div style="max-width: 500px;">
|
||||||
<h4 class="section-title">应用版本号</h4>
|
<h4 class="section-title">上架版本号(master / develop)</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>App_Ver(应用版本)</label>
|
<label>App_Ver(应用版本)</label>
|
||||||
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
|
<input type="text" v-model="versions.release.app_ver" placeholder="2.180.0" @input="onAppVerInput('release')">
|
||||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号</div>
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号。master 与 develop 共用这一套版本号。</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
|
<div class="form-group">
|
||||||
|
<label>Build_Ver(构建号)</label>
|
||||||
|
<input type="text" v-model="versions.release.build_ver" placeholder="2.180.0.0">
|
||||||
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||||
|
四段数字,前三位须与 App_Ver 一致。修改 App_Ver 会自动切换为该版本号已记录的构建号;
|
||||||
|
每个版本号的构建号独立递增,App_Store 打包时自动 +1。一般无需手动修改。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('release')">保存上架版本号</button>
|
||||||
|
|
||||||
|
<h4 class="section-title">自测版本号(feature 分支)</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>App_Ver(应用版本)</label>
|
||||||
|
<input type="text" v-model="versions.feature.app_ver" placeholder="2.180.0" @input="onAppVerInput('feature')">
|
||||||
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">与上架版本号各自独立,互不影响。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Build_Ver(构建号)</label>
|
||||||
|
<input type="text" v-model="versions.feature.build_ver" placeholder="2.180.0.0">
|
||||||
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||||
|
仅供自测,打包时<strong>不会自动递增</strong>,始终使用这里填写的值(默认 .0)。需要区分时请手动修改。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('feature')">保存自测版本号</button>
|
||||||
|
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<h4 class="section-title">打包参数</h4>
|
<h4 class="section-title">打包参数</h4>
|
||||||
@@ -458,7 +491,7 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>腾讯 AppID</label>
|
<label>腾讯 AppID</label>
|
||||||
<input v-model="appForm.tencent" type="text" placeholder="tencent...">
|
<input v-model="appForm.tencent" type="text" placeholder="tencent...(可只填数字,自动补前缀)" @blur="normalizeTencentAppId">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>阿里云 License Key</label>
|
<label>阿里云 License Key</label>
|
||||||
@@ -519,12 +552,21 @@
|
|||||||
<div v-for="skin in appSkins" :key="skin.name" class="skin-item">
|
<div v-for="skin in appSkins" :key="skin.name" class="skin-item">
|
||||||
<span class="skin-name">{{ skin.name }}</span>
|
<span class="skin-name">{{ skin.name }}</span>
|
||||||
<span class="skin-size">{{ formatSize(skin.size) }}</span>
|
<span class="skin-size">{{ formatSize(skin.size) }}</span>
|
||||||
|
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
|
||||||
|
更新
|
||||||
|
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
|
||||||
|
</label>
|
||||||
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">×</button>
|
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="skins-hint">暂无皮肤包</div>
|
<div v-else class="skins-hint">暂无皮肤包</div>
|
||||||
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }">
|
<label class="btn btn-outline skin-upload-btn"
|
||||||
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }}
|
:class="{ disabled: skinUploading, dragover: skinDragover }"
|
||||||
|
@dragover.prevent="skinDragover = true"
|
||||||
|
@dragenter.prevent="skinDragover = true"
|
||||||
|
@dragleave.prevent="skinDragover = false"
|
||||||
|
@drop.prevent="onSkinDrop">
|
||||||
|
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.zip,或拖拽到此处)') }}
|
||||||
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
|
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,7 +638,32 @@ const servers = ref({})
|
|||||||
const branches = ref([])
|
const branches = ref([])
|
||||||
const newBranch = ref('')
|
const newBranch = ref('')
|
||||||
const buildSettings = ref({})
|
const buildSettings = ref({})
|
||||||
const versions = ref({ app_ver: '', build_ver: '' })
|
// 两条版本轨道:release(master/develop,上架用)与 feature(自测用),各自独立
|
||||||
|
const versions = ref({
|
||||||
|
release: { app_ver: '', build_ver: '' },
|
||||||
|
feature: { app_ver: '', build_ver: '' },
|
||||||
|
})
|
||||||
|
const buildMap = ref({ release: {}, feature: {} })
|
||||||
|
const branchTrack = ref({})
|
||||||
|
|
||||||
|
const applyVersions = (data) => {
|
||||||
|
const tracks = data.tracks || {}
|
||||||
|
for (const track of ['release', 'feature']) {
|
||||||
|
const item = tracks[track] || {}
|
||||||
|
buildMap.value[track] = item.build_map || {}
|
||||||
|
versions.value[track] = { app_ver: item.app_ver || '', build_ver: item.build_ver || '' }
|
||||||
|
}
|
||||||
|
branchTrack.value = data.branch_track || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改 App_Ver 时,自动把 Build_Ver 切换为该版本号已记录的构建号(新版本号则为 .0)
|
||||||
|
const onAppVerInput = (track) => {
|
||||||
|
const appVer = (versions.value[track].app_ver || '').trim()
|
||||||
|
if (/^\d+\.\d+\.\d+$/.test(appVer)) {
|
||||||
|
const n = Number((buildMap.value[track] || {})[appVer] || 0)
|
||||||
|
versions.value[track].build_ver = `${appVer}.${n}`
|
||||||
|
}
|
||||||
|
}
|
||||||
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
||||||
const jsonContent = ref('{}')
|
const jsonContent = ref('{}')
|
||||||
|
|
||||||
@@ -615,6 +682,7 @@ const editingAppId = ref(null)
|
|||||||
const appForm = ref({})
|
const appForm = ref({})
|
||||||
const appSkins = ref([])
|
const appSkins = ref([])
|
||||||
const skinUploading = ref(false)
|
const skinUploading = ref(false)
|
||||||
|
const skinDragover = ref(false)
|
||||||
|
|
||||||
const showSchemeModal = ref(false)
|
const showSchemeModal = ref(false)
|
||||||
const editingSchemeId = ref(null)
|
const editingSchemeId = ref(null)
|
||||||
@@ -626,12 +694,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!isAdmin.value) {
|
if (!isAdmin.value) {
|
||||||
const [appsRes, versionsRes] = await Promise.all([
|
// 普通用户只能管理 Apps,版本号等配置均为管理员专属
|
||||||
authFetch('/api/config/apps'),
|
const appsRes = await authFetch('/api/config/apps')
|
||||||
authFetch('/api/config/versions'),
|
|
||||||
])
|
|
||||||
if (appsRes.ok) apps.value = await appsRes.json()
|
if (appsRes.ok) apps.value = await appsRes.json()
|
||||||
if (versionsRes.ok) versions.value = await versionsRes.json()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +718,7 @@ const loadData = async () => {
|
|||||||
buildSettings.value = await buildRes.json()
|
buildSettings.value = await buildRes.json()
|
||||||
uploadConfig.value = await uploadRes.json()
|
uploadConfig.value = await uploadRes.json()
|
||||||
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
||||||
versions.value = await versionsRes.json()
|
applyVersions(await versionsRes.json())
|
||||||
if (usersRes.ok) users.value = await usersRes.json()
|
if (usersRes.ok) users.value = await usersRes.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,9 +927,9 @@ const loadSkins = async () => {
|
|||||||
} catch { appSkins.value = [] }
|
} catch { appSkins.value = [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadSkin = async (event) => {
|
const uploadSkinFile = async (file) => {
|
||||||
const file = event.target.files[0]
|
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); return }
|
||||||
const uploadKey = appForm.value.upload_key
|
const uploadKey = appForm.value.upload_key
|
||||||
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
|
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
|
||||||
skinUploading.value = true
|
skinUploading.value = true
|
||||||
@@ -880,7 +945,12 @@ const uploadSkin = async (event) => {
|
|||||||
throw new Error(err.detail || '上传失败')
|
throw new Error(err.detail || '上传失败')
|
||||||
}
|
}
|
||||||
await loadSkins()
|
await loadSkins()
|
||||||
event.target.value = ''
|
// 若某证书的主题目录还是空的,自动选中刚上传的皮肤
|
||||||
|
const skinTheme = file.name.replace(/\.zip$/i, '')
|
||||||
|
const certs = appForm.value.certificates || {}
|
||||||
|
Object.keys(certs).forEach(certType => {
|
||||||
|
if (!certs[certType].theme) certs[certType].theme = skinTheme
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('上传失败: ' + e.message)
|
alert('上传失败: ' + e.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -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) => {
|
const deleteSkin = async (skinName) => {
|
||||||
if (!confirm(`确定删除皮肤包 ${skinName}?`)) return
|
if (!confirm(`确定删除皮肤包 ${skinName}?`)) return
|
||||||
const uploadKey = appForm.value.upload_key
|
const uploadKey = appForm.value.upload_key
|
||||||
@@ -906,12 +1014,20 @@ const formatSize = (bytes) => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)
|
||||||
|
const normalizeTencentAppId = () => {
|
||||||
|
const value = (appForm.value.tencent || '').trim()
|
||||||
|
appForm.value.tencent = value && !value.startsWith('tencent') ? `tencent${value}` : value
|
||||||
|
}
|
||||||
|
|
||||||
const saveApp = async () => {
|
const saveApp = async () => {
|
||||||
if (!appForm.value.name || !appForm.value.AppGuid) {
|
if (!appForm.value.name || !appForm.value.AppGuid) {
|
||||||
alert('请填写必填字段:App 名称、AppGuid')
|
alert('请填写必填字段:App 名称、AppGuid')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeTencentAppId()
|
||||||
|
|
||||||
// 清理空证书
|
// 清理空证书
|
||||||
if (appForm.value.certificates) {
|
if (appForm.value.certificates) {
|
||||||
Object.keys(appForm.value.certificates).forEach(key => {
|
Object.keys(appForm.value.certificates).forEach(key => {
|
||||||
@@ -948,9 +1064,18 @@ const saveApp = async () => {
|
|||||||
const err = await res.json().catch(() => ({ detail: '保存失败' }))
|
const err = await res.json().catch(() => ({ detail: '保存失败' }))
|
||||||
throw new Error(err.detail || '保存失败')
|
throw new Error(err.detail || '保存失败')
|
||||||
}
|
}
|
||||||
showAppModal.value = false
|
|
||||||
await loadData()
|
await loadData()
|
||||||
alert('保存成功')
|
if (!editingAppId.value) {
|
||||||
|
// 新建成功:不关弹窗,转为编辑态并回填 upload_key,让皮肤包上传区立即可用
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
editingAppId.value = data.id || editingAppId.value
|
||||||
|
if (data.upload_key) appForm.value.upload_key = data.upload_key
|
||||||
|
await loadSkins()
|
||||||
|
alert('保存成功,现在可以上传皮肤包了')
|
||||||
|
} else {
|
||||||
|
showAppModal.value = false
|
||||||
|
alert('保存成功')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('保存失败: ' + e.message)
|
alert('保存失败: ' + e.message)
|
||||||
}
|
}
|
||||||
@@ -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) => {
|
const deleteBranch = async (name) => {
|
||||||
if (!confirm(`确定删除分支「${name}」?`)) return
|
if (!confirm(`确定删除分支「${name}」?`)) return
|
||||||
try {
|
try {
|
||||||
@@ -1047,16 +1190,25 @@ const saveBuildSettings = async () => {
|
|||||||
alert('设置已保存')
|
alert('设置已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveVersions = async () => {
|
const saveVersions = async (track = 'release') => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/config/versions', {
|
const res = await authFetch('/api/config/versions', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ app_ver: versions.value.app_ver }),
|
body: JSON.stringify({
|
||||||
|
track,
|
||||||
|
app_ver: versions.value[track].app_ver,
|
||||||
|
build_ver: versions.value[track].build_ver,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
versions.value = { app_ver: data.app_ver, build_ver: data.build_ver }
|
// 回写该版本号最新的构建号,保持本地 build_map 与服务端一致
|
||||||
|
buildMap.value[track] = {
|
||||||
|
...(buildMap.value[track] || {}),
|
||||||
|
[data.app_ver]: Number((data.build_ver.split('.')[3]) || 0),
|
||||||
|
}
|
||||||
|
versions.value[track] = { app_ver: data.app_ver, build_ver: data.build_ver }
|
||||||
alert('版本号已保存')
|
alert('版本号已保存')
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json()
|
const err = await res.json()
|
||||||
@@ -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-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
|
||||||
.btn-danger-icon { background: #fff1f0; color: #ff4d4f; }
|
.btn-danger-icon { background: #fff1f0; color: #ff4d4f; }
|
||||||
.btn-danger-icon:hover { background: #ff4d4f; color: white; }
|
.btn-danger-icon:hover { background: #ff4d4f; color: white; }
|
||||||
|
.skin-update-btn { font-size: 12px; color: #1890ff; cursor: pointer; padding: 2px 8px; border: 1px solid #91d5ff; border-radius: 4px; background: #e6f7ff; line-height: 1.4; }
|
||||||
|
.skin-update-btn:hover { background: #1890ff; color: white; }
|
||||||
|
.skin-update-btn.disabled { opacity: 0.5; pointer-events: none; }
|
||||||
.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; }
|
.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; }
|
||||||
.btn-outline:hover { background: #e6f7ff; }
|
.btn-outline:hover { background: #e6f7ff; }
|
||||||
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.skin-upload-btn { margin-top: 8px; }
|
.skin-upload-btn { margin-top: 8px; transition: background 0.15s, border-color 0.15s; }
|
||||||
|
.skin-upload-btn.dragover { border-color: #1890ff; border-style: dashed; background: #e6f7ff; color: #1890ff; }
|
||||||
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<option value="failed">失败</option>
|
<option value="failed">失败</option>
|
||||||
<option value="cancelled">已取消</option>
|
<option value="cancelled">已取消</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label v-if="isAdmin" class="superseded-toggle"><input v-model="showSuperseded" type="checkbox" @change="loadTasks"> 显示已覆盖</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="config-table">
|
<table class="config-table">
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
||||||
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
||||||
@click="showQrPreview(task)">
|
@click="showQrPreview(task)">
|
||||||
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a>
|
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="text-muted">-</span>
|
<span v-else class="text-muted">-</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -180,6 +181,7 @@ const logLines = ref([])
|
|||||||
const logContainer = ref(null)
|
const logContainer = ref(null)
|
||||||
const showVerboseLogs = ref(false)
|
const showVerboseLogs = ref(false)
|
||||||
const qrPreview = ref(null)
|
const qrPreview = ref(null)
|
||||||
|
const showSuperseded = ref(false)
|
||||||
let logWs = null
|
let logWs = null
|
||||||
|
|
||||||
const getAppVersion = (task) => {
|
const getAppVersion = (task) => {
|
||||||
@@ -210,11 +212,13 @@ const filteredTasks = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
const loadTasks = async () => {
|
||||||
const res = await authFetch('/api/tasks?limit=100')
|
const res = await authFetch(`/api/tasks?limit=100${showSuperseded.value ? '&include_superseded=true' : ''}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
|
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadTasks)
|
||||||
|
|
||||||
const viewLogs = async (taskId) => {
|
const viewLogs = async (taskId) => {
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
@@ -308,6 +312,17 @@ const downloadDsym = (taskId) => {
|
|||||||
window.open(`/api/tasks/${taskId}/dsym`)
|
window.open(`/api/tasks/${taskId}/dsym`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadIpa = async (taskId) => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const { url } = await res.json()
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
alert('下载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const downloadObfMaps = (taskId) => {
|
const downloadObfMaps = (taskId) => {
|
||||||
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||||
}
|
}
|
||||||
@@ -457,6 +472,7 @@ const errorCategoryLabel = (cat) => {
|
|||||||
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
||||||
|
|
||||||
.version-filter { width: 150px; }
|
.version-filter { width: 150px; }
|
||||||
|
.superseded-toggle { display: flex; align-items: center; gap: 4px; color: #666; font-size: 13px; white-space: nowrap; }
|
||||||
|
|
||||||
/* 日志操作栏 */
|
/* 日志操作栏 */
|
||||||
.log-modal-actions {
|
.log-modal-actions {
|
||||||
@@ -506,7 +522,7 @@ const errorCategoryLabel = (cat) => {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
|
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
|
||||||
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; }
|
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
|
||||||
.download-link:hover { text-decoration: underline; }
|
.download-link:hover { text-decoration: underline; }
|
||||||
.text-muted { color: #999; font-size: 13px; }
|
.text-muted { color: #999; font-size: 13px; }
|
||||||
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
|
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
|
||||||
|
|||||||
@@ -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
@@ -48,6 +48,31 @@ def test_create_app_rejects_unknown_environment_id_rule(client, tmp_config):
|
|||||||
assert "ID 规则" in resp.json()["detail"]
|
assert "ID 规则" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_prefixes_tencent_app_id(client, tmp_config):
|
||||||
|
resp = client.post("/api/config/apps", json={
|
||||||
|
"name": "腾讯App", "server": "测试环境", "AppGuid": "guid", "tencent": "123456",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_keeps_existing_tencent_prefix(client, tmp_config):
|
||||||
|
resp = client.post("/api/config/apps", json={
|
||||||
|
"name": "腾讯App2", "server": "测试环境", "AppGuid": "guid", "tencent": "tencent123456",
|
||||||
|
})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_app_prefixes_tencent_app_id(client, tmp_config):
|
||||||
|
resp = client.put("/api/config/apps/1", json={
|
||||||
|
"name": "改名App", "AppGuid": "test-guid", "tencent": " 987654 ",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert client.get("/api/config/apps").json()["1"]["tencent"] == "tencent987654"
|
||||||
|
|
||||||
|
|
||||||
def test_update_app(client, tmp_config):
|
def test_update_app(client, tmp_config):
|
||||||
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
|
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -172,7 +197,8 @@ def test_update_build_settings(client, tmp_config):
|
|||||||
|
|
||||||
# ---- Versions ----
|
# ---- Versions ----
|
||||||
|
|
||||||
def test_update_versions_resets_build_number(client, tmp_config):
|
def test_update_versions_keeps_per_version_build_number(client, tmp_config):
|
||||||
|
# 首次设置某版本号,构建号从 .0 起(尚未打包)
|
||||||
resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
|
resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["app_ver"] == "2.196.0"
|
assert resp.json()["app_ver"] == "2.196.0"
|
||||||
@@ -180,7 +206,84 @@ def test_update_versions_resets_build_number(client, tmp_config):
|
|||||||
assert client.get("/api/config/versions").json()["build_ver"] == "2.196.0.0"
|
assert client.get("/api/config/versions").json()["build_ver"] == "2.196.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_versions_does_not_reset_used_build_number(client, tmp_config):
|
||||||
|
# 模拟 2.195.0 已打到 .5、并切到 2.196.0 后,再回退到 2.195.0
|
||||||
|
import json
|
||||||
|
with open(tmp_config, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
cfg["versions"] = {"app_ver": "2.196.0", "build_map": {"2.195.0": 5, "2.196.0": 3}}
|
||||||
|
with open(tmp_config, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f)
|
||||||
|
|
||||||
|
resp = client.put("/api/config/versions", json={"app_ver": "2.195.0"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# 回退到旧版本号时,构建号保留其自身已用到的最大值,不被重置
|
||||||
|
assert resp.json()["build_ver"] == "2.195.0.5"
|
||||||
|
assert client.get("/api/config/versions").json()["build_ver"] == "2.195.0.5"
|
||||||
|
|
||||||
|
|
||||||
def test_update_versions_rejects_invalid_app_version(client, tmp_config):
|
def test_update_versions_rejects_invalid_app_version(client, tmp_config):
|
||||||
resp = client.put("/api/config/versions", json={"app_ver": "2.196"})
|
resp = client.put("/api/config/versions", json={"app_ver": "2.196"})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "App_Ver" in resp.json()["detail"]
|
assert "App_Ver" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_tracks_are_independent(client, tmp_config):
|
||||||
|
# 两条轨道的 App_Ver 各自独立,互不影响
|
||||||
|
assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200
|
||||||
|
assert client.put("/api/config/versions", json={"track": "feature", "app_ver": "2.100.0"}).status_code == 200
|
||||||
|
|
||||||
|
data = client.get("/api/config/versions").json()
|
||||||
|
assert data["tracks"]["release"]["app_ver"] == "2.196.0"
|
||||||
|
assert data["tracks"]["feature"]["app_ver"] == "2.100.0"
|
||||||
|
assert data["tracks"]["feature"]["build_ver"] == "2.100.0.0"
|
||||||
|
# 顶层字段镜像 release 轨,兼容旧客户端
|
||||||
|
assert data["app_ver"] == "2.196.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_versions_rejects_unknown_track(client, tmp_config):
|
||||||
|
resp = client.put("/api/config/versions", json={"track": "beta", "app_ver": "2.196.0"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_versions_migrate_to_release_track(client, tmp_config):
|
||||||
|
import json
|
||||||
|
with open(tmp_config, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
cfg["versions"] = {"app_ver": "2.195.0", "build_ver": "2.195.0.7"}
|
||||||
|
with open(tmp_config, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f)
|
||||||
|
|
||||||
|
data = client.get("/api/config/versions").json()
|
||||||
|
assert data["tracks"]["release"] == {
|
||||||
|
"app_ver": "2.195.0",
|
||||||
|
"build_ver": "2.195.0.7",
|
||||||
|
"build_map": {"2.195.0": 7},
|
||||||
|
}
|
||||||
|
# feature 轨以同一 App_Ver 初始化,构建号从 .0 起
|
||||||
|
assert data["tracks"]["feature"]["app_ver"] == "2.195.0"
|
||||||
|
assert data["tracks"]["feature"]["build_ver"] == "2.195.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_track_defaults_and_override(client, tmp_config):
|
||||||
|
assert client.post("/api/config/branches", json={"name": "develop"}).status_code == 200
|
||||||
|
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
|
||||||
|
|
||||||
|
tracks = client.get("/api/config/versions").json()["branch_track"]
|
||||||
|
assert tracks["develop"] == "release"
|
||||||
|
assert tracks["feature/login"] == "feature"
|
||||||
|
|
||||||
|
# 手动改归属
|
||||||
|
resp = client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "release"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert client.get("/api/config/versions").json()["branch_track"]["feature/login"] == "release"
|
||||||
|
|
||||||
|
assert client.put("/api/config/branches/track", json={"branch": "nope", "track": "release"}).status_code == 404
|
||||||
|
assert client.put("/api/config/branches/track", json={"branch": "develop", "track": "beta"}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_branch_with_slash_in_name(client, tmp_config):
|
||||||
|
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
|
||||||
|
resp = client.delete("/api/config/branches/feature%2Flogin")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "feature/login" not in client.get("/api/config/branches").json()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -214,3 +214,19 @@ def test_delete_task_keeps_shared_remote_artifact(client, tmp_config):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "仍被其他记录引用" in resp.json()["message"]
|
assert "仍被其他记录引用" in resp.json()["message"]
|
||||||
delete.assert_not_called()
|
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
@@ -20,6 +20,7 @@ from backend.services.build_service import (
|
|||||||
run_pod_install,
|
run_pod_install,
|
||||||
_patch_afnetworking_private_headers,
|
_patch_afnetworking_private_headers,
|
||||||
_cleanup_old_builds,
|
_cleanup_old_builds,
|
||||||
|
_mark_superseded_tasks,
|
||||||
_resolve_provisioning_profile,
|
_resolve_provisioning_profile,
|
||||||
)
|
)
|
||||||
from backend.services.log_streamer import LogStreamer
|
from backend.services.log_streamer import LogStreamer
|
||||||
@@ -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")
|
assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W")
|
||||||
|
|
||||||
|
|
||||||
|
def _versions_config(release: dict, feature: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"apps": {"1": {"name": "App", "certificates": {}}},
|
||||||
|
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
|
||||||
|
"versions": {"tracks": {"release": release, "feature": feature}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
||||||
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
||||||
build_dir = tmp_path / "build"
|
build_dir = tmp_path / "build"
|
||||||
@@ -324,12 +333,12 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
|||||||
task.scheme_id = "1"
|
task.scheme_id = "1"
|
||||||
task.build_type = "Ad_Hoc"
|
task.build_type = "Ad_Hoc"
|
||||||
task.obfuscation = False
|
task.obfuscation = False
|
||||||
|
task.branch = "master"
|
||||||
|
|
||||||
mock_config = {
|
mock_config = _versions_config(
|
||||||
"apps": {"1": {"name": "App", "certificates": {}}},
|
{"app_ver": "3.0.1", "build_map": {}},
|
||||||
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
|
{"app_ver": "9.9.9", "build_map": {}},
|
||||||
"versions": {"app_ver": "3.0.1", "build_ver": "3.0.1.0"},
|
)
|
||||||
}
|
|
||||||
|
|
||||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
with patch("backend.routers.config.load_config", return_value=mock_config):
|
||||||
result = await generate_config("t1", task, build_dir)
|
result = await generate_config("t1", task, build_dir)
|
||||||
@@ -338,6 +347,96 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
|||||||
assert result["BUILD_VERSION"] == "3.0.1.0"
|
assert result["BUILD_VERSION"] == "3.0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_config_appstore_increments_per_version(tmp_path, log_streamer):
|
||||||
|
"""App_Store 打包时,按版本号在其自身已用构建号上 +1(回退旧版本号亦从自身最大值继续)"""
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
|
||||||
|
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
|
||||||
|
obfuscation=False, branch="master")
|
||||||
|
|
||||||
|
# 2.195.0 已打到 .5,切到 2.196.0 后又回退到 2.195.0
|
||||||
|
mock_config = _versions_config(
|
||||||
|
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5, "2.196.0": 3}},
|
||||||
|
{"app_ver": "2.195.0", "build_map": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("backend.routers.config.load_config", return_value=mock_config), \
|
||||||
|
patch("backend.routers.config.save_config") as save_mock:
|
||||||
|
result = await generate_config("t1", task, build_dir)
|
||||||
|
|
||||||
|
# 从 2.195.0 自身的 5 继续到 6,不受 2.196.0 影响,也不撞已传的 .5
|
||||||
|
release_map = mock_config["versions"]["tracks"]["release"]["build_map"]
|
||||||
|
assert result["BUILD_VERSION"] == "2.195.0.6"
|
||||||
|
assert release_map["2.195.0"] == 6
|
||||||
|
assert release_map["2.196.0"] == 3
|
||||||
|
save_mock.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_config_develop_shares_release_track(tmp_path, log_streamer):
|
||||||
|
"""develop 与 master 共用 release 轨的版本号与构建号序列"""
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
|
||||||
|
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
|
||||||
|
obfuscation=False, branch="develop")
|
||||||
|
|
||||||
|
mock_config = _versions_config(
|
||||||
|
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5}},
|
||||||
|
{"app_ver": "2.100.0", "build_map": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("backend.routers.config.load_config", return_value=mock_config), \
|
||||||
|
patch("backend.routers.config.save_config"):
|
||||||
|
result = await generate_config("t1", task, build_dir)
|
||||||
|
|
||||||
|
assert result["VERSION"] == "2.195.0"
|
||||||
|
assert result["BUILD_VERSION"] == "2.195.0.6"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_config_feature_branch_never_increments(tmp_path, log_streamer):
|
||||||
|
"""feature 分支用自己的版本号,且构建号永不自增(即使是 App_Store 打包)"""
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
|
||||||
|
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
|
||||||
|
obfuscation=False, branch="feature/login")
|
||||||
|
|
||||||
|
mock_config = _versions_config(
|
||||||
|
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5}},
|
||||||
|
{"app_ver": "2.100.0", "build_map": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("backend.routers.config.load_config", return_value=mock_config), \
|
||||||
|
patch("backend.routers.config.save_config") as save_mock:
|
||||||
|
result = await generate_config("t1", task, build_dir)
|
||||||
|
|
||||||
|
assert result["VERSION"] == "2.100.0"
|
||||||
|
assert result["BUILD_VERSION"] == "2.100.0.0"
|
||||||
|
assert mock_config["versions"]["tracks"]["feature"]["build_map"] == {}
|
||||||
|
save_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_config_branch_track_override(tmp_path, log_streamer):
|
||||||
|
"""branch_track 中的显式配置优先于按分支名推断"""
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
|
||||||
|
task = MagicMock(app_id="1", scheme_id="1", build_type="Ad_Hoc",
|
||||||
|
obfuscation=False, branch="release/2.196")
|
||||||
|
|
||||||
|
mock_config = _versions_config(
|
||||||
|
{"app_ver": "2.196.0", "build_map": {}},
|
||||||
|
{"app_ver": "2.100.0", "build_map": {}},
|
||||||
|
)
|
||||||
|
mock_config["branch_track"] = {"release/2.196": "feature"}
|
||||||
|
|
||||||
|
with patch("backend.routers.config.load_config", return_value=mock_config):
|
||||||
|
result = await generate_config("t1", task, build_dir)
|
||||||
|
|
||||||
|
assert result["VERSION"] == "2.100.0"
|
||||||
|
|
||||||
|
|
||||||
async def test_generate_config_uses_server_theme_not_source_autopacking(tmp_path, log_streamer):
|
async def test_generate_config_uses_server_theme_not_source_autopacking(tmp_path, log_streamer):
|
||||||
"""Web 打包不读取分支源码中的 AutoPacking 目录。"""
|
"""Web 打包不读取分支源码中的 AutoPacking 目录。"""
|
||||||
build_dir = tmp_path / "build"
|
build_dir = tmp_path / "build"
|
||||||
@@ -418,3 +517,19 @@ def test_cleanup_keeps_recent(tmp_path):
|
|||||||
|
|
||||||
assert recent_dir.exists()
|
assert recent_dir.exists()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_superseded_tasks_for_same_oss_url():
|
||||||
|
from backend.database import SessionLocal, init_db
|
||||||
|
from backend.models import Task
|
||||||
|
init_db()
|
||||||
|
db = SessionLocal()
|
||||||
|
old = Task(id="old", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.html")
|
||||||
|
latest = Task(id="latest", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.html")
|
||||||
|
db.add_all([old, latest])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_mark_superseded_tasks(db, latest)
|
||||||
|
assert db.get(Task, "old").superseded_at is not None
|
||||||
|
assert db.get(Task, "latest").superseded_at is None
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from backend.services.distribution import (
|
|||||||
_write_download_page,
|
_write_download_page,
|
||||||
_write_manifest,
|
_write_manifest,
|
||||||
publish_ipa,
|
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.html",
|
||||||
"readoor/iOS/100_2_0_0_0.png",
|
"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
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ def test_production_rejects_default_security_settings(monkeypatch):
|
|||||||
config.validate_production_security()
|
config.validate_production_security()
|
||||||
|
|
||||||
|
|
||||||
def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config):
|
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
|
||||||
"""普通账号可管理 Apps 与版本号,不能读取或修改管理员配置。"""
|
"""普通账号只能管理 Apps,版本号等其余配置均为管理员专属。"""
|
||||||
response = client.post("/api/users", json={
|
response = client.post("/api/users", json={
|
||||||
"username": "builder",
|
"username": "builder",
|
||||||
"password": "builder-password-123",
|
"password": "builder-password-123",
|
||||||
@@ -57,10 +57,13 @@ def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_conf
|
|||||||
assert client.post("/api/config/apps", json={
|
assert client.post("/api/config/apps", json={
|
||||||
"name": "普通用户 App", "server": "测试环境",
|
"name": "普通用户 App", "server": "测试环境",
|
||||||
}, headers=headers).status_code == 200
|
}, headers=headers).status_code == 200
|
||||||
assert client.get("/api/config/versions", headers=headers).status_code == 200
|
assert client.get("/api/config/versions", headers=headers).status_code == 403
|
||||||
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers)
|
assert client.put(
|
||||||
assert versions.status_code == 200
|
"/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers
|
||||||
assert versions.json()["build_ver"] == "2.196.0.0"
|
).status_code == 403
|
||||||
|
assert client.put(
|
||||||
|
"/api/config/branches/track", json={"branch": "master", "track": "feature"}, headers=headers
|
||||||
|
).status_code == 403
|
||||||
assert client.get("/api/config", headers=headers).status_code == 403
|
assert client.get("/api/config", headers=headers).status_code == 403
|
||||||
assert client.get("/api/config/servers", headers=headers).status_code == 403
|
assert client.get("/api/config/servers", headers=headers).status_code == 403
|
||||||
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403
|
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403
|
||||||
|
|||||||
Reference in New Issue
Block a user