23 Commits
Author SHA1 Message Date
shenleiandClaude Opus 5 5582aadc09 feat: 支持按 Tag 打包,分支管理可添加 Tag
- 分支管理新增「分支 / Tag」类型选择,Tag 存于 config["tags"],与分支不可重名
- 每个 Tag 与分支一样拥有同名独立版本轨道,默认上架轨
- 打包页按「分支」「Tag」分组选择;任务新增 ref_type 字段记录引用类型
- Tag 打包时 fetch --tags --force 后以游离 HEAD 检出 refs/tags/<tag>
- 历史记录与下载页按类型显示「分支」或「Tag」

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:59:18 +09:00
shenleiandClaude Opus 5 6867a3cb01 feat: 版本轨道按分支彻底分开,自测分支不再共用 feature 轨
每个分支各有一条独立轨道(轨道名即分支名,master/main 沿用历史名 release),
轨道类型 kind 只决定构建号行为:release 上架自增、feature 自测不自增。

- branch_track 映射分支 → 轨道名,新增分支自动建同名轨道
- 迁移:旧的共用 release / feature 轨按分支拆开并复制已用构建号,
  拆完没有分支引用的旧共用轨直接删除;无自测分支时保留 feature 轨作模板
- 分支管理页的下拉只改轨道类型(是否自增),不再影响版本号归属
- 打包设置页按分支分块填写版本号

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJaZ913U5GVdTkYHiRpvDU
2026-09-02 10:51:35 +09:00
shenleiandClaude Opus 5 9b4667cab5 refactor: master 沿用 release 轨道名,develop 用 develop,feature 用 feature
上一版把 master 的轨道也改名成 master,会把历史构建号搬到新键上。
改为 master/main 继续用现有的 release 轨(build_map 原地保留),
只有 develop 等其它上架分支新建以分支名命名的轨道并复制一份构建号。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJaZ913U5GVdTkYHiRpvDU
2026-09-02 10:39:52 +09:00
shenleiandClaude Opus 5 bf7a5a0449 feat: 上架版本号按分支拆分,master / develop 各自独立
原来 master 与 develop 共用一条 release 轨,版本号和构建号绑在一起。
现改为每个上架分支各有一条轨道(轨道名即分支名),自测分支仍共用 feature 轨。

- 轨道记录新增 kind(release/feature),branch_track 存轨道名
- 旧的共用 release 轨及更早的单轨结构,加载时自动按上架分支拆开,
  已用构建号原样复制,拆分后各分支从原来的号继续递增
- 构建号递增时跨上架轨取同一 App_Ver 的最大值 +1:App Store 的构建号
  唯一性按 MARKETING_VERSION 全局判定,避免两分支同版本号时重号被拒
- 分支管理页轨道选择改为「上架(本分支独立版本号)/ 自测(共用)」,
  打包设置页按上架分支分块填写版本号

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJaZ913U5GVdTkYHiRpvDU
2026-09-02 10:32:43 +09:00
shenleiandClaude Sonnet 5 dae14a8a10 fix: App_Store 钉钉通知因缺少关键词「打包」被拦截,标题改回含「打包」
钉钉机器人启用了自定义关键词校验。9ace2c8 将 App_Store 标题改为
「【iOS】App_Store 包信息」,丢掉了「打包」二字,导致消息被钉钉拒收
(errmsg: 关键词不匹配),任务日志出现「钉钉通知未发送」。

标题改回「【iOS】App_Store 打包信息」,重新包含关键词;测试同步更新。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FETBaqMgRqx3kdpzccKg1
2026-09-01 11:34:57 +09:00
shenleiandClaude Sonnet 5 9ace2c8aca feat: App_Store 打包也发钉钉通知,Ad_Hoc/App_Store 各自的 @ 名单
- App_Store 完成后同样发送钉钉通知:标题改为「【iOS】App_Store 包信息」,
  下载链接为 IPA 地址,不带二维码。
- 钉钉 markdown 消息需在正文出现 @手机号 才会真正 @ 到人,故除 at.atMobiles
  外同时在正文追加。
- 新增 upload.dingtalk.at_mobiles(Ad_Hoc)与 at_mobiles_app_store(App_Store)
  两份名单,按 BUILD_TYPE 选用;配置页「钉钉通知」区新增两个输入框。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZULZ8fkiaiJrxqf318xTn
2026-09-01 11:02:57 +09:00
shenleiandClaude Sonnet 5 fa559101b9 fix: 企业微信 Schema 未替换,改写 CFBundleURLTypes 中 wxwork 条目
之前把 wxworkSchema 当成 Info.plist 顶层键写入,但真实 readoor31.plist
里企业微信 Schema 位于 CFBundleURLTypes(CFBundleURLName == "wxwork",
占位值 "1"),顶层键无效、占位符从未被替换。

改为并入 _update_plist 已有的 CFBundleURLTypes 替换逻辑:填写则替换
CFBundleURLSchemes,未配置则整条移除,与 weixinlogin/wechatpay/tencent 一致。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZULZ8fkiaiJrxqf318xTn
2026-09-01 11:02:50 +09:00
shenleiandClaude Sonnet 5 e10afb180d feat: APP 配置新增企业微信 Schema,写入 Info.plist wxworkSchema 键
与微信登录 AppID 处理一致:填写则写入 Info.plist 的 wxworkSchema,
未配置则移除该键。前端「关联配置」区新增输入框,打包流程透传该字段。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wQh7H1Dn3TjQm87TyYgvf
2026-08-28 16:38:22 +09:00
shenleiandClaude Sonnet 5 082c487cf8 fix: dSYM/混淆映射下载缺少认证头导致下载失败,dSYM 远端下载改为签名链接
历史记录点击 dSYM 下载后停留在接口地址本身:window.open 无法带
Authorization 头,接口未认证直接 401,重定向逻辑根本没机会执行。
改为前端先用 authFetch 认证请求,再按返回类型跳转远端地址或保存二
进制文件;远端地址同时改造成与 IPA 一致的 OSS 签名短时链接,避免
未签名地址在私有 bucket 下被拒绝。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:36:36 +09:00
shenleiandClaude Sonnet 5 1466e24c83 feat: dSYM 按上传配置一并保存到远端,本地清理后回退到远端地址下载
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:02:03 +09:00
shenleiandClaude Sonnet 5 c17b7d5ad9 fix: 皮肤包删除后被自动迁移逻辑复活,IPA 下载连击刷屏操作日志
_migrate_old_themes 之前每次 load_config() 都会重跑,只要证书仍引用某皮肤
且旧版 themes 目录还在,删除后会被立即重新生成;现改为仅首次执行一次并
在 config.json 中打标记跳过后续调用。

打包历史下载 IPA 按钮此前无连击防护,前端加 in-flight 禁用态,后端对同一
用户同一任务的下载操作日志增加 5 秒去重,避免连击刷屏审计日志。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:19:00 +09:00
shenleiandClaude Opus 5 84a2d3db01 feat: 安装页改为移动端卡片样式并支持深色模式
- 抽出 DOWNLOAD_PAGE_TEMPLATE,卡片布局 + 大按钮,适配 iPhone 安全区
- 通过 prefers-color-scheme 切换配色,主色沿用 #50a0ff
- 展示构建版本/Bundle ID/分支,补充 Safari 打开与信任描述文件提示
- 动态字段统一 html.escape,修正 install_url 中裸 & 未转义的问题

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:50:22 +09:00
shenleiandClaude Opus 5 64715a2c9e feat: 腾讯 AppID 自动补全 tencent 前缀
新增/编辑 App 时,腾讯 AppID 允许只填数字,后端统一归一化为
tencent 前缀;前端输入框失焦即时补全,保存前再校正一次。

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:14:08 +09:00
shenlei bb8a0afeac feat: 自动隐藏被覆盖的打包历史 2026-07-24 12:05:37 +09:00
shenlei 4dccc4d96b feat: 增加操作审计与受控下载 2026-07-22 18:12:19 +09:00
41 changed files with 2659 additions and 703 deletions
+1
View File
@@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败 # 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1 BUILD_TIMEOUT_HOURS=1
DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ---- # ---- Watchdog 健康监测 ----
# 健康检查间隔(秒) # 健康检查间隔(秒)
+3
View File
@@ -12,7 +12,10 @@ __pycache__/
# 运行时数据 # 运行时数据
backend/data/*.bak backend/data/*.bak
# 运行时配置:网页 UI 改配置、每次 App_Store 打包都会写盘,不纳入版本控制。
# 结构参见仓库根目录的 config.example.json;首次部署可 cp 一份作为初始种子。
config.json config.json
backend/data/config.json
ReadoorBranches/ ReadoorBranches/
build/ build/
+4 -2
View File
@@ -25,11 +25,13 @@ 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-branch: every branch owns a track in `config["versions"]["tracks"]`, named after the branch (master/main keeps the historical name `release`); `config["branch_track"]` maps branch → track name, and each track's `kind` is `release` (build number auto-increments on App_Store) or `feature` (never increments)
- Tags are buildable refs too: `config["tags"]` (names never collide with branches), tracked in `branch_track` like branches (default kind `release`); tasks store the name in `branch` plus `ref_type` (`branch`/`tag`), and tag builds check out `refs/tags/<tag>` detached
## 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
+90 -12
View File
@@ -13,6 +13,7 @@
- [Watchdog 健康监测](#watchdog-健康监测) - [Watchdog 健康监测](#watchdog-健康监测)
- [macOS 开机自启](#macos-开机自启) - [macOS 开机自启](#macos-开机自启)
- [开发模式](#开发模式) - [开发模式](#开发模式)
- [推送代码到 Git 服务器](#推送代码到-git-服务器)
- [自动化测试](#自动化测试) - [自动化测试](#自动化测试)
- [项目结构](#项目结构) - [项目结构](#项目结构)
- [常见问题](#常见问题) - [常见问题](#常见问题)
@@ -63,11 +64,24 @@ ADMIN_PASSWORD=your_secure_password
### 3. 构建与启动 ### 3. 构建与启动
推荐一键部署(构建 + 启动 + 健康监测,一步到位):
```bash
# 一键部署:build + start + watchdog(强制重来)
./deploy.sh up
```
`up` 会自动构建前端、(强制)重启后端并**轮询 `/api/health` 直到就绪**才返回,再拉起 watchdog,最后打印状态。适合首次部署和更新代码后重新上线。
> ⚠️ `up`/`start` 采用「强制重来」语义:若检测到旧服务会先停再起,正在进行的打包任务会被中断。
也可以分步执行:
```bash ```bash
# 一键构建(安装依赖 + 构建前端) # 一键构建(安装依赖 + 构建前端)
./deploy.sh build ./deploy.sh build
# 启动服务 # 启动服务(等待就绪;端口被非本项目进程占用时会明确报错)
./deploy.sh start ./deploy.sh start
``` ```
@@ -156,19 +170,55 @@ 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 等 |
| 分支管理 | 可打包的代码分支列表 | | 分支管理 | 可打包的代码分支和 Tag 列表,以及各自的版本轨道类型(上架 / 自测);Tag 打包时以游离 HEAD 检出 `refs/tags/<tag>` |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码 | | 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 | | 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
### 版本号与版本轨道
**每个分支各有一条版本轨道(track)**,App_Ver 与构建号完全独立、互不影响。轨道名即分支名(master / main 沿用历史名称 `release`),轨道类型 `kind` 决定构建号行为:
| 轨道类型 | 默认适用分支 | 构建号行为 |
|----------|--------------|-----------|
| `release`(上架) | master、main、develop、dev、release-*、hotfix-* | App_Store 打包时按当前 App_Ver 自动 +1 |
| `feature`(自测) | 其余分支 | 永不自增,始终使用配置页填写的值(默认 `.0` |
```jsonc
"versions": {
"tracks": {
"release": { "kind": "release", "app_ver": "2.195.0", "build_map": { "2.195.0": 7 } }, // master
"develop": { "kind": "release", "app_ver": "2.196.0", "build_map": {} },
"feature/login": { "kind": "feature", "app_ver": "2.100.0", "build_map": {} }
}
},
"branch_track": { "master": "release", "develop": "develop", "feature/login": "feature/login" }
```
- 新增分支时自动建一条同名轨道,类型按分支名推断,可在「分支管理」页逐个改(只改构建号是否自增,版本号仍各自独立)。
- Tag 存在 `config["tags"]`,与分支不可重名,同样在 `branch_track` 中各有一条同名轨道,新增时默认为上架轨。
- 「打包设置」页按分支分块填写版本号。
- 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。
- App Store 的构建号唯一性按 App_Ver 全局判定,因此两个上架分支若临时用了同一个 App_Ver,递增时会跨轨道取最大值 +1,避免重号被拒。
- 旧配置(master/develop 共用的 `release` 轨、各自测分支共用的 `feature` 轨)首次启动时自动拆开:master 留在 `release` 轨,其余分支新建同名轨道并复制一份已用构建号。
- 需要两个分支共用同一条版本线时,可直接把 `branch_track` 里的值指向另一条轨道名。
### 服务端自动化资源 ### 服务端自动化资源
项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。 项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
@@ -179,6 +229,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 +249,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 +263,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 +305,7 @@ logs/
| 角色 | 权限 | | 角色 | 权限 |
|------|------| |------|------|
| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) | | 管理员 | 所有功能 + 用户管理(增删改查、角色切换) |
| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) | | 普通用户 | 打包、查看历史、管理 Apps 配置(不可修改版本号及其余配置、不可管理用户) |
### 管理用户 ### 管理用户
@@ -370,6 +423,31 @@ launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist
--- ---
## 推送代码到 Git 服务器
本项目托管在自建 Git 服务(`origin` 走 HTTPS),推送需要账号凭据:
```bash
# 提交改动
git add -A
git commit -m "feat: 描述你的改动"
# 推送到远程 main
git push origin main
```
首次推送会提示输入用户名和密码/访问令牌。若不想每次都输入,可启用 macOS 钥匙串缓存凭据(只需配置一次):
```bash
git config --global credential.helper osxkeychain
```
配置后,下一次 `git push` 输入的账号密码会被安全保存到钥匙串,后续免输入。
> 若 Git 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
---
## 自动化测试 ## 自动化测试
### 运行测试 ### 运行测试
+2
View File
@@ -146,6 +146,8 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production") JWT_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:
-455
View File
@@ -1,455 +0,0 @@
{
"apps": {
"100": {
"name": "阅门户测试App",
"server": "测试环境",
"AppGuid": "647372741900537856",
"API": "https://api3-dev.readoor.cn",
"AssDom": "applinks:dev-data1.readoor.cn",
"UniversalLink": "https://dev-data1.readoor.cn",
"weixinlogin": "wx30847bd529fe856c",
"weixinpay": "wx30847bd529fe856c",
"tencent": "tencent1105839866",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "0e6b269a",
"skins": [
"ymh.zip"
]
},
"101": {
"name": "申学",
"server": "测试环境",
"AppGuid": "571681541265068032",
"API": "https://api3-dev.readoor.cn",
"AssDom": "applinks:dev-data1.readoor.cn",
"UniversalLink": "https://dev-data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx30847bd529fe856c",
"weixinpay": "wx30847bd529fe856c",
"tencent": "tencent1105839866",
"certificates": {
"Ad_Hoc": {
"name": "com.kingsunsoft.CollectionBooks.Xinhua",
"pro": "TouchLinked_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "c843d672",
"skins": [
"ymh.zip"
]
},
"102": {
"name": "英汉大词典测试",
"server": "测试环境",
"AppGuid": "1593413622",
"API": "https://api3-dev.readoor.cn",
"certificates": {
"Ad_Hoc": {
"name": "com.yiwen.ecdstandalone",
"pro": "TouchLinked_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "weread"
}
},
"upload_key": "9b470281",
"skins": [
"weread.zip"
]
},
"103": {
"name": "英汉大词典",
"server": "正式环境",
"AppGuid": "1607584582",
"API": "https://api3.readoor.cn",
"certificates": {
"Ad_Hoc": {
"name": "com.yiwen.ecdstandalone",
"pro": "ecdstandalone_Adhoc",
"cer": "iPhone Distribution: Shanghai Translation Publishing House (8S29652W62)",
"theme": ""
},
"App_Store": {
"name": "com.yiwen.ecdstandalone",
"pro": "ecdstandalone_provisioning_profiles",
"cer": "iPhone Distribution: Shanghai Translation Publishing House (8S29652W62)",
"theme": ""
}
},
"upload_key": "2e743ca9"
},
"201": {
"name": "3.1版测试",
"server": "正式环境",
"AppGuid": "447445078487633920",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc1": {
"name": "cn.touchv.a0yny24",
"pro": "a0yny24_adhoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
},
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "2522c9bb",
"skins": [
"ymh.zip"
]
},
"202": {
"name": "北教辅学",
"server": "正式环境",
"AppGuid": "416182063440367616",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx1416817fa79df772",
"weixinpay": "wx1416817fa79df772",
"tencent": "",
"certificates": {
"App_Store": {
"name": "cn.touchv.adsATM4",
"pro": "beijiaofuxue",
"cer": "iPhone Distribution: Jingban Beijing Education Culture Media Corporation Limited (343SD396U6)",
"theme": "bj"
}
},
"upload_key": "ca03a41b",
"skins": [
"bj.zip"
]
},
"203": {
"name": "智慧云学",
"server": "正式环境",
"AppGuid": "576814720200249344",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "689c69a2",
"skins": [
"ymh.zip"
]
},
"204": {
"name": "北洋学堂",
"server": "正式环境",
"AppGuid": "559336679295455232",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx1df85bf317e35171",
"weixinpay": "wx1df85bf317e35171",
"tencent": "",
"certificates": {
"App_Store": {
"name": "cn.tjdxcbs.aBEM0B3",
"pro": "beiyangxuetang",
"cer": "iPhone Distribution: Tianjin University Press Co., Ltd.. (D3NDDP8CZU)",
"theme": "byxt"
}
},
"upload_key": "27fb0395",
"skins": [
"byxt.zip"
]
},
"205": {
"name": "交我学",
"server": "正式环境",
"AppGuid": "119822865246760960",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "0718cd13",
"skins": [
"ymh.zip"
]
},
"206": {
"name": "上财云津",
"server": "正式环境",
"AppGuid": "389000208056356864",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx6f1163efcb202265",
"weixinpay": "wx6f1163efcb202265",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.shcj.aGh0UB2",
"pro": "aGh0UB2_adhoc",
"cer": "iPhone Distribution: Shanghai University of Finance and Economics Press Co., Ltd. (8S29L934WG)",
"theme": "shangcai"
},
"App_Store": {
"name": "cn.shcj.aGh0UB2",
"pro": "aGh0UB2_dis",
"cer": "iPhone Distribution: Shanghai University of Finance and Economics Press Co., Ltd. (8S29L934WG)",
"theme": "shangcai"
}
},
"upload_key": "8e326070",
"skins": [
"shangcai.zip"
]
},
"301": {
"name": "教育汇",
"server": "华师大环境",
"AppGuid": "152115765687226368",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "jPtBO1QJzkkHLu3F896a0569e78de46979050fb2605a6a2a5",
"weixinlogin": "wx9e23021f7f4313c0",
"weixinpay": "wx9e23021f7f4313c0",
"tencent": "tencent1105919518",
"certificates": {
"Ad_Hoc": {
"name": "com.ecnup.aSCLdl2",
"pro": "aSCLdl2_adhoc",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "hsd"
},
"App_Store": {
"name": "com.ecnup.aSCLdl2",
"pro": "com-ecnup-aSCLdl2_Distribution",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "hsd"
}
},
"upload_key": "f6fe827d",
"skins": [
"hsd.zip"
]
},
"302": {
"name": "教育汇测试",
"server": "华师大环境",
"AppGuid": "335068450529882112",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx9e23021f7f4313c0",
"weixinpay": "wx9e23021f7f4313c0",
"tencent": "tencent1105919518",
"certificates": {
"Ad_Hoc": {
"name": "com.ecnup.a4PBPk",
"pro": "a4PBPk_adhoc",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "hsd"
}
},
"upload_key": "4f9c7953",
"skins": [
"hsd.zip"
]
},
"303": {
"name": "大夏书系",
"server": "华师大环境",
"AppGuid": "639419713965142016",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "jPtBO1QJzkkHLu3F896a0569e78de46979050fb2605a6a2a5",
"weixinlogin": "wx1d95492fee2ce298",
"weixinpay": "wx1d95492fee2ce298",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "com.ecnup.aaS16d3",
"pro": "aaS16d3_adhoc",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "dxsx"
},
"App_Store": {
"name": "com.ecnup.aaS16d3",
"pro": "aaS16d3_dis",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "dxsx"
}
},
"upload_key": "a29091d9",
"skins": [
"dxsx.zip"
]
},
"304": {
"name": "HSD测试App",
"server": "华师大环境",
"AppGuid": "470235916070694912",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx9e23021f7f4313c0",
"weixinpay": "wx9e23021f7f4313c0",
"tencent": "tencent1105919518",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.aO3pOM",
"pro": "aO3pOM_adhoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "86bfb114",
"skins": [
"ymh.zip"
]
},
"401": {
"name": "WE Read",
"server": "外教环境",
"AppGuid": "631907986336071680",
"API": "https://weread-api3.sflep.com/api3",
"AssDom": "applinks:wereadossda.sflep.com",
"UniversalLink": "https://wereadossda.sflep.com",
"AlivcLicenseKey": "",
"weixinlogin": "wx6d6c63f4df7e5720",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.sflep.aSiLjn",
"pro": "cn_sflep_aSiLjn_adhoc",
"cer": "iPhone Distribution: Shanghai Foreign Language Education Press Co.,Ltd. (G4X56AY2VB)",
"theme": "weread"
},
"App_Store": {
"name": "cn.sflep.aSiLjn",
"pro": "cn-sflep-aSiLjn",
"cer": "iPhone Distribution: Shanghai Foreign Language Education Press Co.,Ltd. (G4X56AY2VB)",
"theme": "weread"
}
},
"upload_key": "87e7656f",
"skins": [
"weread.zip"
]
}
},
"schemes": {
"1": {
"name": "readoor31",
"ossFloder": "readoor"
},
"2": {
"name": "readoor31OtherPay",
"ossFloder": "readoor"
},
"3": {
"name": "readoorShenXue",
"ossFloder": "readoor"
},
"4": {
"name": "readoorDict",
"ossFloder": "ecdict"
}
},
"servers": {
"测试环境": {
"api": "https://api3-dev.readoor.cn",
"assDom": "applinks:dev-data1.readoor.cn",
"universalLink": "https://dev-data1.readoor.cn"
},
"正式环境": {
"api": "https://api3.readoor.cn",
"assDom": "applinks:data1.readoor.cn",
"universalLink": "https://data1.readoor.cn"
},
"华师大环境": {
"api": "https://api3.ecnupress.com.cn",
"assDom": "applinks:data1.ecnupress.com.cn",
"universalLink": "https://data1.ecnupress.com.cn"
},
"外教环境": {
"api": "https://weread-api3.sflep.com/api3",
"assDom": "applinks:wereadossda.sflep.com",
"universalLink": "https://wereadossda.sflep.com"
}
},
"branches": [
"master",
"develop",
"feature"
],
"upload": {
"mode": "webdav",
"oss": {
"access_key_id": "",
"access_key_secret": "",
"endpoint": "oss-cn-beijing.aliyuncs.com",
"bucket_name": "",
"base_url": ""
},
"webdav": {
"server_url": "http://192.168.21.200:5005",
"username": "slcydia",
"password": "S9rr9359p:4o)nfel?W",
"base_path": "/APPBuildInfo",
"public_url": "http://192.168.21.200:5005/APPBuildInfo"
},
"dingtalk": {
"enabled": false,
"webhook_url": "",
"secret": ""
}
},
"versions": {
"app_ver": "2.195.0",
"build_ver": "2.195.0.0"
}
}
+3
View File
@@ -23,7 +23,10 @@ def _migrate_db():
"""简单的数据库迁移:为旧表添加缺失的列""" """简单的数据库迁移:为旧表添加缺失的列"""
migrations = [ migrations = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "ref_type", "VARCHAR DEFAULT 'branch'"),
("tasks", "error_category", "VARCHAR"), ("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"),
("tasks", "dsym_url", "VARCHAR"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from .database import init_db from .database import init_db
from .routers import config, apps, tasks, auth, users from .routers import config, apps, tasks, auth, users, operations
from .services.log_streamer import log_streamer from .services.log_streamer import log_streamer
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user from .deps import decode_current_user
@@ -48,6 +48,7 @@ app.include_router(users.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(operations.router)
@app.websocket("/ws/tasks/{task_id}") @app.websocket("/ws/tasks/{task_id}")
+24 -1
View File
@@ -15,7 +15,8 @@ class Task(Base):
scheme_id = Column(String, nullable=False) scheme_id = Column(String, nullable=False)
scheme_name = Column(String, nullable=False) scheme_name = Column(String, nullable=False)
obfuscation = Column(Boolean, default=False) obfuscation = Column(Boolean, default=False)
branch = Column(String, default="main") branch = Column(String, default="main") # 分支名或 Tag 名,由 ref_type 区分
ref_type = Column(String, default="branch") # branch/tag
# 状态 # 状态
status = Column(String, default="pending") # pending/running/completed/failed/cancelled status = Column(String, default="pending") # pending/running/completed/failed/cancelled
@@ -25,11 +26,14 @@ 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)
oss_url = Column(String) oss_url = Column(String)
dsym_path = Column(String) dsym_path = Column(String)
dsym_url = Column(String)
obfuscation_maps_path = Column(String) obfuscation_maps_path = Column(String)
qr_code_path = Column(String) qr_code_path = Column(String)
build_dir = Column(String) build_dir = Column(String)
@@ -60,3 +64,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)
+7 -1
View File
@@ -1,7 +1,7 @@
"""打包选择 API""" """打包选择 API"""
from fastapi import APIRouter from fastapi import APIRouter
from .config import load_config from .config import get_tags, load_config
router = APIRouter(prefix="/api", tags=["apps"]) router = APIRouter(prefix="/api", tags=["apps"])
@@ -44,3 +44,9 @@ async def get_branches_for_build():
"""获取分支列表(供打包选择)""" """获取分支列表(供打包选择)"""
config = load_config() config = load_config()
return config.get("branches", ["main"]) return config.get("branches", ["main"])
@router.get("/tags")
async def get_tags_for_build():
"""获取 Tag 列表(供打包选择)"""
return get_tags(load_config())
+493 -28
View File
@@ -26,10 +26,9 @@ from ..deps import get_current_user, require_admin
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict: def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。""" """普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/") path = request.url.path.rstrip("/")
if (path == "/api/config/apps" or path.startswith("/api/config/apps/") if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
or path == "/api/config/versions"):
return user return user
return require_admin(user) return require_admin(user)
@@ -59,6 +58,8 @@ DEFAULT_UPLOAD = {
"enabled": False, "enabled": False,
"webhook_url": "", "webhook_url": "",
"secret": "", "secret": "",
"at_mobiles": [],
"at_mobiles_app_store": [],
}, },
} }
@@ -90,11 +91,303 @@ DEFAULT_SERVERS = {
} }
} }
DEFAULT_VERSIONS = { DEFAULT_APP_VER = "2.180.0"
"app_ver": "2.180.0",
"build_ver": "2.180.0.0", # 版本轨道:每个分支各有一条独立轨道,轨道名即分支名(master/main 沿用历史名称 release),
# 各分支的 App_Ver 与构建号互不影响。轨道类型(kind)决定构建号行为:
# release(上架)— App_Store 打包时自增;feature(自测)— 永不自增。
KIND_RELEASE = "release"
KIND_FEATURE = "feature"
KINDS = (KIND_RELEASE, KIND_FEATURE)
TRACK_RELEASE = "release" # master/main 的轨道名(沿用旧名,历史构建号原地保留)
TRACK_FEATURE = "feature" # 旧版所有自测分支共用的轨道名,迁移时按分支拆开
FIXED_TRACKS = (TRACK_RELEASE, TRACK_FEATURE)
# 按名字推断轨道类型的默认规则;轨道记录里的 kind 为准,此处只用于新建轨道。
MASTER_BRANCH_NAMES = {"master", "main"}
RELEASE_BRANCH_NAMES = MASTER_BRANCH_NAMES | {"develop", "dev"}
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-")
# 可打包的代码引用类型:分支在 config["branches"]Tag 在 config["tags"],两者名字不可重复,
# 因此任务里只存名字即可反查类型;分支与 Tag 共用 branch_track 映射各自的版本轨道。
REF_BRANCH = "branch"
REF_TAG = "tag"
def get_tags(config: dict) -> list:
"""取可打包的 Tag 列表。"""
tags = config.get("tags")
return tags if isinstance(tags, list) else []
def get_ref_type(config: dict, name: str) -> str:
"""按名字判断是分支还是 Tag。"""
return REF_TAG if name in get_tags(config) else REF_BRANCH
def _build_refs(config: dict) -> list:
"""所有可打包的引用(分支在前,Tag 在后)。"""
return list(config.get("branches", []) or []) + get_tags(config)
def _is_release_branch(branch: str) -> bool:
"""按分支名推断是否为上架分支。"""
name = (branch or "").strip().lower()
return name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES)
def _default_kind_for_branch(branch: str) -> str:
"""新建轨道时按分支名推断轨道类型。"""
return KIND_RELEASE if _is_release_branch(branch) else KIND_FEATURE
def _default_kind_for_ref(config: dict, name: str) -> str:
"""新建轨道时的缺省类型:Tag 一般用于重打已发布的版本,默认上架轨;分支按名字推断。"""
if get_ref_type(config, name) == REF_TAG:
return KIND_RELEASE
return _default_kind_for_branch(name)
def _default_track_for_branch(branch: str) -> str:
"""分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。
分支恰好叫 release / feature(且不是 master)时加前缀避让,免得撞上固定轨道名。
"""
if (branch or "").strip().lower() in MASTER_BRANCH_NAMES:
return TRACK_RELEASE
return f"branch:{branch}" if branch in FIXED_TRACKS else branch
def _new_track(app_ver: str = DEFAULT_APP_VER, kind: str = KIND_RELEASE) -> dict:
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
return {"kind": kind, "app_ver": app_ver, "build_map": {}}
def _seed_track(tracks: dict, kind: str, legacy: dict = None) -> dict:
"""新建轨道的初值:优先沿用被拆分的旧共用轨(含已用构建号),
否则沿用任一同类型轨道的 App_Ver,都没有则用默认版本号。"""
if legacy:
return {"kind": kind, "app_ver": legacy["app_ver"], "build_map": dict(legacy["build_map"])}
for track in tracks.values():
if isinstance(track, dict) and track.get("kind") == kind:
return _new_track(track.get("app_ver") or DEFAULT_APP_VER, kind)
return _new_track(kind=kind)
def _ensure_track(config: dict, track_name: str, kind: str = None) -> dict:
"""确保轨道存在(新建时沿用同类型轨道的 App_Ver,构建号从 .0 起);
显式传 kind 时同时把轨道类型改成它。"""
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track = tracks.get(track_name)
if not isinstance(track, dict):
track = tracks[track_name] = _seed_track(
tracks, kind or _default_kind_for_track(track_name)
)
elif kind:
track["kind"] = kind
return track
def _default_kind_for_track(track_name: str) -> str:
"""轨道记录缺 kind 时按轨道名推断(轨道名即分支名)。"""
if track_name == TRACK_RELEASE:
return KIND_RELEASE
if track_name == TRACK_FEATURE:
return KIND_FEATURE
return _default_kind_for_branch(track_name)
def _legacy_single_track(versions: dict) -> dict:
"""把最旧的单轨结构读成 {app_ver, build_map}。"""
app_ver = versions.get("app_ver") or DEFAULT_APP_VER
build_map = versions.get("build_map")
if not isinstance(build_map, dict):
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():
build_map[".".join(parts[:3])] = int(parts[3])
versions.pop("app_ver", None)
versions.pop("build_map", None)
versions.pop("build_ver", None)
return {"app_ver": app_ver, "build_map": build_map}
def _legacy_shared_track(tracks: dict, name: str) -> dict:
"""取出待拆分的旧共用轨(迁移后的轨道都带 kind,没有 kind 的即旧共用轨)。"""
track = tracks.get(name)
if not isinstance(track, dict) or "kind" in track:
return None
build_map = track.get("build_map")
return {
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_map": build_map if isinstance(build_map, dict) else {},
} }
def _ensure_versions(config: dict) -> bool:
"""确保 versions 为「每个分支一条轨道」结构,并迁移旧配置。
旧式单轨 ``{"app_ver": ..., "build_map": ...}``、旧的 master/develop 共用
``release`` 轨和所有自测分支共用的 ``feature`` 轨,都会按分支拆开:
master/main 留在 release 轨(历史构建号原地保留),其余分支新建以分支名命名的
轨道并复制一份已用构建号,使拆分后每个分支都从原来的构建号继续。
"""
versions = config.get("versions")
changed = False
if not isinstance(versions, dict):
versions = {}
config["versions"] = versions
changed = True
# 待拆分到各分支的旧共用轨(按类型各一条)
legacy = {KIND_RELEASE: None, KIND_FEATURE: None}
tracks = versions.get("tracks")
if not isinstance(tracks, dict):
legacy[KIND_RELEASE] = _legacy_single_track(versions)
legacy[KIND_FEATURE] = dict(legacy[KIND_RELEASE], build_map={})
tracks = {}
versions["tracks"] = tracks
changed = True
else:
# release 轨拆分后继续作为 master/main 的轨道,feature 轨拆分后成为无人引用的模板
legacy[KIND_RELEASE] = _legacy_shared_track(tracks, TRACK_RELEASE)
legacy[KIND_FEATURE] = _legacy_shared_track(tracks, TRACK_FEATURE)
splitting = any(legacy.values())
split_kinds = set() # 实际被拆分出去的旧共用轨类型
for name, track in tracks.items():
if not isinstance(track, dict):
track = tracks[name] = _new_track(kind=_default_kind_for_track(name))
changed = True
if track.get("kind") not in KINDS:
track["kind"] = _default_kind_for_track(name)
changed = True
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 _build_refs(config):
name = branch_track.get(branch)
if splitting and name in FIXED_TRACKS:
# 旧的共用轨 → 各分支自己的轨道(master/main 仍留在 release 轨)
name = _default_track_for_branch(branch)
if not isinstance(name, str) or not name:
name = _default_track_for_branch(branch)
if branch_track.get(branch) != name:
branch_track[branch] = name
changed = True
if name not in tracks:
kind = _default_kind_for_ref(config, branch)
tracks[name] = _seed_track(tracks, kind, legacy[kind])
if legacy[kind]:
split_kinds.add(kind)
changed = True
# 内容已复制到各分支轨道、又没有分支引用的旧共用轨,直接删掉,免得配置里留下无用条目
for name in FIXED_TRACKS:
kind = _default_kind_for_track(name)
if kind in split_kinds and name in tracks and name not in branch_track.values():
tracks.pop(name)
changed = True
return changed
def _primary_track_name(config: dict) -> str:
"""主上架轨:第一个上架分支(kind=release)所属的轨道;都没有则取第一个分支的轨道。
仅用于顶层 app_ver/build_ver 的兼容字段和未指定轨道时的缺省值。
"""
fallback = TRACK_RELEASE
for index, branch in enumerate(config.get("branches", []) or []):
name = get_track_name(config, branch)
if index == 0:
fallback = name
if is_release_track(get_track(config, name)):
return name
return fallback
def get_track_name(config: dict, branch: str) -> str:
"""取分支所属的版本轨道名。"""
name = (config.get("branch_track") or {}).get(branch)
tracks = (config.get("versions") or {}).get("tracks") or {}
if isinstance(name, str) and name in tracks:
return name
return _default_track_for_branch(branch)
def get_track(config: dict, track_name: str) -> dict:
"""取某条轨道的版本记录副本(不存在时返回默认值;只读,不写回配置)。
未迁移的旧记录没有 kind,按轨道名(即分支名)推断补上。
"""
tracks = (config.get("versions") or {}).get("tracks") or {}
track = tracks.get(track_name)
kind = _default_kind_for_track(track_name)
if not isinstance(track, dict):
return _new_track(kind=kind)
return {**track, "kind": track.get("kind") if track.get("kind") in KINDS else kind}
def is_release_track(track: dict) -> bool:
"""该轨道是否为上架轨(App_Store 打包时构建号自增)。"""
return track.get("kind", KIND_RELEASE) == KIND_RELEASE
def next_build_no(tracks: dict, app_ver: str) -> int:
"""该 App_Ver 下一个可用的构建号。
构建号唯一性由 App Store 按 MARKETING_VERSION 全局判定,与分支无关,
因此取所有上架轨在这个版本号上已用到的最大值再 +1,避免两个上架分支
临时使用同一 App_Ver 时产生重复构建号被拒。
"""
used = [
int((track.get("build_map") or {}).get(app_ver, 0))
for track in tracks.values()
if isinstance(track, dict) and is_release_track(track)
]
return max(used, default=0) + 1
def resolve_track(config: dict, branch: str, track: str, default_kind: str = None):
"""把用户传入的轨道值解析为 (轨道名, 轨道类型)。
传类型(release / feature)时用分支自己的轨道,并把它设成该类型;
也可直接传已存在的轨道名,让这个分支与另一个分支共用同一条版本线(类型不变)。
不传时类型取 default_kind,缺省按分支名推断。
"""
value = (track or "").strip()
if not value:
return _default_track_for_branch(branch), default_kind or _default_kind_for_branch(branch)
if value in KINDS:
return _default_track_for_branch(branch), value
if value in ((config.get("versions") or {}).get("tracks") or {}):
return value, None
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {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 = {
"测试环境": 1, "测试环境": 1,
@@ -148,6 +441,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")
@@ -186,8 +487,14 @@ def _ensure_upload_keys(config: dict) -> bool:
def _migrate_old_themes(config: dict) -> bool: def _migrate_old_themes(config: dict) -> bool:
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更""" """将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更
changed = False
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
"""
if config.get("_legacy_themes_migrated"):
return False
themes_dir = AUTOMATION_DIR / "themes" themes_dir = AUTOMATION_DIR / "themes"
for app_id, app in config.get("apps", {}).items(): for app_id, app in config.get("apps", {}).items():
upload_key = app.get("upload_key", "") upload_key = app.get("upload_key", "")
@@ -222,8 +529,8 @@ def _migrate_old_themes(config: dict) -> bool:
app["skins"] = skins app["skins"] = skins
if theme != skin_name: if theme != skin_name:
cert["theme"] = skin_name cert["theme"] = skin_name
changed = True config["_legacy_themes_migrated"] = True
return changed return True
def load_config() -> dict: def load_config() -> dict:
@@ -247,13 +554,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 +613,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 +623,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 +636,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 +718,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,7 +837,12 @@ async def get_branches():
@router.post("/branches") @router.post("/branches")
async def add_branch(data: dict): async def add_branch(data: dict):
"""新增分支""" """新增分支,可指定版本轨道类型(缺省按分支名推断)。
每个分支都有自己独立的一条轨道(轨道名即分支名):track 传 release 表示上架用、
App_Store 打包时构建号自增,feature 表示自测用、构建号不自增;
也可直接传已有轨道名,让该分支与另一个分支共用同一条版本线。
"""
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="分支名称不能为空")
@@ -512,13 +852,40 @@ async def add_branch(data: dict):
branches = config.get("branches", ["main"]) branches = config.get("branches", ["main"])
if name in branches: if name in branches:
raise HTTPException(status_code=400, detail="分支已存在") raise HTTPException(status_code=400, detail="分支已存在")
if name in get_tags(config):
raise HTTPException(status_code=400, detail="已存在同名 Tag")
track, kind = resolve_track(config, name, data.get("track"))
branches.append(name) branches.append(name)
config["branches"] = branches config["branches"] = branches
config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track, kind)
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):
"""修改分支或 Tag 的版本轨道类型(release=上架,构建号自增;feature=自测,不自增)。
每个分支用自己的一条轨道,切换类型只改这条轨道的行为,版本号仍与其它分支互不影响。
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
"""
branch = (data.get("branch") or "").strip()
if not (data.get("track") or "").strip():
raise HTTPException(status_code=400, detail="未指定版本轨道")
async with _config_lock:
config = load_config()
if branch not in _build_refs(config):
raise HTTPException(status_code=404, detail="分支不存在")
track, kind = resolve_track(config, branch, data.get("track"))
config.setdefault("branch_track", {})[branch] = track
_ensure_track(config, track, kind)
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,10 +895,56 @@ 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": "分支已删除"}
# Tag 管理 API:打包时检出指定 Tag 的代码,版本轨道与分支一样按名字各自独立
@router.get("/tags")
async def get_tags_list():
"""获取 Tag 列表"""
return get_tags(load_config())
@router.post("/tags")
async def add_tag(data: dict):
"""新增 Tag,可指定版本轨道类型(缺省为上架轨,App_Store 打包构建号自增)。"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag 名称不能为空")
async with _config_lock:
config = load_config()
tags = get_tags(config)
if name in tags:
raise HTTPException(status_code=400, detail="Tag 已存在")
if name in config.get("branches", []):
raise HTTPException(status_code=400, detail="已存在同名分支")
track, kind = resolve_track(config, name, data.get("track"), default_kind=KIND_RELEASE)
tags.append(name)
config["tags"] = tags
config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track, kind)
save_config(config)
return {"message": "Tag 已添加", "track": track}
@router.delete("/tags/{tag_name:path}")
async def delete_tag(tag_name: str):
"""删除 Tag"""
async with _config_lock:
config = load_config()
tags = get_tags(config)
if tag_name not in tags:
raise HTTPException(status_code=404, detail="Tag 不存在")
tags.remove(tag_name)
config["tags"] = tags
config.get("branch_track", {}).pop(tag_name, None)
save_config(config)
return {"message": "Tag 已删除"}
@router.get("/build") @router.get("/build")
async def get_build_settings(db: Session = Depends(get_db)): async def get_build_settings(db: Session = Depends(get_db)):
"""获取打包设置""" """获取打包设置"""
@@ -579,32 +992,84 @@ 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 及其当前构建号。
每个上架分支(master / develop 等)一条独立轨道,轨道名即分支名;自测分支共用 feature 轨。
build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
仅供展示;实际递增只发生在上架轨的 App_Store 打包时,feature 轨永不自增。
顶层 app_ver / build_ver 为主上架轨的镜像,保留给旧客户端。
"""
config = load_config() config = load_config()
versions = config.get("versions", DEFAULT_VERSIONS.copy()) tracks = {
name: {
"kind": track.get("kind") if track.get("kind") in KINDS else _default_kind_for_track(name),
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_ver": track_build_ver(track),
"build_map": track.get("build_map") or {},
}
for name, track in ((config.get("versions") or {}).get("tracks") or {}).items()
if isinstance(track, dict)
}
primary = _primary_track_name(config)
return { return {
"app_ver": versions.get("app_ver", DEFAULT_VERSIONS["app_ver"]), **{k: v for k, v in tracks.get(primary, {}).items() if k != "kind"},
"build_ver": versions.get("build_ver", DEFAULT_VERSIONS["build_ver"]), "tracks": tracks,
"primary_track": primary,
"branch_track": {
branch: get_track_name(config, branch)
for branch in _build_refs(config)
},
} }
@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:轨道名,上架分支为分支名(如 master、develop),自测为 feature;缺省为主上架轨。
- 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
"""
track_name = (data.get("track") or "").strip()
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_name = track_name or _primary_track_name(config)
"build_ver": build_ver, if track_name not in tracks:
} raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track_name}")
track = tracks[track_name]
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
+65
View File
@@ -0,0 +1,65 @@
"""操作日志与统计 API(仅管理员)。"""
import csv
import io
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import OperationLog
router = APIRouter(prefix="/api/operations", tags=["operations"])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _query_logs(db: Session, days: int, username: str = "", action: str = ""):
since = datetime.utcnow() - timedelta(days=max(1, min(days, 365)))
query = db.query(OperationLog).filter(OperationLog.created_at >= since)
if username:
query = query.filter(OperationLog.username == username)
if action:
query = query.filter(OperationLog.action == action)
return query
@router.get("/summary")
async def operation_summary(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
query = _query_logs(db, days)
total = query.count()
action_counts = dict(query.with_entities(OperationLog.action, func.count()).group_by(OperationLog.action).all())
users = query.with_entities(OperationLog.username, func.count()).group_by(OperationLog.username).order_by(func.count().desc()).limit(10).all()
apps = query.filter(OperationLog.app_name.isnot(None)).with_entities(OperationLog.app_name, func.count()).group_by(OperationLog.app_name).order_by(func.count().desc()).limit(10).all()
return {"days": days, "total": total, "action_counts": action_counts,
"top_users": [{"name": name, "count": count} for name, count in users],
"top_apps": [{"name": name, "count": count} for name, count in apps]}
@router.get("")
async def list_operations(days: int = Query(30, ge=1, le=365), username: str = "", action: str = "", limit: int = Query(100, ge=1, le=500), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
logs = _query_logs(db, days, username, action).order_by(OperationLog.created_at.desc()).limit(limit).all()
return [{"id": log.id, "created_at": log.created_at, "username": log.username,
"action": log.action, "app_name": log.app_name, "task_id": log.task_id,
"resource_type": log.resource_type, "resource_name": log.resource_name,
"detail": json.loads(log.detail_json or "{}"), "client_ip": log.client_ip} for log in logs]
@router.get("/export")
async def export_operations(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["时间", "用户", "操作", "App", "资源类型", "资源名称", "IP", "详情"])
for log in _query_logs(db, days).order_by(OperationLog.created_at.desc()).all():
writer.writerow([log.created_at, log.username, log.action, log.app_name or "", log.resource_type or "", log.resource_name or "", log.client_ip or "", log.detail_json or ""])
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=operation-logs.csv"})
+117 -10
View File
@@ -3,10 +3,11 @@ import os
import json import json
import shutil import shutil
import tempfile import tempfile
import time
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
@@ -18,9 +19,13 @@ from ..deps import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
_recent_download_logs: dict[str, float] = {}
@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
@@ -50,6 +55,10 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}", detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
) )
# 分支与 Tag 不允许重名,按名字即可确定要检出的引用类型
from .config import get_ref_type
ref_type = get_ref_type(config, task.branch)
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
db_task = Task( db_task = Task(
id=task_id, id=task_id,
@@ -60,9 +69,13 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
scheme_name=scheme.get("displayName") or scheme_name, scheme_name=scheme.get("displayName") or scheme_name,
obfuscation=task.obfuscation, obfuscation=task.obfuscation,
branch=task.branch, branch=task.branch,
ref_type=ref_type,
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, "ref_type": ref_type, "build_type": task.build_type})
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
@@ -100,12 +113,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 +142,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 +152,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 +175,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
@@ -188,6 +210,25 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
except (json.JSONDecodeError, DistributionError) as exc: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
# dSYM 压缩包的远端对象键与 IPA 独立,同一版本/分支重复打包会复用并覆盖同一个远端文件,
# 仍有其他历史记录引用时不能删除。
has_shared_dsym = bool(
task.dsym_url and db.query(Task).filter(
Task.id != task.id,
Task.dsym_url == task.dsym_url,
).first()
)
if task.dsym_url and not has_shared_dsym:
if not task.config_json:
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端 dSYM 文件")
try:
build_config = json.loads(task.config_json)
from .config import load_config
from ..services.distribution import DistributionError, delete_published_dsym
delete_published_dsym(build_config, load_config().get("upload", {}))
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端 dSYM 删除失败,记录未删除:{exc}") from exc
# 删除日志文件 # 删除日志文件
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists(): if log_path.exists():
@@ -199,6 +240,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,9 +286,47 @@ 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
debounce_key = f"{user.get('username')}:{task_id}"
now = time.monotonic()
last = _recent_download_logs.get(debounce_key)
if last is None or now - last > _DOWNLOAD_LOG_DEBOUNCE_SECONDS:
if len(_recent_download_logs) > 500:
cutoff = now - _DOWNLOAD_LOG_DEBOUNCE_SECONDS
for key, ts in list(_recent_download_logs.items()):
if ts < cutoff:
del _recent_download_logs[key]
_recent_download_logs[debounce_key] = now
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
import tempfile import tempfile
@@ -253,13 +334,35 @@ async def download_dsym(task_id: str, 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.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
dsym_path = task.dsym_path dsym_path = task.dsym_path
if not os.path.exists(dsym_path): local_exists = bool(dsym_path) and os.path.exists(dsym_path)
if not local_exists and not task.dsym_url:
if not dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
raise HTTPException(status_code=404, detail="dSYM 文件已被清理") raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
from ..services.audit_log import record_operation
resource_name = os.path.basename(dsym_path) if dsym_path else os.path.basename(task.dsym_url)
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=resource_name)
db.commit()
if not local_exists:
# dSYM 需要携带认证信息才能访问该接口,浏览器无法直接跳转到本接口的
# 302 目标,因此改为返回 JSON 让前端自行发起下载。
try:
from .config import load_config
from ..services.distribution import DistributionError, get_published_dsym_url
download_url = get_published_dsym_url(
json.loads(task.config_json or "{}"),
load_config().get("upload", {}),
task.dsym_url,
)
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
return {"url": download_url}
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载 # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
if os.path.isdir(dsym_path): if os.path.isdir(dsym_path):
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip") zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
@@ -275,13 +378,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):
+3
View File
@@ -22,14 +22,17 @@ class TaskResponse(BaseModel):
scheme_name: str scheme_name: str
obfuscation: bool obfuscation: bool
branch: str branch: str
ref_type: Optional[str] = "branch"
status: str status: str
current_step: Optional[str] current_step: Optional[str]
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]
dsym_url: Optional[str] = None
obfuscation_maps_path: Optional[str] obfuscation_maps_path: Optional[str]
qr_code_path: Optional[str] qr_code_path: Optional[str]
build_dir: Optional[str] build_dir: Optional[str]
+23
View File
@@ -0,0 +1,23 @@
"""操作审计日志。"""
import json
import uuid
from datetime import datetime
from ..models import OperationLog
def record_operation(db, user: dict, action: str, *, request=None, task=None,
resource_type: str = "", resource_name: str = "", detail: dict | None = None):
"""将已登录用户的关键操作写入当前数据库事务。"""
client_ip = request.client.host if request and request.client else ""
user_agent = request.headers.get("user-agent", "")[:500] if request else ""
db.add(OperationLog(
id=str(uuid.uuid4()), created_at=datetime.utcnow(), username=user.get("username", ""),
is_admin=bool(user.get("is_admin")), action=action,
task_id=task.id if task else None,
app_id=task.app_id if task else None,
app_name=task.app_name if task else None,
resource_type=resource_type, resource_name=resource_name,
detail_json=json.dumps(detail or {}, ensure_ascii=False),
client_ip=client_ip, user_agent=user_agent,
))
+130 -33
View File
@@ -6,7 +6,7 @@ import plistlib
import shutil import shutil
import subprocess import subprocess
import zipfile import zipfile
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@@ -23,7 +23,7 @@ from ..config import (
mask_git_remote_url, mask_git_remote_url,
) )
from .log_streamer import log_streamer from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa from .distribution import DistributionError, publish_dsym, publish_ipa
from .notification import NotificationError, send_dingtalk_notification from .notification import NotificationError, send_dingtalk_notification
from .project_patcher import ProjectPatchError, apply_project_config from .project_patcher import ProjectPatchError, apply_project_config
@@ -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():
@@ -246,8 +291,8 @@ def _db_update(db, task, **fields):
db.commit() db.commit()
async def update_source(task_id: str, source_dir: Path, branch: str): async def update_source(task_id: str, source_dir: Path, branch: str, ref_type: str = "branch"):
"""将共享源码工作目录强制同步到指定远程分支。""" """将共享源码工作目录强制同步到指定远程分支或 Tagref_type="tag" 时 branch 为 Tag 名)"""
remote_url = get_git_remote_url(with_credentials=True) remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url) remote_url_masked = mask_git_remote_url(remote_url)
git_env = _git_env() git_env = _git_env()
@@ -276,7 +321,8 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if not cloned and not (source_dir / ".git").exists(): if not cloned and not (source_dir / ".git").exists():
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}") raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}") is_tag = ref_type == "tag"
await log_streamer.emit(task_id, f"同步共享源码到{'Tag' if is_tag else '分支'}: {branch}")
if remote_url: if remote_url:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@@ -288,6 +334,16 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if process.returncode != 0: if process.returncode != 0:
raise Exception("git remote set-url 失败") raise Exception("git remote set-url 失败")
if is_tag:
# Tag 检出为游离 HEAD--force 让远端被移动过的 Tag 覆盖本地旧 Tag
tag_ref = f"refs/tags/{branch}"
commands = [
(("git", "fetch", "origin", "--tags", "--force"), "git fetch 失败"),
(("git", "checkout", "--detach", tag_ref), f"git checkout Tag {branch} 失败"),
(("git", "reset", "--hard", tag_ref), f"git reset Tag {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
else:
commands = [ commands = [
(("git", "fetch", "origin"), "git fetch 失败"), (("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"), (("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
@@ -330,7 +386,7 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
source_dir = get_source_dir(task.branch) source_dir = get_source_dir(task.branch)
if not source_dir.exists(): if not source_dir.exists():
raise Exception( raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL" f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支(或 Tag代码,或配置 GIT_REMOTE_URL"
) )
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}") await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
return await copy_source_code(task_id, task, source_dir), "" return await copy_source_code(task_id, task, source_dir), ""
@@ -339,7 +395,8 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
await log_streamer.emit(task_id, "等待共享源码准备队列...") await log_streamer.emit(task_id, "等待共享源码准备队列...")
async with source_prepare_lock: async with source_prepare_lock:
await log_streamer.emit(task_id, "开始准备共享源码快照") await log_streamer.emit(task_id, "开始准备共享源码快照")
await update_source(task_id, source_dir, task.branch) ref_type = "tag" if task.ref_type == "tag" else "branch"
await update_source(task_id, source_dir, task.branch, ref_type)
commit = await get_source_commit(source_dir) commit = await get_source_commit(source_dir)
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}") await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
build_dir = await copy_source_code(task_id, task, source_dir) build_dir = await copy_source_code(task_id, task, source_dir)
@@ -388,6 +445,7 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, current_step="config") await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir) config_data = await generate_config(task_id, task, build_dir)
config_data["SOURCE_BRANCH"] = task.branch config_data["SOURCE_BRANCH"] = task.branch
config_data["SOURCE_REF_TYPE"] = "tag" if task.ref_type == "tag" else "branch"
config_data["SOURCE_COMMIT"] = source_commit config_data["SOURCE_COMMIT"] = source_commit
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False)) config_json=json.dumps(config_data, ensure_ascii=False))
@@ -410,7 +468,7 @@ async def run_build_task(task_id: str):
ipa_path = await build_project(task_id, task, config_data, build_dir) ipa_path = await build_project(task_id, task, config_data, build_dir)
# 查找 dSYM # 查找 dSYM
dsym_path = await find_dsym(task_id, build_dir) dsym_path = await find_dsym(task_id, build_dir, config_data.get("SCHEME", ""))
# 查找混淆映射表 # 查找混淆映射表
obf_maps_path = build_dir / "obfuscation_maps" obf_maps_path = build_dir / "obfuscation_maps"
@@ -432,6 +490,13 @@ async def run_build_task(task_id: str):
upload_fields["oss_url"] = oss_url upload_fields["oss_url"] = oss_url
if qr_code_path: if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path upload_fields["qr_code_path"] = qr_code_path
# dSYM 用于后续崩溃符号化,按上传配置一并保存到远端;失败不影响本次打包结果。
if dsym_path:
dsym_url = await upload_dsym(task_id, task, config_data, dsym_path, build_dir)
if dsym_url:
upload_fields["dsym_url"] = dsym_url
if upload_fields: if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields) await asyncio.to_thread(_db_update, db, task, **upload_fields)
@@ -439,14 +504,15 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(), status="completed", completed_at=datetime.utcnow(),
current_step=None) current_step=None)
await asyncio.to_thread(_mark_superseded_tasks, db, task)
await log_streamer.emit_step(task_id, "打包完成") await log_streamer.emit_step(task_id, "打包完成")
await log_streamer.emit(task_id, f"IPA: {ipa_path}") await log_streamer.emit(task_id, f"IPA: {ipa_path}")
if task.oss_url: if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。 # Ad Hoc 发安装页二维码通知,App Store IPA 下载地址通知;
if task.build_type == "Ad_Hoc": # 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。 # 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {}) dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
try: try:
@@ -555,35 +621,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,
is_release_track, next_build_no, _ensure_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 等)一条独立轨道,
build_ver = versions.get("build_ver", "2.180.0.0") # 自测分支共用 feature 轨,各轨道的 App_Ver 互不影响。
track_name = get_track_name(config, task.branch)
track = get_track(config, track_name)
app_ver = track.get("app_ver") or "2.180.0"
# App_Store 打包时自动递增 Build_Ver 第四位版本号 # 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
if task.build_type == "App_Store": build_ver = track_build_ver(track)
is_release = is_release_track(track)
await log_streamer.emit(
task_id,
f"版本轨道: {track_name}{'上架,App_Store 打包自增' if is_release else '自测,构建号不自增'}",
)
# 上架轨的 App_Store 打包,在该版本号的构建号上 +1(回退到旧版本号也从它自己的最大值继续);
# feature 轨始终使用手动设定的构建号。
if is_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"] = { track = _ensure_track(config, track_name)
"app_ver": app_ver, track.setdefault("app_ver", app_ver)
"build_ver": build_ver, n = next_build_no(config["versions"]["tracks"], app_ver)
} track.setdefault("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 +704,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:
@@ -655,7 +735,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 关联域名等配置 # 关联域名等配置
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "") config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
for key in ["weixinlogin", "weixinpay", "tencent", "UniversalLink", "AlivcLicenseKey"]: for key in ["weixinlogin", "weixinpay", "wxworkSchema", "tencent", "UniversalLink", "AlivcLicenseKey"]:
if key in app: if key in app:
config_data[key] = app[key] config_data[key] = app[key]
@@ -854,16 +934,23 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
return ipa_path return ipa_path
async def find_dsym(task_id: str, build_dir: Path) -> Path: async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path:
"""查找 dSYM 文件""" """查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM"""
derived_data = build_dir / "build" / "derived_data" derived_data = build_dir / "build" / "derived_data"
if not derived_data.exists(): if not derived_data.exists():
return None return None
for dsym in derived_data.rglob("*.dSYM"): dsyms = list(derived_data.rglob("*.dSYM"))
return dsym if not dsyms:
return None return None
if scheme:
for dsym in dsyms:
if dsym.name == f"{scheme}.app.dSYM":
return dsym
return dsyms[0]
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple: async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
"""上传 IPA,返回 (download_url, qr_code_path)""" """上传 IPA,返回 (download_url, qr_code_path)"""
@@ -878,3 +965,13 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil
return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir) return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir)
except DistributionError as exc: except DistributionError as exc:
raise BuildError(str(exc), category="upload") from exc raise BuildError(str(exc), category="upload") from exc
async def upload_dsym(task_id: str, task, config_data: dict, dsym_path: Path, build_dir: Path) -> str:
"""上传 dSYM,返回下载 URL;上传失败仅记录警告,不影响已完成的打包结果"""
await log_streamer.emit_step(task_id, "上传 dSYM")
try:
return await asyncio.to_thread(publish_dsym, config_data, dsym_path, build_dir)
except DistributionError as exc:
await log_streamer.emit(task_id, f"dSYM 上传失败: {exc}", level="warn")
return ""
+211 -5
View File
@@ -4,11 +4,14 @@ import plistlib
import posixpath import posixpath
import re import re
import shutil import shutil
from html import escape
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
import httpx import httpx
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
class DistributionError(Exception): class DistributionError(Exception):
"""分发配置或上传过程失败。""" """分发配置或上传过程失败。"""
@@ -52,18 +55,149 @@ def _write_manifest(config: dict, manifest: Path, ipa_url: str):
plistlib.dump(data, f) plistlib.dump(data, f)
DOWNLOAD_PAGE_TEMPLATE = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="format-detection" content="telephone=no">
<title>{title} - 安装</title>
<style>
:root {{
--bg: #f2f3f7;
--card: #ffffff;
--text: #1c1c1e;
--muted: #8a8a8e;
--line: rgba(60, 60, 67, .12);
--brand: #50a0ff;
}}
@media (prefers-color-scheme: dark) {{
:root {{
--bg: #000000;
--card: #1c1c1e;
--text: #f5f5f7;
--muted: #98989d;
--line: rgba(255, 255, 255, .12);
}}
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
padding: 10vh 20px calc(32px + env(safe-area-inset-bottom));
display: flex;
justify-content: center;
align-items: flex-start;
background: var(--bg);
color: var(--text);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
}}
.card {{
width: 100%;
max-width: 420px;
background: var(--card);
border-radius: 20px;
padding: 28px 24px 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, .08);
text-align: center;
}}
.name {{
margin: 0;
font-size: 24px;
font-weight: 600;
word-break: break-word;
}}
.version {{
margin: 6px 0 22px;
color: var(--muted);
font-size: 15px;
}}
.install {{
display: block;
padding: 15px;
border-radius: 14px;
background: var(--brand);
color: #fff;
font-size: 17px;
font-weight: 600;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}}
.install:active {{ opacity: .75; }}
.meta {{
margin: 22px 0 0;
padding-top: 16px;
border-top: 1px solid var(--line);
text-align: left;
font-size: 14px;
}}
.meta div {{
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
}}
.meta dt {{ color: var(--muted); flex: none; }}
.meta dd {{
margin: 0;
text-align: right;
word-break: break-all;
font-variant-numeric: tabular-nums;
}}
.tips {{
margin: 18px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.7;
text-align: left;
}}
</style>
</head>
<body>
<div class="card">
<h1 class="name">{title}</h1>
<p class="version">版本 {version}</p>
<a class="install" href="{install_url}">安装 App</a>
<dl class="meta">{meta}</dl>
<p class="tips">请使用 iPhone 自带的 Safari 浏览器打开本页面安装。<br>
安装后如提示“未受信任的开发者”,请前往「设置 → 通用 → VPN 与设备管理」信任该描述文件。</p>
</div>
</body>
</html>
"""
def _write_download_page(config: dict, html: Path, manifest_url: str): def _write_download_page(config: dict, html: Path, manifest_url: str):
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}" install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}"
title = config.get("APPID_NAME", "iOS App") title = config.get("APPID_NAME") or "iOS App"
meta_items = [
("构建版本", config.get("BUILD_VERSION", "")),
("Bundle ID", config.get("BUNDLE_ID", "")),
("Tag" if config.get("SOURCE_REF_TYPE") == "tag" else "分支", config.get("SOURCE_BRANCH", "")),
]
meta = "".join(
f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
for label, value in meta_items
if value
)
html.write_text( html.write_text(
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">" DOWNLOAD_PAGE_TEMPLATE.format(
f"<title>{title}</title><body><h2>{title}</h2>" title=escape(title),
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>" version=escape(str(config.get("VERSION", ""))),
"</body></html>", install_url=escape(install_url, quote=True),
meta=meta,
),
encoding="utf-8", encoding="utf-8",
) )
def _dsym_remote_path(config: dict) -> str:
stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
return posixpath.join(folder, "iOS", f"{stem}.dSYM.zip")
def _remote_paths(config: dict) -> tuple[str, str, str, str]: def _remote_paths(config: dict) -> tuple[str, str, str, str]:
stem = _artifact_stem(config) stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds" folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
@@ -167,6 +301,59 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths) deleter(upload_config, remote_paths)
def delete_published_dsym(build_config: dict, upload_config: dict):
"""删除任务对应的远端 dSYM 压缩包。
dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定,
无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。
"""
mode = upload_config.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
deleter = _delete_oss if mode == "oss" else _delete_webdav
deleter(upload_config, [_dsym_remote_path(build_config)])
def _sign_oss_url(upload_config: dict, remote_path: str) -> str:
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_path, DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
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 文件路径,无法生成受控下载链接")
return _sign_oss_url(upload_config, remote_paths[0])
def get_published_dsym_url(build_config: dict, upload_config: dict, dsym_url: str) -> str:
"""返回 dSYM 受控下载的短时链接;WebDAV 保持原公开链接。
dSYM 的远端对象键完全由构建配置决定,直接按命名规则重新计算即可,
不需要像 IPA 那样从已保存的下载链接反推。
"""
if upload_config.get("mode") != "oss":
return dsym_url
return _sign_oss_url(upload_config, _dsym_remote_path(build_config))
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]:
@@ -257,3 +444,22 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
qrcode.make(urls[".html"]).save(qr_path) qrcode.make(urls[".html"]).save(qr_path)
urls.update(uploader(upload, [(qr_path, qr_remote)])) urls.update(uploader(upload, [(qr_path, qr_remote)]))
return urls[".html"], urls[".png"] return urls[".html"], urls[".png"]
def publish_dsym(config: dict, dsym_path: Path, build_dir: Path) -> str:
"""压缩并上传主 target 的 dSYM,返回下载 URL。"""
upload = config.get("_upload_config", {})
mode = upload.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
output_dir = build_dir / "distribution"
output_dir.mkdir(parents=True, exist_ok=True)
zip_base = output_dir / f"{_artifact_stem(config)}.dSYM"
zip_path = Path(shutil.make_archive(
str(zip_base), "zip", root_dir=str(dsym_path.parent), base_dir=dsym_path.name,
))
uploader = _upload_oss if mode == "oss" else _upload_webdav
urls = uploader(upload, [(zip_path, _dsym_remote_path(config))])
return urls[".zip"]
+37 -5
View File
@@ -27,8 +27,33 @@ def _signed_webhook_url(webhook_url: str, secret: str) -> str:
return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}" return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}"
def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: str = "") -> dict: def _normalize_mobiles(at_mobiles) -> list:
"""把手机号配置统一成去重、去空的字符串列表,兼容列表或逗号/换行分隔的字符串。"""
if not at_mobiles:
return []
if isinstance(at_mobiles, str):
raw = at_mobiles.replace("\n", ",").replace("", ",").replace(" ", ",").split(",")
else:
raw = at_mobiles
seen = []
for item in raw:
mobile = str(item).strip()
if mobile and mobile not in seen:
seen.append(mobile)
return seen
def build_dingtalk_payload(
config_data: dict,
download_url: str,
qr_code_url: str = "",
at_mobiles=None,
) -> dict:
"""生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。""" """生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。"""
mobiles = _normalize_mobiles(at_mobiles)
# App_Store 包与 Ad Hoc 用不同标题,App_Store 只有 IPA 下载地址、无二维码。
# 标题必须保留「打包」二字:钉钉机器人启用了自定义关键词,缺少关键词会被拒收。
heading = "【iOS】App_Store 打包信息" if config_data.get("BUILD_TYPE") == "App_Store" else "【iOS】打包信息"
details = ( details = (
f"**环境:** {config_data.get('SERVER', '')}\n\n" f"**环境:** {config_data.get('SERVER', '')}\n\n"
f"**版本:** {config_data.get('VERSION', '')}\n\n" f"**版本:** {config_data.get('VERSION', '')}\n\n"
@@ -36,14 +61,18 @@ def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: st
f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n" f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n"
f"**App Guid** {config_data.get('APPID', '')}" f"**App Guid** {config_data.get('APPID', '')}"
) )
text = f"## 【iOS】打包信息\n\n{details}\n\n**iOS 下载链接:** {download_url}\n" text = f"## {heading}\n\n{details}\n\n**iOS 下载链接:** {download_url}\n"
if qr_code_url: if qr_code_url:
text += f"\n![image]({qr_code_url})" text += f"\n![image]({qr_code_url})"
# 钉钉 markdown 消息必须在正文内出现 @手机号 才会真正 @ 到人,
# 仅靠 at.atMobiles 不生效。
if mobiles:
text += "\n\n" + " ".join(f"@{m}" for m in mobiles)
return { return {
"msgtype": "markdown", "msgtype": "markdown",
"markdown": {"title": "iOS应用下载", "text": text}, "markdown": {"title": "iOS应用下载", "text": text},
"at": {"atMobiles": [], "isAtAll": False}, "at": {"atMobiles": mobiles, "isAtAll": False},
} }
@@ -61,8 +90,11 @@ def send_dingtalk_notification(
if not webhook_url: if not webhook_url:
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL") raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
payload = build_dingtalk_payload(config_data, download_url, qr_code_url) # Ad_Hoc 与 App_Store 使用各自的 @ 名单。
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", []) mobiles_key = "at_mobiles_app_store" if config_data.get("BUILD_TYPE") == "App_Store" else "at_mobiles"
payload = build_dingtalk_payload(
config_data, download_url, qr_code_url, dingtalk_config.get(mobiles_key, [])
)
response = httpx.post( response = httpx.post(
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")), _signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
json=payload, json=payload,
+7
View File
@@ -35,11 +35,15 @@ def _update_plist(path: Path, config: dict):
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "") plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or "" plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower() scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
values = { values = {
"weixinlogin": config.get("weixinlogin", ""), "weixinlogin": config.get("weixinlogin", ""),
"wechatpay": config.get("weixinpay", ""), "wechatpay": config.get("weixinpay", ""),
"tencent": config.get("tencent", ""), "tencent": config.get("tencent", ""),
# 企业微信 Schema 与微信登录 AppID 同样存在 CFBundleURLTypes 中
# CFBundleURLName == "wxwork"),未配置则整条移除。
"wxwork": (config.get("wxworkSchema", "") or "").strip(),
"readoorUrlScheme": scheme, "readoorUrlScheme": scheme,
} }
url_types = [] url_types = []
@@ -68,6 +72,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)
+87
View File
@@ -0,0 +1,87 @@
{
"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": "",
"wxworkSchema": "",
"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", "feature/示例分支"],
"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": "",
"at_mobiles": [],
"at_mobiles_app_store": []
}
},
"versions": {
"tracks": {
"release": { "kind": "release", "app_ver": "2.180.0", "build_map": {} },
"develop": { "kind": "release", "app_ver": "2.180.0", "build_map": {} },
"feature/示例分支": { "kind": "feature", "app_ver": "2.180.0", "build_map": {} }
}
},
"branch_track": {
"master": "release",
"develop": "develop",
"feature/示例分支": "feature/示例分支"
}
}
+114 -43
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# iOS 自动打包服务 — 部署与管理脚本 # iOS 自动打包服务 — 部署与管理脚本
# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop} # 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop}
set -e set -e
@@ -23,6 +23,11 @@ WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒) WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却 MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒) COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒)
WATCHDOG_STARTUP_GRACE="${WATCHDOG_STARTUP_GRACE:-10}" # watchdog 首次检查前的宽限(秒),避开正常启动窗口
STARTUP_READY_TIMEOUT="${STARTUP_READY_TIMEOUT:-20}" # 启动后等待 /api/health 就绪的最长时间(秒)
# 重启方式:nohup(默认,PID 文件托管)| launchd(交回 launchctl kickstart 托管,避免与 KeepAlive 抢端口)
WATCHDOG_RESTART_MODE="${WATCHDOG_RESTART_MODE:-nohup}"
APP_LAUNCHD_LABEL="${APP_LAUNCHD_LABEL:-com.readoor.buildserver}"
# ========== 颜色输出 ========== # ========== 颜色输出 ==========
RED='\033[0;31m' RED='\033[0;31m'
@@ -64,6 +69,52 @@ get_server_pid() {
return 1 return 1
} }
# 返回占用目标端口、但命令不属于本项目的进程 PID(若有),用于区分“端口冲突”与“本服务在跑”。
foreign_port_holder() {
local pid
for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do
if ! is_server_process "$pid"; then
echo "$pid"
return 0
fi
done
return 1
}
# 启动 uvicorn 后端进程(单点维护启动参数,供 start / watchdog 复用),PID 写入 PID_FILE。
_spawn_server() {
setup_venv
mkdir -p "$LOG_DIR"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
>> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"
}
# 轮询 /api/health 直到返回 200 或超时;就绪返回 0,否则返回 1。
# 健康检查固定走 127.0.0.1(与 check_health 一致),不受 BIND_HOST 影响。
_wait_ready() {
local timeout="${1:-$STARTUP_READY_TIMEOUT}"
local deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "$deadline" ]; do
# 进程若已退出则无需再等
if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
return 1
fi
local code
code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 2 --max-time 3 \
"http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000")
[ "$code" = "200" ] && return 0
sleep 1
done
return 1
}
# 日志同时写文件和终端 # 日志同时写文件和终端
log_to_file() { log_to_file() {
local level="$1"; shift local level="$1"; shift
@@ -143,11 +194,23 @@ do_build() {
do_start() { do_start() {
check_env check_env
# 端口被非本项目进程占用:明确报错,避免 uvicorn 静默绑定失败。
local foreign
foreign=$(foreign_port_holder || true)
if [ -n "$foreign" ]; then
error "端口 $BACKEND_PORT 被非本项目进程占用 (PID: $foreign):"
ps -p "$foreign" -o pid,command= 2>/dev/null | sed 's/^/ /'
error "请先释放该端口,或在 .env 修改 BACKEND_PORT"
return 1
fi
# 本项目实例已在运行:强制重来(用户选定语义)。
# 注意:这会中断正在进行的打包任务。
local existing_pid local existing_pid
existing_pid=$(get_server_pid || true) existing_pid=$(get_server_pid || true)
if [ -n "$existing_pid" ]; then if [ -n "$existing_pid" ]; then
warn "服务已在运行 (PID: $existing_pid)" warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..."
return 0 do_stop
fi fi
# 确保前端已构建 # 确保前端已构建
@@ -156,28 +219,15 @@ do_start() {
do_build do_build
fi fi
setup_venv
mkdir -p "$LOG_DIR"
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..." info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ _spawn_server
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE" if _wait_ready; then
sleep 1 info "服务已就绪 (PID: $(cat "$PID_FILE"))"
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
info "服务已启动 (PID: $(cat "$PID_FILE"))"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT" info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else else
error "启动失败,请查看日志: $LOG_DIR/server.log" error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log"
rm -f "$PID_FILE" tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /'
return 1 return 1
fi fi
} }
@@ -252,12 +302,15 @@ check_health() {
# ========== Watchdog(前台模式,用于调试) ========== # ========== Watchdog(前台模式,用于调试) ==========
do_watchdog_fg() { do_watchdog_fg() {
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s" info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s"
info "按 Ctrl+C 停止" info "按 Ctrl+C 停止"
local restart_count=0 local restart_count=0
local last_restart_time=0 local last_restart_time=0
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do while true; do
local result local result
result=$(check_health) result=$(check_health)
@@ -338,7 +391,10 @@ do_watchdog_loop() {
local restart_count=0 local restart_count=0
local last_restart_time=0 local last_restart_time=0
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s" log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s, grace=${WATCHDOG_STARTUP_GRACE}s, mode=${WATCHDOG_RESTART_MODE}"
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do while true; do
local result local result
@@ -366,9 +422,17 @@ 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
# launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。
if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then
log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL"
else
log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL"
fi
else
# nohup 托管模式:停掉旧进程后重新拉起。
if [ -f "$PID_FILE" ]; then if [ -f "$PID_FILE" ]; then
local old_pid local old_pid
old_pid=$(cat "$PID_FILE") old_pid=$(cat "$PID_FILE")
@@ -377,24 +441,14 @@ do_watchdog_loop() {
kill -9 "$old_pid" 2>/dev/null || true kill -9 "$old_pid" 2>/dev/null || true
rm -f "$PID_FILE" rm -f "$PID_FILE"
fi fi
_spawn_server
fi
# 重新启动 # 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。
setup_venv if _wait_ready; then
mkdir -p "$LOG_DIR" log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
>> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"
sleep 3
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))"
else else
log_to_file "ERROR" "Service restart failed" log_to_file "ERROR" "Service restart failed or not ready in ${STARTUP_READY_TIMEOUT}s"
fi fi
fi fi
@@ -457,8 +511,21 @@ do_test() {
fi fi
} }
# ========== 一键部署(强制重来)==========
# build → start(强制重启并等待就绪)→ watchdog,最后打印状态。
do_up() {
info "一键部署(强制重来):build → start → watchdog"
do_watchdog_stop >/dev/null 2>&1 || true # 先停监听,避免它在重启窗口内干预
do_build
do_start # do_start 内部会强制停旧服务并等待 health 就绪
do_watchdog
echo ""
do_status
}
# ========== 主入口 ========== # ========== 主入口 ==========
case "${1:-}" in case "${1:-}" in
up) do_up ;;
build) do_build ;; build) do_build ;;
start) do_start ;; start) do_start ;;
stop) do_stop; do_watchdog_stop ;; stop) do_stop; do_watchdog_stop ;;
@@ -470,10 +537,11 @@ case "${1:-}" in
watchdog-fg) do_watchdog_fg ;; watchdog-fg) do_watchdog_fg ;;
_watchdog_loop) do_watchdog_loop ;; _watchdog_loop) do_watchdog_loop ;;
*) *)
echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}" echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}"
echo "" echo ""
echo " up 一键部署:build + start + watchdog(强制重来)"
echo " build 构建前端、安装依赖" echo " build 构建前端、安装依赖"
echo " start 启动服务(后台运行" echo " start 启动服务(强制重启并等待就绪;端口被占用会报错"
echo " stop 停止服务(同时停止 watchdog" echo " stop 停止服务(同时停止 watchdog"
echo " restart 重启服务" echo " restart 重启服务"
echo " status 查看运行状态" echo " status 查看运行状态"
@@ -485,6 +553,9 @@ case "${1:-}" in
echo "环境变量(可在 .env 中配置):" echo "环境变量(可在 .env 中配置):"
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)" echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)" echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)"
echo " WATCHDOG_STARTUP_GRACE watchdog 首次检查前宽限(秒,默认 10)"
echo " STARTUP_READY_TIMEOUT 启动后等待 health 就绪的最长时间(秒,默认 20)"
echo " WATCHDOG_RESTART_MODE 重启方式 nohup|launchd(默认 nohup"
echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3" echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3"
echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)" echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
exit 1 exit 1
+2
View File
@@ -95,6 +95,8 @@ EOF
<dict> <dict>
<key>PATH</key> <key>PATH</key>
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> <string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>WATCHDOG_RESTART_MODE</key>
<string>launchd</string>
</dict> </dict>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
+1
View File
@@ -7,6 +7,7 @@
<router-link to="/build">打包</router-link> <router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link> <router-link to="/history">历史</router-link>
<router-link to="/admin">管理</router-link> <router-link to="/admin">管理</router-link>
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
</nav> </nav>
<div v-if="!isLoggedIn" class="user-info"> <div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button> <button class="btn-login" @click="showLogin = true">登录</button>
+40
View File
@@ -73,6 +73,46 @@ describe('BuildView.vue', () => {
expect(branchSelect).toBeTruthy() expect(branchSelect).toBeTruthy()
}) })
it('分支和 Tag 分组显示,可选择 Tag 提交', async () => {
fetch.mockImplementation((url, opts) => {
if (url === '/api/tasks' && opts?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'tag-task', status: 'pending' }) })
}
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tags': ['v3.2.0'],
'/api/tasks': [],
}
return Promise.resolve({ json: () => Promise.resolve(responses[url] || {}) })
})
const wrapper = mountBuildView()
await flushPromises()
const groups = wrapper.findAll('optgroup')
expect(groups.map(g => g.attributes('label'))).toEqual(['分支', 'Tag'])
expect(groups[1].findAll('option').map(o => o.text())).toEqual(['v3.2.0'])
wrapper.vm.selectedServer = '测试环境'
await wrapper.vm.$nextTick()
wrapper.vm.form.app_id = '1'
wrapper.vm.form.branch = 'v3.2.0'
await wrapper.vm.$nextTick()
await wrapper.find('.btn-primary').trigger('click')
await flushPromises()
const post = fetch.mock.calls.find(([url, opts]) => url === '/api/tasks' && opts?.method === 'POST')
expect(JSON.parse(post[1].body).branch).toBe('v3.2.0')
})
it('没有 Tag 时不显示 Tag 分组', async () => {
const wrapper = mountBuildView()
await flushPromises()
expect(wrapper.findAll('optgroup').map(g => g.attributes('label'))).toEqual(['分支'])
})
it('默认值正确', async () => { it('默认值正确', async () => {
const wrapper = mountBuildView() const wrapper = mountBuildView()
await flushPromises() await flushPromises()
+115 -6
View File
@@ -23,8 +23,19 @@ function mockFetch(url) {
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } }, '/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
'/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/tags': ['v3.2.0'],
'/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',
primary_track: 'release',
tracks: {
release: { kind: 'release', app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} },
dev: { kind: 'feature', app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
'v3.2.0': { kind: 'release', app_ver: '3.2.0', build_ver: '3.2.0.0', build_map: {} },
},
branch_track: { main: 'release', dev: 'dev', 'v3.2.0': 'v3.2.0' },
},
'/api/config': { apps: {}, schemes: {}, branches: ['main'] }, '/api/config': { apps: {}, schemes: {}, branches: ['main'] },
} }
return Promise.resolve({ return Promise.resolve({
@@ -168,19 +179,117 @@ 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('应用版本号') // 每个分支一块:main(上架,release 轨)、dev(自测,dev 轨)
expect(wrapper.text()).not.toContain('打包参数') expect(wrapper.text()).toContain('上架版本号(main')
expect(wrapper.findAll('input').length).toBe(1) expect(wrapper.text()).toContain('自测版本号(dev')
const inputs = wrapper.findAll('input[type="text"]')
expect(inputs[0].element.value).toBe('2.195.0') // main 分支 App_Ver
expect(inputs[2].element.value).toBe('2.100.0') // dev 分支 App_Ver
await inputs[2].setValue('2.101.0')
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存 dev 版本号')
await saveBtn.trigger('click')
await flushPromises()
expect(calls).toEqual([{ track: 'dev', 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') // main → release 轨(上架)
await select.setValue('feature')
await flushPromises()
expect(calls).toEqual([{ branch: 'main', track: 'feature' }])
})
it('分支管理可添加和删除 Tag', async () => {
window.confirm = vi.fn(() => true)
const calls = []
fetch.mockImplementation((url, opts) => {
if (opts?.method === 'POST' || opts?.method === 'DELETE') {
calls.push([opts.method, url, opts.body && 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 rows = wrapper.findAll('.config-table tbody tr')
expect(rows.map(r => r.findAll('td').slice(0, 2).map(td => td.text()))).toEqual([
['main', '分支'], ['dev', '分支'], ['v3.2.0', 'Tag'],
])
await wrapper.find('select').setValue('tag')
await wrapper.find('input[placeholder="输入 Tag 名称"]').setValue('v3.3.0')
await wrapper.findAll('.btn-primary').find(b => b.text() === '添加 Tag').trigger('click')
await flushPromises()
await rows[2].find('.action-btn.delete').trigger('click')
await flushPromises()
expect(calls).toEqual([
['POST', '/api/config/tags', { name: 'v3.3.0' }],
['DELETE', '/api/config/tags/v3.2.0', undefined],
])
}) })
}) })
@@ -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
View File
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import BuildView from '../views/BuildView.vue' import BuildView from '../views/BuildView.vue'
import HistoryView from '../views/HistoryView.vue' import HistoryView from '../views/HistoryView.vue'
import ConfigView from '../views/ConfigView.vue' import ConfigView from '../views/ConfigView.vue'
import OperationLogView from '../views/OperationLogView.vue'
const routes = [ const routes = [
{ path: '/', redirect: '/build' }, { path: '/', redirect: '/build' },
{ path: '/build', name: 'Build', component: BuildView }, { path: '/build', name: 'Build', component: BuildView },
{ path: '/history', name: 'History', component: HistoryView }, { path: '/history', name: 'History', component: HistoryView },
{ path: '/admin', name: 'Admin', component: ConfigView }, { path: '/admin', name: 'Admin', component: ConfigView },
{ path: '/operations', name: 'Operations', component: OperationLogView },
] ]
const router = createRouter({ const router = createRouter({
+12 -3
View File
@@ -41,9 +41,14 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>代码分支</label> <label>代码分支 / Tag</label>
<select v-model="form.branch"> <select v-model="form.branch">
<optgroup label="分支">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option> <option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
</optgroup>
<optgroup v-if="tags.length" label="Tag">
<option v-for="t in tags" :key="`tag:${t}`" :value="t">{{ t }}</option>
</optgroup>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -167,6 +172,7 @@ const authFetch = (url, options = {}) => {
const apps = ref({}) const apps = ref({})
const schemes = ref({}) const schemes = ref({})
const branches = ref(['main']) const branches = ref(['main'])
const tags = ref([])
const tasks = ref([]) const tasks = ref([])
const selectedServer = ref('') const selectedServer = ref('')
const serverEnvironments = computed(() => { const serverEnvironments = computed(() => {
@@ -314,15 +320,18 @@ onUnmounted(() => {
}) })
onMounted(async () => { onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([ const [appsRes, schemesRes, branchesRes, tagsRes, tasksRes] = await Promise.all([
authFetch('/api/apps'), authFetch('/api/apps'),
authFetch('/api/schemes'), authFetch('/api/schemes'),
authFetch('/api/branches'), authFetch('/api/branches'),
authFetch('/api/tags'),
authFetch('/api/tasks'), authFetch('/api/tasks'),
]) ])
apps.value = await appsRes.json() apps.value = await appsRes.json()
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
branches.value = await branchesRes.json() branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
const allTasks = await tasksRes.json() const allTasks = await tasksRes.json()
// 从历史页跳转过来时,读取 taskId 查询参数 // 从历史页跳转过来时,读取 taskId 查询参数
@@ -436,7 +445,7 @@ const errorCategoryHint = (cat) => {
provisioning: '描述文件问题:描述文件过期、不匹配或未安装', provisioning: '描述文件问题:描述文件过期、不匹配或未安装',
compilation: '代码编译错误:请检查代码语法和类型引用', compilation: '代码编译错误:请检查代码语法和类型引用',
dependency: '依赖问题:Pod 依赖缺失或版本不兼容', dependency: '依赖问题:Pod 依赖缺失或版本不兼容',
git: '代码拉取失败:请检查分支是否存在', git: '代码拉取失败:请检查分支或 Tag 是否存在',
config: '配置替换失败:请检查打包配置', config: '配置替换失败:请检查打包配置',
build: '构建配置错误:请检查 Scheme 和 Workspace 配置', build: '构建配置错误:请检查 Scheme 和 Workspace 配置',
unknown: '打包失败:请查看详细日志排查原因', unknown: '打包失败:请查看详细日志排查原因',
+262 -38
View File
@@ -15,7 +15,7 @@
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li> <li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li> <li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li> <li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li> <li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li> <li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul> </ul>
</div> </div>
@@ -151,27 +151,43 @@
<!-- 分支管理 --> <!-- 分支管理 -->
<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;">
管理可打包的代码分支和 Tag打包时从对应分支或 Tag 获取源码
每个分支 / Tag 各有一套独立的版本号版本轨道决定构建号行为上架App_Store 打包构建号自增自测构建号不自增新增 Tag 默认为上架
</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;"> <select v-model="newRefType" style="width: auto; padding: 10px 12px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button> <option value="branch">分支</option>
<option value="tag">Tag</option>
</select>
<input v-model="newBranch" type="text" :placeholder="newRefType === 'tag' ? '输入 Tag 名称' : '输入分支名称'" 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">{{ newRefType === 'tag' ? '添加 Tag' : '添加分支' }}</button>
</div> </div>
<table class="config-table"> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>分支名称</th> <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="r in buildRefs" :key="`${r.type}:${r.name}`">
<td><strong>{{ b }}</strong></td> <td><strong>{{ r.name }}</strong></td>
<td>{{ r.type === 'tag' ? 'Tag' : '分支' }}</td>
<td>
<select :value="branchKind(r.name)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(r.name, $event.target.value)">
<option value="release">上架App_Store 打包构建号自增</option>
<option value="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(r)">删除</button>
</td> </td>
</tr> </tr>
<tr v-if="!branches.length"> <tr v-if="!buildRefs.length">
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td> <td colspan="4" style="text-align: center; color: #999;">暂无分支配置</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -258,6 +274,16 @@
<label>加签密钥可选</label> <label>加签密钥可选</label>
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签"> <input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
</div> </div>
<div class="form-group">
<label>Ad_Hoc @ 开发人员手机号可选</label>
<textarea v-model="dingtalkAtMobiles" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 4px;">须是钉钉账号绑定的手机号Ad_Hoc 打包完成的通知会 @ 这些人</div>
</div>
<div class="form-group">
<label>App_Store @ 开发人员手机号可选</label>
<textarea v-model="dingtalkAtMobilesAppStore" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 4px;">App_Store 打包完成的通知会 @ 这些人</div>
</div>
</div> </div>
</div> </div>
@@ -269,13 +295,31 @@
<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> <template v-for="t in branchTracks" :key="t.name">
<h4 class="section-title">
{{ t.kind === 'release' ? '上架版本号' : '自测版本号' }}{{ t.branches.join(' / ') }}
</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[t.name].app_ver" placeholder="2.180.0" @input="onAppVerInput(t.name)">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }}轨道 {{ t.name }}与其它分支 / Tag 互不影响
</div> </div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button> </div>
<div class="form-group">
<label>Build_Ver构建号</label>
<input type="text" v-model="versions[t.name].build_ver" placeholder="2.180.0.0">
<div v-if="t.kind === 'release'" style="font-size: 12px; color: #999; margin-top: 4px;">
四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号
每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改
</div>
<div v-else style="font-size: 12px; color: #999; margin-top: 4px;">
四段数字前三位须与 App_Ver 一致自测轨打包时<strong>不会自动递增</strong>
始终使用这里填写的值默认 .0需要区分时请手动修改
</div>
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions(t.name)">保存 {{ t.branches.join(' / ') }} 版本号</button>
</template>
<template v-if="isAdmin"> <template v-if="isAdmin">
<h4 class="section-title">打包参数</h4> <h4 class="section-title">打包参数</h4>
@@ -455,10 +499,16 @@
<input v-model="appForm.weixinpay" type="text" placeholder="wx..."> <input v-model="appForm.weixinpay" type="text" placeholder="wx...">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label>企业微信 Schema</label>
<input v-model="appForm.wxworkSchema" type="text" placeholder="不填则不写入 Info.plist(与微信登录 AppID 相同)">
</div>
</div>
<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 +569,21 @@
<div v-for="skin in appSkins" :key="skin.name" class="skin-item"> <div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span> <span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span> <span class="skin-size">{{ formatSize(skin.size) }}</span>
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
更新
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
</label>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button> <button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div> </div>
</div> </div>
<div v-else class="skins-hint">暂无皮肤包</div> <div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }"> <label class="btn btn-outline skin-upload-btn"
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }} :class="{ disabled: skinUploading, dragover: skinDragover }"
@dragover.prevent="skinDragover = true"
@dragenter.prevent="skinDragover = true"
@dragleave.prevent="skinDragover = false"
@drop.prevent="onSkinDrop">
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.zip或拖拽到此处)') }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden> <input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label> </label>
</div> </div>
@@ -594,12 +653,81 @@ const apps = ref({})
const schemes = ref({}) const schemes = ref({})
const servers = ref({}) const servers = ref({})
const branches = ref([]) const branches = ref([])
const tags = ref([])
const newBranch = ref('') const newBranch = ref('')
const newRefType = ref('branch') // 新增的是分支还是 Tag
// 分支管理表格:分支在前,Tag 在后
const buildRefs = computed(() => [
...branches.value.map(name => ({ name, type: 'branch' })),
...tags.value.map(name => ({ name, type: 'tag' })),
])
const buildSettings = ref({}) const buildSettings = ref({})
const versions = ref({ app_ver: '', build_ver: '' }) // 版本轨道:每个分支一条独立轨道,轨道名即分支名(master/main 沿用历史名 release)。
// 轨道类型 kind 决定构建号行为:release=上架、App_Store 打包自增;feature=自测、不自增。
const versions = ref({}) // 轨道名 -> { app_ver, build_ver }
const buildMap = ref({}) // 轨道名 -> { App_Ver: 已用构建号 }
const trackKind = ref({}) // 轨道名 -> release | feature
const branchTrack = ref({}) // 分支名 -> 轨道名
const applyVersions = (data) => {
const tracks = data.tracks || {}
const nextVersions = {}
const nextMap = {}
const nextKind = {}
for (const [name, item] of Object.entries(tracks)) {
nextVersions[name] = { app_ver: item.app_ver || '', build_ver: item.build_ver || '' }
nextMap[name] = item.build_map || {}
nextKind[name] = item.kind || 'release'
}
versions.value = nextVersions
buildMap.value = nextMap
trackKind.value = nextKind
branchTrack.value = data.branch_track || {}
}
// 分支所属轨道的类型(供分支管理里的下拉框显示)
const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'feature'
// 打包设置里按分支分块展示版本号;多个分支共用一条轨道时只显示一块
const branchTracks = computed(() => {
const seen = new Set()
const refs = [...branches.value, ...tags.value]
return refs
.map(b => branchTrack.value[b])
.filter(name => name && versions.value[name] && !seen.has(name) && seen.add(name))
.map(name => ({
name,
kind: trackKind.value[name] || 'release',
branches: refs.filter(b => branchTrack.value[b] === name),
}))
})
// 修改 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('{}')
// 钉钉 @ 手机号:配置里存字符串数组,输入框按逗号/换行分隔编辑。
// Ad_Hoc 与 App_Store 各一份名单。
const dingtalkAtMobilesModel = (key) => computed({
get: () => (uploadConfig.value.dingtalk?.[key] || []).join('\n'),
set: (val) => {
if (!uploadConfig.value.dingtalk) uploadConfig.value.dingtalk = {}
uploadConfig.value.dingtalk[key] = val
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
},
})
const dingtalkAtMobiles = dingtalkAtMobilesModel('at_mobiles')
const dingtalkAtMobilesAppStore = dingtalkAtMobilesModel('at_mobiles_app_store')
const showServerModal = ref(false) const showServerModal = ref(false)
const editingServerName = ref(null) const editingServerName = ref(null)
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null }) const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null })
@@ -615,6 +743,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,20 +755,18 @@ 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
} }
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([ const [appsRes, schemesRes, serversRes, branchesRes, tagsRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'), authFetch('/api/config/apps'),
authFetch('/api/config/schemes'), authFetch('/api/config/schemes'),
authFetch('/api/config/servers'), authFetch('/api/config/servers'),
authFetch('/api/config/branches'), authFetch('/api/config/branches'),
authFetch('/api/config/tags'),
authFetch('/api/config/build'), authFetch('/api/config/build'),
authFetch('/api/config/upload'), authFetch('/api/config/upload'),
authFetch('/api/config'), authFetch('/api/config'),
@@ -650,10 +777,12 @@ const loadData = async () => {
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
servers.value = await serversRes.json() servers.value = await serversRes.json()
branches.value = await branchesRes.json() branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
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()
} }
@@ -818,6 +947,7 @@ const openAppModal = (id = null, app = null) => {
UniversalLink: '', UniversalLink: '',
weixinlogin: '', weixinlogin: '',
weixinpay: '', weixinpay: '',
wxworkSchema: '',
tencent: '', tencent: '',
AlivcLicenseKey: '', AlivcLicenseKey: '',
app_id_prefix_override: '', app_id_prefix_override: '',
@@ -862,9 +992,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 +1010,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 +1023,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 +1079,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 +1129,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()
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('保存成功') alert('保存成功')
}
} catch (e) { } catch (e) {
alert('保存失败: ' + e.message) alert('保存失败: ' + e.message)
} }
@@ -1005,12 +1195,12 @@ const deleteScheme = async (id) => {
await loadData() await loadData()
} }
// 分支管理 // 分支管理(分支与 Tag 共用一张表)
const addBranch = async () => { const addBranch = async () => {
const name = newBranch.value.trim() const name = newBranch.value.trim()
if (!name) return if (!name) return
try { try {
const res = await authFetch('/api/config/branches', { const res = await authFetch(newRefType.value === 'tag' ? '/api/config/tags' : '/api/config/branches', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
@@ -1026,10 +1216,31 @@ const addBranch = async () => {
} }
} }
const deleteBranch = async (name) => { const setBranchTrack = async (name, track) => {
if (!confirm(`确定删除分支「${name}」?`)) return
try { try {
const res = await authFetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' }) 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 || '修改失败')
}
// 切到上架轨可能新建了轨道,重新拉取版本号
applyVersions(await (await authFetch('/api/config/versions')).json())
} catch (e) {
alert('修改失败: ' + e.message)
await loadData()
}
}
const deleteBranch = async ({ name, type }) => {
const isTag = type === 'tag'
if (!confirm(`确定删除${isTag ? ' Tag ' : '分支'}${name}」?`)) return
try {
const base = isTag ? '/api/config/tags' : '/api/config/branches'
const res = await authFetch(`${base}/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败') if (!res.ok) throw new Error('删除失败')
await loadData() await loadData()
} catch (e) { } catch (e) {
@@ -1047,16 +1258,25 @@ const saveBuildSettings = async () => {
alert('设置已保存') alert('设置已保存')
} }
const saveVersions = async () => { const saveVersions = async (track) => {
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 +1380,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>
+68 -11
View File
@@ -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" :disabled="!!downloadingIds[task.id]" @click="downloadIpa(task.id)">{{ downloadingIds[task.id] ? '下载中...' : '下载 IPA' }}</button>
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
@@ -109,7 +110,7 @@
<span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span> <span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<span class="meta-label">分支</span> <span class="meta-label">{{ logTask?.ref_type === 'tag' ? 'Tag' : '分支' }}</span>
<span class="meta-value">{{ logTask?.branch || '-' }}</span> <span class="meta-value">{{ logTask?.branch || '-' }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
@@ -158,7 +159,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, reactive, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken', () => '') const getToken = inject('getToken', () => '')
const isAdmin = inject('isAdmin', ref(false)) const isAdmin = inject('isAdmin', ref(false))
@@ -180,6 +181,8 @@ 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)
const downloadingIds = reactive({})
let logWs = null let logWs = null
const getAppVersion = (task) => { const getAppVersion = (task) => {
@@ -210,11 +213,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)
@@ -304,12 +309,61 @@ onUnmounted(() => {
if (logWs) { logWs.close(); logWs = null } if (logWs) { logWs.close(); logWs = null }
}) })
const downloadDsym = (taskId) => { const triggerBlobDownload = (blob, filename) => {
window.open(`/api/tasks/${taskId}/dsym`) const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename || ''
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} }
const downloadObfMaps = (taskId) => { const filenameFromDisposition = (res, fallback) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`) const cd = res.headers.get('content-disposition') || ''
const match = cd.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)
return match ? decodeURIComponent(match[1]) : fallback
}
const downloadDsym = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/dsym`)
if (!res.ok) throw new Error()
if ((res.headers.get('content-type') || '').includes('application/json')) {
const { url } = await res.json()
window.open(url, '_blank')
} else {
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'dsym.zip'))
}
} catch {
alert('下载失败,请稍后重试')
}
}
const downloadIpa = async (taskId) => {
if (downloadingIds[taskId]) return
downloadingIds[taskId] = true
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('下载失败,请稍后重试')
} finally {
delete downloadingIds[taskId]
}
}
const downloadObfMaps = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/obfuscation-maps`)
if (!res.ok) throw new Error()
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'obfuscation_maps.zip'))
} catch {
alert('下载失败,请稍后重试')
}
} }
const deleteTask = async (taskId) => { const deleteTask = async (taskId) => {
@@ -457,6 +511,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,8 +561,10 @@ 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; }
.download-link:disabled { color: #999; cursor: default; }
.download-link:disabled:hover { text-decoration: none; }
.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; }
.qr-thumb:hover { border-color: #1890ff; } .qr-thumb:hover { border-color: #1890ff; }
+24
View File
@@ -0,0 +1,24 @@
<template>
<div v-if="isAdmin" class="container">
<div class="panel">
<div class="header-row"><h2>操作日志</h2><div><select v-model.number="days" @change="load"><option :value="7"> 7 </option><option :value="30"> 30 </option><option :value="90"> 90 </option></select><button @click="exportCsv">导出 CSV</button></div></div>
<div class="cards"><div><strong>{{ summary.total || 0 }}</strong><span>操作总数</span></div><div><strong>{{ summary.action_counts?.build_created || 0 }}</strong><span>发起打包</span></div><div><strong>{{ summary.action_counts?.ipa_download_requested || 0 }}</strong><span>发起 IPA 下载</span></div></div>
<div class="columns"><section><h3>活跃用户</h3><p v-for="item in summary.top_users || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section><section><h3>App 操作量</h3><p v-for="item in summary.top_apps || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section></div>
<table><thead><tr><th>时间</th><th>用户</th><th>操作</th><th>App</th><th>资源</th><th>IP</th></tr></thead><tbody><tr v-for="log in logs" :key="log.id"><td>{{ formatTime(log.created_at) }}</td><td>{{ log.username }}</td><td>{{ actionText(log.action) }}</td><td>{{ log.app_name || '-' }}</td><td>{{ log.resource_type || '-' }}</td><td>{{ log.client_ip || '-' }}</td></tr><tr v-if="!logs.length"><td colspan="6">暂无操作记录</td></tr></tbody></table>
</div>
</div>
</template>
<script setup>
import { inject, ref, onMounted } from 'vue'
const isAdmin = inject('isAdmin', ref(false)); const getToken = inject('getToken', () => '')
const days = ref(30); const summary = ref({}); const logs = ref([])
const authFetch = (url) => fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })
const load = async () => { const [summaryRes, logsRes] = await Promise.all([authFetch(`/api/operations/summary?days=${days.value}`), authFetch(`/api/operations?days=${days.value}`)]); if (summaryRes.ok) summary.value = await summaryRes.json(); if (logsRes.ok) logs.value = await logsRes.json() }
const exportCsv = async () => { const res = await authFetch(`/api/operations/export?days=${days.value}`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'operation-logs.csv'; link.click(); URL.revokeObjectURL(url) }
const formatTime = value => value ? new Date(value.endsWith('Z') ? value : `${value}Z`).toLocaleString('zh-CN', { hour12: false }) : '-'
const actionText = action => ({ build_created: '发起打包', build_cancelled: '取消打包', task_deleted: '删除记录', ipa_download_requested: '发起 IPA 下载', dsym_downloaded: '下载 dSYM', obfuscation_maps_downloaded: '下载混淆映射' }[action] || action)
onMounted(load)
</script>
<style scoped>
.container{max-width:1200px;margin:0 auto;padding:24px}.panel{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px #00000014}.header-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.header-row h2{margin:0}.header-row select,.header-row button{padding:8px 12px;margin-left:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff}.header-row button{color:#1890ff;cursor:pointer}.cards,.columns{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.cards div,.columns section{padding:16px;border-radius:8px;background:#fafafa}.cards strong{display:block;font-size:24px;color:#1890ff}.cards span{font-size:13px;color:#666}.columns{grid-template-columns:1fr 1fr}.columns h3{margin:0 0 10px;font-size:14px}.columns p{display:flex;justify-content:space-between;margin:8px 0;color:#555}table{width:100%;border-collapse:collapse}th,td{padding:11px;border-bottom:1px solid #eee;text-align:left;font-size:13px}th{color:#666;background:#fafafa}@media(max-width:700px){.cards{grid-template-columns:1fr}.columns{grid-template-columns:1fr}.header-row{align-items:flex-start;gap:12px;flex-direction:column}}
</style>
+196 -1
View File
@@ -48,6 +48,31 @@ def test_create_app_rejects_unknown_environment_id_rule(client, tmp_config):
assert "ID 规则" in resp.json()["detail"] assert "ID 规则" in resp.json()["detail"]
def test_create_app_prefixes_tencent_app_id(client, tmp_config):
resp = client.post("/api/config/apps", json={
"name": "腾讯App", "server": "测试环境", "AppGuid": "guid", "tencent": "123456",
})
assert resp.status_code == 200
app_id = resp.json()["id"]
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
def test_create_app_keeps_existing_tencent_prefix(client, tmp_config):
resp = client.post("/api/config/apps", json={
"name": "腾讯App2", "server": "测试环境", "AppGuid": "guid", "tencent": "tencent123456",
})
app_id = resp.json()["id"]
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
def test_update_app_prefixes_tencent_app_id(client, tmp_config):
resp = client.put("/api/config/apps/1", json={
"name": "改名App", "AppGuid": "test-guid", "tencent": " 987654 ",
})
assert resp.status_code == 200
assert client.get("/api/config/apps").json()["1"]["tencent"] == "tencent987654"
def test_update_app(client, tmp_config): def test_update_app(client, tmp_config):
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"}) resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
assert resp.status_code == 200 assert resp.status_code == 200
@@ -172,7 +197,8 @@ def test_update_build_settings(client, tmp_config):
# ---- Versions ---- # ---- Versions ----
def test_update_versions_resets_build_number(client, tmp_config): def test_update_versions_keeps_per_version_build_number(client, tmp_config):
# 首次设置某版本号,构建号从 .0 起(尚未打包)
resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"}) resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["app_ver"] == "2.196.0" assert resp.json()["app_ver"] == "2.196.0"
@@ -180,7 +206,176 @@ 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):
# 每个分支一条轨道(main/master 为 release,其余即分支名),App_Ver 各自独立
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
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": "dev", "app_ver": "2.198.0"}).status_code == 200
assert client.put("/api/config/versions", json={"track": "feature/login", "app_ver": "2.100.0"}).status_code == 200
data = client.get("/api/config/versions").json()
assert data["branch_track"] == {"main": "release", "dev": "dev", "feature/login": "feature/login"}
assert data["tracks"]["release"]["app_ver"] == "2.196.0"
assert data["tracks"]["dev"]["app_ver"] == "2.198.0"
assert data["tracks"]["feature/login"]["app_ver"] == "2.100.0"
assert data["tracks"]["feature/login"]["build_ver"] == "2.100.0.0"
assert data["tracks"]["release"]["kind"] == "release"
assert data["tracks"]["feature/login"]["kind"] == "feature"
# 顶层字段镜像主上架轨(第一个上架分支),兼容旧客户端
assert data["primary_track"] == "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_split_per_branch(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()
# main 留在 release 轨,dev 拆出自己的轨,两者都带着原来的已用构建号
assert data["branch_track"] == {"main": "release", "dev": "dev"}
for track in ("release", "dev"):
assert data["tracks"][track] == {
"kind": "release",
"app_ver": "2.195.0",
"build_ver": "2.195.0.7",
"build_map": {"2.195.0": 7},
}
# 拆分后各轨独立:改 release 不影响 dev
assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200
data = client.get("/api/config/versions").json()
assert data["tracks"]["release"]["app_ver"] == "2.196.0"
assert data["tracks"]["dev"]["app_ver"] == "2.195.0"
def test_legacy_shared_tracks_split_per_branch(client, tmp_config):
"""旧的共用轨:master 留在 release 轨,其余分支(含自测分支)各拆出自己的轨"""
import json
with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f)
cfg["branches"] = ["main", "dev", "feature/login", "feature/pay"]
cfg["versions"] = {"tracks": {
"release": {"app_ver": "2.195.0", "build_map": {"2.195.0": 4}},
"feature": {"app_ver": "2.100.0", "build_map": {}},
}}
cfg["branch_track"] = {
"main": "release", "dev": "release",
"feature/login": "feature", "feature/pay": "feature",
}
with open(tmp_config, "w", encoding="utf-8") as f:
json.dump(cfg, f)
data = client.get("/api/config/versions").json()
assert data["branch_track"] == {
"main": "release", "dev": "dev",
"feature/login": "feature/login", "feature/pay": "feature/pay",
}
for track in ("release", "dev"):
assert data["tracks"][track]["kind"] == "release"
assert data["tracks"][track]["build_ver"] == "2.195.0.4"
for track in ("feature/login", "feature/pay"):
assert data["tracks"][track]["kind"] == "feature"
assert data["tracks"][track]["app_ver"] == "2.100.0"
# 自测分支的版本号各自独立
assert client.put("/api/config/versions", json={"track": "feature/login", "app_ver": "2.101.0"}).status_code == 200
data = client.get("/api/config/versions").json()
assert data["tracks"]["feature/login"]["app_ver"] == "2.101.0"
assert data["tracks"]["feature/pay"]["app_ver"] == "2.100.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
data = client.get("/api/config/versions").json()
# 每个分支一条自己的轨道,类型按分支名推断
assert data["branch_track"]["develop"] == "develop"
assert data["branch_track"]["feature/login"] == "feature/login"
assert data["tracks"]["develop"]["kind"] == "release"
assert data["tracks"]["feature/login"]["kind"] == "feature"
# 手动改类型:轨道名不变,只改构建号是否自增
resp = client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "release"})
assert resp.status_code == 200
assert resp.json()["track"] == "feature/login"
data = client.get("/api/config/versions").json()
assert data["tracks"]["feature/login"]["kind"] == "release"
assert client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "feature"}).status_code == 200
assert client.get("/api/config/versions").json()["tracks"]["feature/login"]["kind"] == "feature"
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()
def test_tags_crud_and_default_release_track(client, tmp_config):
"""Tag 与分支分开存放,默认建一条同名上架轨,可切换类型、可删除"""
assert client.get("/api/config/tags").json() == []
resp = client.post("/api/config/tags", json={"name": "v3.2.0"})
assert resp.status_code == 200
assert resp.json()["track"] == "v3.2.0"
assert client.get("/api/config/tags").json() == ["v3.2.0"]
assert client.get("/api/tags").json() == ["v3.2.0"]
assert "v3.2.0" not in client.get("/api/config/branches").json()
data = client.get("/api/config/versions").json()
assert data["branch_track"]["v3.2.0"] == "v3.2.0"
assert data["tracks"]["v3.2.0"]["kind"] == "release"
# 主上架轨仍按分支判断,Tag 不会抢占
assert data["primary_track"] == "release"
assert client.put("/api/config/branches/track", json={"branch": "v3.2.0", "track": "feature"}).status_code == 200
assert client.get("/api/config/versions").json()["tracks"]["v3.2.0"]["kind"] == "feature"
assert client.delete("/api/config/tags/v3.2.0").status_code == 200
assert client.get("/api/config/tags").json() == []
assert "v3.2.0" not in client.get("/api/config/versions").json()["branch_track"]
assert client.delete("/api/config/tags/v3.2.0").status_code == 404
def test_tag_and_branch_names_must_not_collide(client, tmp_config):
assert client.post("/api/config/tags", json={"name": ""}).status_code == 400
assert client.post("/api/config/tags", json={"name": "main"}).status_code == 400
assert client.post("/api/config/tags", json={"name": "release/v1"}).status_code == 200
assert client.post("/api/config/tags", json={"name": "release/v1"}).status_code == 400
assert client.post("/api/config/branches", json={"name": "release/v1"}).status_code == 400
assert client.delete("/api/config/tags/release%2Fv1").status_code == 200
+47
View File
@@ -0,0 +1,47 @@
"""操作审计日志接口测试。"""
from unittest.mock import AsyncMock, patch
@patch("backend.services.build_queue.build_queue")
def test_build_creation_is_recorded_and_visible_to_admin(mock_queue, client, tmp_config):
mock_queue.submit = AsyncMock()
response = client.post("/api/tasks", json={"app_id": "1", "scheme_id": "1", "build_type": "Ad_Hoc"})
assert response.status_code == 200
summary = client.get("/api/operations/summary")
assert summary.status_code == 200
assert summary.json()["action_counts"]["build_created"] == 1
logs = client.get("/api/operations").json()
assert logs[0]["username"] == "admin"
assert logs[0]["action"] == "build_created"
def test_download_link_is_recorded(client, tmp_config):
from backend.database import SessionLocal
from backend.models import Task
db = SessionLocal()
db.add(Task(id="download-task", app_id="1", app_name="测试App", build_type="App_Store", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.ipa"))
db.commit()
db.close()
with patch("backend.services.distribution.get_published_download_url", return_value="https://signed.example.com/app.ipa?signature=1"):
response = client.get("/api/tasks/download-task/download-link")
assert response.status_code == 200
assert "signature=1" in response.json()["url"]
assert client.get("/api/operations").json()[0]["action"] == "ipa_download_requested"
def test_regular_user_cannot_view_operation_logs(client):
from backend.database import SessionLocal
from backend.models import User
from backend.security import hash_password
db = SessionLocal()
db.add(User(id="regular-user", username="reader", password_hash=hash_password("reader-password"), is_admin=False))
db.commit()
db.close()
login = client.post("/api/auth/login", json={"username": "reader", "password": "reader-password"})
client.headers.update({"Authorization": f"Bearer {login.json()['token']}"})
assert client.get("/api/operations").status_code == 403
assert client.get("/api/operations/summary").status_code == 403
+33
View File
@@ -38,6 +38,23 @@ def test_create_task_default_branch(mock_queue, client, tmp_config):
}) })
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["branch"] == "main" assert resp.json()["branch"] == "main"
assert resp.json()["ref_type"] == "branch"
@patch("backend.services.build_queue.build_queue")
def test_create_task_from_tag(mock_queue, client, tmp_config):
"""选择已配置的 Tag 打包时,任务记录为 tag 类型"""
mock_queue.submit = AsyncMock()
assert client.post("/api/config/tags", json={"name": "v3.2.0"}).status_code == 200
resp = client.post("/api/tasks", json={
"app_id": "1",
"build_type": "Ad_Hoc",
"scheme_id": "1",
"branch": "v3.2.0",
})
assert resp.status_code == 200
assert resp.json()["branch"] == "v3.2.0"
assert resp.json()["ref_type"] == "tag"
@patch("backend.services.build_queue.build_queue") @patch("backend.services.build_queue.build_queue")
@@ -214,3 +231,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"}
+187 -6
View File
@@ -20,6 +20,7 @@ from backend.services.build_service import (
run_pod_install, run_pod_install,
_patch_afnetworking_private_headers, _patch_afnetworking_private_headers,
_cleanup_old_builds, _cleanup_old_builds,
_mark_superseded_tasks,
_resolve_provisioning_profile, _resolve_provisioning_profile,
) )
from backend.services.log_streamer import LogStreamer from backend.services.log_streamer import LogStreamer
@@ -111,6 +112,25 @@ async def test_update_source_existing(tmp_path, log_streamer):
assert call_count == 4 # fetch, checkout, reset, clean assert call_count == 4 # fetch, checkout, reset, clean
async def test_update_source_tag_checks_out_detached(tmp_path, log_streamer):
"""Tag 打包:拉取 Tag 后以游离 HEAD 检出 refs/tags/<tag>,不依赖同名远程分支"""
source_dir = tmp_path / "workspace"
source_dir.mkdir()
(source_dir / ".git").mkdir()
with patch("backend.services.build_service.get_git_remote_url", return_value=""):
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
await update_source("t1", source_dir, "v3.2.0", "tag")
git_args = [call.args[3:] for call in mock_exec.call_args_list]
assert git_args == [
("fetch", "origin", "--tags", "--force"),
("checkout", "--detach", "refs/tags/v3.2.0"),
("reset", "--hard", "refs/tags/v3.2.0"),
("clean", "-ffdx"),
]
async def test_update_source_fetch_failure(tmp_path, log_streamer): async def test_update_source_fetch_failure(tmp_path, log_streamer):
"""fetch 失败时抛异常""" """fetch 失败时抛异常"""
source_dir = tmp_path / "branches" / "dev" source_dir = tmp_path / "branches" / "dev"
@@ -135,12 +155,27 @@ async def test_prepare_source_snapshot_serializes_shared_source(tmp_dirs, log_st
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \ with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \ patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \ patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \ patch("backend.services.build_service.update_source", new_callable=AsyncMock) as mock_update, \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"): patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
build_dir, commit = await prepare_source_snapshot("t1", task) build_dir, commit = await prepare_source_snapshot("t1", task)
assert build_dir.exists() assert build_dir.exists()
assert commit == "abc123" assert commit == "abc123"
assert mock_update.call_args.args[2:] == ("main", "branch")
async def test_prepare_source_snapshot_passes_tag_ref_type(tmp_dirs, log_streamer):
source_dir, build_dir_parent = tmp_dirs
task = MagicMock(id="tag-task", branch="v3.2.0", ref_type="tag")
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock) as mock_update, \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
await prepare_source_snapshot("t1", task)
assert mock_update.call_args.args[2:] == ("v3.2.0", "tag")
# ---- copy_source_code ---- # ---- copy_source_code ----
@@ -314,6 +349,23 @@ 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(tracks: dict) -> dict:
"""构造带版本轨道的配置;tracks 为 轨道名 -> {kind, app_ver, build_map}。"""
return {
"apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
"versions": {"tracks": tracks},
}
def _release(app_ver: str, build_map: dict = None) -> dict:
return {"kind": "release", "app_ver": app_ver, "build_map": build_map or {}}
def _feature(app_ver: str, build_map: dict = None) -> dict:
return {"kind": "feature", "app_ver": app_ver, "build_map": build_map or {}}
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 +376,9 @@ 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({"release": _release("3.0.1"), "feature": _feature("9.9.9")})
"apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
"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 +387,122 @@ 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({
"release": _release("2.195.0", {"2.195.0": 5, "2.196.0": 3}),
"feature": _feature("2.195.0"),
})
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_release_branches_are_independent(tmp_path, log_streamer):
"""master 与 develop 各用自己的轨道,版本号与构建号互不影响"""
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({
"release": _release("2.195.0", {"2.195.0": 5}), # master
"develop": _release("2.196.0", {"2.196.0": 1}),
"feature": _feature("2.100.0"),
})
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.196.0"
assert result["BUILD_VERSION"] == "2.196.0.2"
# master 的 release 轨完全不受影响
assert mock_config["versions"]["tracks"]["release"]["build_map"] == {"2.195.0": 5}
async def test_generate_config_build_no_skips_other_release_track(tmp_path, log_streamer):
"""两个上架分支临时用同一 App_Ver 时,构建号跨轨道取最大值 +1,避免 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="develop")
mock_config = _versions_config({
"release": _release("2.196.0", {"2.196.0": 7}), # master
"develop": _release("2.196.0", {"2.196.0": 2}),
"feature": _feature("2.100.0"),
})
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["BUILD_VERSION"] == "2.196.0.8"
assert mock_config["versions"]["tracks"]["develop"]["build_map"]["2.196.0"] == 8
async def test_generate_config_feature_branch_never_increments(tmp_path, log_streamer):
"""自测分支用自己那条轨道的版本号,且构建号永不自增(即使是 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({
"release": _release("2.195.0", {"2.195.0": 5}),
"feature/login": _feature("2.100.0"),
})
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/login"]["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({
"release": _release("2.196.0"),
"feature/login": _feature("2.100.0"),
})
# release/2.196 按名字本应是上架轨,显式指到 feature/login 那条轨道
mock_config["branch_track"] = {"release/2.196": "feature/login"}
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 +583,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()
+20
View File
@@ -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
+14
View File
@@ -66,6 +66,20 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
assert resp.status_code == 404 assert resp.status_code == 404
def test_download_dsym_remote_fallback(client, tmp_config):
"""本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向,
因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)"""
task_id = _create_task_with_files(
client, tmp_config,
dsym_path="/tmp/nonexistent.dSYM",
dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip",
)
with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"):
resp = client.get(f"/api/tasks/{task_id}/dsym")
assert resp.status_code == 200
assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1"
# ---- 混淆映射表下载 ---- # ---- 混淆映射表下载 ----
def test_download_obfuscation_maps_not_exist(client, tmp_config): def test_download_obfuscation_maps_not_exist(client, tmp_config):
+73
View File
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
assert "![image](https://download.example.com/app.png)" in text assert "![image](https://download.example.com/app.png)" in text
def test_dingtalk_payload_at_mobiles_appended_to_text_and_at_field():
payload = build_dingtalk_payload(
_build_config(),
"https://download.example.com/app.html",
at_mobiles=["13800000000", " 13900000001 ", "13800000000", ""],
)
# 去重去空后写入 at.atMobiles,并在正文追加 @手机号(钉钉 markdown 必需)
assert payload["at"]["atMobiles"] == ["13800000000", "13900000001"]
assert "@13800000000" in payload["markdown"]["text"]
assert "@13900000001" in payload["markdown"]["text"]
def test_dingtalk_payload_without_at_mobiles_has_empty_at():
payload = build_dingtalk_payload(_build_config(), "https://download.example.com/app.html")
assert payload["at"]["atMobiles"] == []
assert "@" not in payload["markdown"]["text"]
def test_dingtalk_notification_forwards_at_mobiles():
response = MagicMock(status_code=200)
response.json.return_value = {"errcode": 0}
with patch("backend.services.notification.httpx.post", return_value=response) as post:
send_dingtalk_notification(
{
"enabled": True,
"webhook_url": "https://example.com/robot",
"at_mobiles": ["13800000000", "13900000001"],
},
_build_config(),
"https://download.example.com/app.html",
)
sent = post.call_args.kwargs["json"]
assert sent["at"]["atMobiles"] == ["13800000000", "13900000001"]
assert "@13800000000" in sent["markdown"]["text"]
def test_dingtalk_payload_app_store_heading_and_no_qr():
config = {**_build_config(), "BUILD_TYPE": "App_Store"}
# App_Store 流程不传二维码地址(publish_ipa 返回空串)
payload = build_dingtalk_payload(
config, "https://oss.example.com/readoor/iOS/app_2_197_1.ipa"
)
text = payload["markdown"]["text"]
# 标题保留「打包」二字,避免命中钉钉自定义关键词拦截
assert "## 【iOS】App_Store 打包信息" in text
assert "**iOS 下载链接:** https://oss.example.com/readoor/iOS/app_2_197_1.ipa" in text
assert "![image]" not in text
def test_dingtalk_notification_app_store_uses_own_at_list():
response = MagicMock(status_code=200)
response.json.return_value = {"errcode": 0}
dingtalk_config = {
"enabled": True,
"webhook_url": "https://example.com/robot",
"at_mobiles": ["13800000000"],
"at_mobiles_app_store": ["13911111111", "13922222222"],
}
with patch("backend.services.notification.httpx.post", return_value=response) as post:
send_dingtalk_notification(
dingtalk_config,
{**_build_config(), "BUILD_TYPE": "App_Store"},
"https://oss.example.com/app.ipa",
)
sent = post.call_args.kwargs["json"]
assert sent["at"]["atMobiles"] == ["13911111111", "13922222222"]
assert "## 【iOS】App_Store 打包信息" in sent["markdown"]["text"]
assert "@13800000000" not in sent["markdown"]["text"]
def test_dingtalk_notification_skips_when_disabled(): def test_dingtalk_notification_skips_when_disabled():
with patch("backend.services.notification.httpx.post") as post: with patch("backend.services.notification.httpx.post") as post:
assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False
+54 -1
View File
@@ -33,7 +33,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
"<string>applinks:old</string>\n", encoding="utf-8" "<string>applinks:old</string>\n", encoding="utf-8"
) )
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f: with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f:
plistlib.dump({"CFBundleURLTypes": []}, f) plistlib.dump({"CFBundleURLTypes": [
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
]}, f)
theme = tmp_path / "theme" / "AppIcon.appiconset" theme = tmp_path / "theme" / "AppIcon.appiconset"
theme.mkdir(parents=True) theme.mkdir(parents=True)
@@ -42,6 +44,7 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
config = { config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid", "VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test", "API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
"wxworkSchema": "wx1234567890abcdef",
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile", "BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc", "ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
"CERTIFICATE": "Apple Distribution", "CERTIFICATE": "Apple Distribution",
@@ -58,3 +61,53 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
assert "iPhone Developer" not in project_content assert "iPhone Developer" not in project_content
assert (build_dir / "exportOptions.plist").exists() assert (build_dir / "exportOptions.plist").exists()
assert (logo / "icon-1024.png").read_bytes() == b"icon" assert (logo / "icon-1024.png").read_bytes() == b"icon"
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("rb") as f:
url_types = plistlib.load(f)["CFBundleURLTypes"]
wxwork = next(i for i in url_types if i["CFBundleURLName"] == "wxwork")
assert wxwork["CFBundleURLSchemes"] == ["wx1234567890abcdef"]
def _make_minimal_project(build_dir):
"""构造 apply_project_config 所需的最小工程骨架,返回关键文件路径。"""
project = build_dir / "readoor.xcodeproj"
swift = build_dir / "readoor" / "3.0" / "AppConfig"
entitlements_dir = build_dir / "readoor" / "3.0"
for path in [project, swift, entitlements_dir]:
path.mkdir(parents=True, exist_ok=True)
(project / "project.pbxproj").write_text(
"MARKETING_VERSION = 1.0;\nCURRENT_PROJECT_VERSION = 1;\n"
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
encoding="utf-8",
)
(swift / "RDAppConfiguration.swift").write_text(
'let RD_APP_GUID: String = "old"\nlet RD_API_DOMAIN: String = "old"\n'
'let RD_WECHAT_PAY_ID: String = "old"\nlet RD_SOURCE_VERSION: String = "old"\n',
encoding="utf-8",
)
(entitlements_dir / "readoor31.entitlements").write_text(
"<string>applinks:old</string>\n", encoding="utf-8"
)
return entitlements_dir / "readoor31.plist"
def test_wxwork_schema_removed_when_not_configured(tmp_path):
build_dir = tmp_path / "build"
plist_path = _make_minimal_project(build_dir)
with plist_path.open("wb") as f:
plistlib.dump({"CFBundleURLTypes": [
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
]}, f)
config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "APPID_NAME": "Test",
"ASSOCIATED_DOMAINS": "applinks:example.com", "BUILD_TYPE": "Ad_Hoc",
"CERTIFICATE": "Apple Distribution",
}
apply_project_config(build_dir, config)
with plist_path.open("rb") as f:
url_types = plistlib.load(f)["CFBundleURLTypes"]
assert all(i.get("CFBundleURLName") != "wxwork" for i in url_types)
+9 -6
View File
@@ -37,8 +37,8 @@ def test_production_rejects_default_security_settings(monkeypatch):
config.validate_production_security() config.validate_production_security()
def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config): def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
"""普通账号管理 Apps版本号,不能读取或修改管理员配置""" """普通账号只能管理 Apps版本号等其余配置均为管理员专属"""
response = client.post("/api/users", json={ response = client.post("/api/users", json={
"username": "builder", "username": "builder",
"password": "builder-password-123", "password": "builder-password-123",
@@ -57,10 +57,13 @@ def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_conf
assert client.post("/api/config/apps", json={ assert client.post("/api/config/apps", json={
"name": "普通用户 App", "server": "测试环境", "name": "普通用户 App", "server": "测试环境",
}, headers=headers).status_code == 200 }, headers=headers).status_code == 200
assert client.get("/api/config/versions", headers=headers).status_code == 200 assert client.get("/api/config/versions", headers=headers).status_code == 403
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers) assert client.put(
assert versions.status_code == 200 "/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers
assert versions.json()["build_ver"] == "2.196.0.0" ).status_code == 403
assert client.put(
"/api/config/branches/track", json={"branch": "master", "track": "feature"}, headers=headers
).status_code == 403
assert client.get("/api/config", headers=headers).status_code == 403 assert client.get("/api/config", headers=headers).status_code == 403
assert client.get("/api/config/servers", headers=headers).status_code == 403 assert client.get("/api/config/servers", headers=headers).status_code == 403
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403 assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403