21 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
29 changed files with 2244 additions and 709 deletions
+3
View File
@@ -12,7 +12,10 @@ __pycache__/
# 运行时数据
backend/data/*.bak
# 运行时配置:网页 UI 改配置、每次 App_Store 打包都会写盘,不纳入版本控制。
# 结构参见仓库根目录的 config.example.json;首次部署可 cp 一份作为初始种子。
config.json
backend/data/config.json
ReadoorBranches/
build/
+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/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
- `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI
- Versions are per-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
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
- Admin-only routes: user management (`/api/users/*`)
- Admin-only routes: user management (`/api/users/*`) and all of `/api/config/*` except `/api/config/apps*`
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
- Chinese UI and documentation throughout
+90 -12
View File
@@ -13,6 +13,7 @@
- [Watchdog 健康监测](#watchdog-健康监测)
- [macOS 开机自启](#macos-开机自启)
- [开发模式](#开发模式)
- [推送代码到 Git 服务器](#推送代码到-git-服务器)
- [自动化测试](#自动化测试)
- [项目结构](#项目结构)
- [常见问题](#常见问题)
@@ -63,11 +64,24 @@ ADMIN_PASSWORD=your_secure_password
### 3. 构建与启动
推荐一键部署(构建 + 启动 + 健康监测,一步到位):
```bash
# 一键部署:build + start + watchdog(强制重来)
./deploy.sh up
```
`up` 会自动构建前端、(强制)重启后端并**轮询 `/api/health` 直到就绪**才返回,再拉起 watchdog,最后打印状态。适合首次部署和更新代码后重新上线。
> ⚠️ `up`/`start` 采用「强制重来」语义:若检测到旧服务会先停再起,正在进行的打包任务会被中断。
也可以分步执行:
```bash
# 一键构建(安装依赖 + 构建前端)
./deploy.sh build
# 启动服务
# 启动服务(等待就绪;端口被非本项目进程占用时会明确报错)
./deploy.sh start
```
@@ -156,19 +170,55 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
### Web 管理配置
以下配置通过管理后台 Web 界面管理(存储在 `config.json`):
以下配置通过管理后台 Web 界面管理(存储在 `backend/data/config.json`):
首次部署时,如果项目根目录下存在 `config.json`,系统会自动导入其中的 `apps``schemes` 作为初始配置;导入后即可继续在后台增删改
> ⚠️ `config.json` 是**运行时数据文件**(网页改配置、每次 App_Store 打包写入构建号都会改写它),**不纳入版本控制**,`git pull` 不会覆盖它。仓库仅提供结构模板 `config.example.json`
>
> 首次部署时,若项目根目录下存在 `config.json`,系统会自动将其复制为 `backend/data/config.json` 作为初始配置。可从模板起步:
>
> ```bash
> cp config.example.json config.json # 按需填入真实的 apps / servers / upload 等,再启动
> ```
>
> 导入后即可继续在后台增删改。
| 配置项 | 说明 |
|--------|------|
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
| Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表 |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码 |
| 分支管理 | 可打包的代码分支和 Tag 列表,以及各自的版本轨道类型(上架 / 自测);Tag 打包时以游离 HEAD 检出 `refs/tags/<tag>` |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 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/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
@@ -179,6 +229,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|------|--------|------|
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) |
| `WATCHDOG_STARTUP_GRACE` | `10` | 首次检查前的宽限(秒),避开服务正常启动窗口,防止误重启 |
| `STARTUP_READY_TIMEOUT` | `20` | `start` 后等待 `/api/health` 就绪的最长时间(秒) |
| `WATCHDOG_RESTART_MODE` | `nohup` | 重启方式:`nohup`PID 文件托管)或 `launchd`(用 `launchctl kickstart` 交回 launchd 托管,避免与 KeepAlive 抢端口) |
| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 |
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
@@ -196,8 +249,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
| 命令 | 说明 |
|------|------|
| `up` | **一键部署**build + start + watchdog(强制重来),启动后等待就绪 |
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
| `start` | 后台启动服务 |
| `start` | 后台启动服务(强制重启并等待 `/api/health` 就绪;端口被非本项目进程占用会报错) |
| `stop` | 停止服务和 watchdog |
| `restart` | 重启服务 |
| `status` | 查看服务和 watchdog 运行状态 |
@@ -209,18 +263,17 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
### 常用操作
```bash
# 首次部署
./deploy.sh build && ./deploy.sh start
# 首次部署(一键:build + start + watchdog
./deploy.sh up
# 查看状态
./deploy.sh status
# 更新代码后重新部署
git pull
./deploy.sh build
./deploy.sh restart
./deploy.sh up
# 启用健康监测
# 单独启用健康监测
./deploy.sh watchdog
# 停止所有服务
@@ -252,7 +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 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
---
## 自动化测试
### 运行测试
-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"
}
}
+2
View File
@@ -23,8 +23,10 @@ def _migrate_db():
"""简单的数据库迁移:为旧表添加缺失的列"""
migrations = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "ref_type", "VARCHAR DEFAULT 'branch'"),
("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"),
("tasks", "dsym_url", "VARCHAR"),
]
with engine.connect() as conn:
for table, column, col_type in migrations:
+3 -1
View File
@@ -15,7 +15,8 @@ class Task(Base):
scheme_id = Column(String, nullable=False)
scheme_name = Column(String, nullable=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
@@ -32,6 +33,7 @@ class Task(Base):
ipa_path = Column(String)
oss_url = Column(String)
dsym_path = Column(String)
dsym_url = Column(String)
obfuscation_maps_path = Column(String)
qr_code_path = Column(String)
build_dir = Column(String)
+7 -1
View File
@@ -1,7 +1,7 @@
"""打包选择 API"""
from fastapi import APIRouter
from .config import load_config
from .config import get_tags, load_config
router = APIRouter(prefix="/api", tags=["apps"])
@@ -44,3 +44,9 @@ async def get_branches_for_build():
"""获取分支列表(供打包选择)"""
config = load_config()
return config.get("branches", ["main"])
@router.get("/tags")
async def get_tags_for_build():
"""获取 Tag 列表(供打包选择)"""
return get_tags(load_config())
+494 -29
View File
@@ -26,10 +26,9 @@ from ..deps import get_current_user, require_admin
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/")
if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
or path == "/api/config/versions"):
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
return user
return require_admin(user)
@@ -59,6 +58,8 @@ DEFAULT_UPLOAD = {
"enabled": False,
"webhook_url": "",
"secret": "",
"at_mobiles": [],
"at_mobiles_app_store": [],
},
}
@@ -90,10 +91,302 @@ DEFAULT_SERVERS = {
}
}
DEFAULT_VERSIONS = {
"app_ver": "2.180.0",
"build_ver": "2.180.0.0",
}
DEFAULT_APP_VER = "2.180.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 自动分配。
LEGACY_SERVER_PREFIXES = {
@@ -148,6 +441,14 @@ def _apply_special_app_prefix_override(app: dict) -> None:
app["app_id_prefix_override"] = 1
def _normalize_tencent_app_id(app: dict) -> None:
"""腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)。"""
value = str(app.get("tencent") or "").strip()
if not value:
return
app["tencent"] = value if value.startswith("tencent") else f"tencent{value}"
def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
prefix = app.get("app_id_prefix_override")
@@ -186,8 +487,14 @@ def _ensure_upload_keys(config: dict) -> bool:
def _migrate_old_themes(config: dict) -> bool:
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更"""
changed = False
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
"""
if config.get("_legacy_themes_migrated"):
return False
themes_dir = AUTOMATION_DIR / "themes"
for app_id, app in config.get("apps", {}).items():
upload_key = app.get("upload_key", "")
@@ -222,8 +529,8 @@ def _migrate_old_themes(config: dict) -> bool:
app["skins"] = skins
if theme != skin_name:
cert["theme"] = skin_name
changed = True
return changed
config["_legacy_themes_migrated"] = True
return True
def load_config() -> dict:
@@ -247,13 +554,12 @@ def load_config() -> dict:
config["branches"] = ["main"]
if "upload" not in config:
config["upload"] = DEFAULT_UPLOAD
if "versions" not in config:
config["versions"] = DEFAULT_VERSIONS.copy()
versions_changed = _ensure_versions(config)
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
prefixes_changed = _ensure_server_id_prefixes(config)
overrides_changed = _ensure_special_app_prefix_overrides(config)
keys_changed = _ensure_upload_keys(config)
if prefixes_changed or overrides_changed or keys_changed:
if versions_changed or prefixes_changed or overrides_changed or keys_changed:
save_config(config)
# 迁移旧式目录皮肤为 ZIP
if _migrate_old_themes(config):
@@ -307,6 +613,7 @@ async def create_app(app: dict):
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
_apply_special_app_prefix_override(app)
_normalize_tencent_app_id(app)
new_id = _next_app_id(apps, config.get("servers", {}), app)
# 自动生成 upload_key
@@ -316,7 +623,7 @@ async def create_app(app: dict):
apps[new_id] = app
config["apps"] = apps
save_config(config)
return {"id": new_id, "message": "App 已创建"}
return {"id": new_id, "upload_key": app["upload_key"], "message": "App 已创建"}
@router.put("/apps/{app_id}")
@@ -329,6 +636,7 @@ async def update_app(app_id: str, app: dict):
if app_id not in apps:
raise HTTPException(status_code=404, detail="App 不存在")
_normalize_tencent_app_id(app)
apps[app_id] = app
config["apps"] = apps
save_config(config)
@@ -410,6 +718,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
return {"message": "皮肤包上传成功", "name": file.filename}
@router.put("/skins/{upload_key}/{skin_name}")
async def replace_skin(upload_key: str, skin_name: str, file: UploadFile = File(...)):
"""替换已有皮肤包内容,保持原文件名不变。
APP 配置的 theme 通过文件名(去掉 .zip)引用皮肤包,若上传新文件名会新增一个皮肤而非更新,
导致原引用失效。此接口就地覆盖指定皮肤,确保被引用的皮肤可以更新。
"""
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
config = load_config()
app_id, _ = _find_app_by_upload_key(config, upload_key)
if not app_id:
raise HTTPException(status_code=404, detail="App 不存在")
app_skins_dir = SKINS_DIR / upload_key
skin_path = app_skins_dir / skin_name
if not skin_path.exists():
raise HTTPException(status_code=404, detail="皮肤包不存在")
content = await file.read()
with open(skin_path, "wb") as f:
f.write(content)
return {"message": "皮肤包已更新", "name": skin_name}
@router.delete("/skins/{upload_key}/{skin_name}")
async def delete_skin(upload_key: str, skin_name: str):
"""删除指定皮肤包"""
@@ -502,7 +837,12 @@ async def get_branches():
@router.post("/branches")
async def add_branch(data: dict):
"""新增分支"""
"""新增分支,可指定版本轨道类型(缺省按分支名推断)。
每个分支都有自己独立的一条轨道(轨道名即分支名):track 传 release 表示上架用、
App_Store 打包时构建号自增,feature 表示自测用、构建号不自增;
也可直接传已有轨道名,让该分支与另一个分支共用同一条版本线。
"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="分支名称不能为空")
@@ -512,13 +852,40 @@ async def add_branch(data: dict):
branches = config.get("branches", ["main"])
if name in branches:
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)
config["branches"] = branches
config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track, kind)
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 with _config_lock:
@@ -528,10 +895,56 @@ async def delete_branch(branch_name: str):
raise HTTPException(status_code=404, detail="分支不存在")
branches.remove(branch_name)
config["branches"] = branches
config.get("branch_track", {}).pop(branch_name, None)
save_config(config)
return {"message": "分支已删除"}
# 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")
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
@router.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()
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 {
"app_ver": versions.get("app_ver", DEFAULT_VERSIONS["app_ver"]),
"build_ver": versions.get("build_ver", DEFAULT_VERSIONS["build_ver"]),
**{k: v for k, v in tracks.get(primary, {}).items() if k != "kind"},
"tracks": tracks,
"primary_track": primary,
"branch_track": {
branch: get_track_name(config, branch)
for branch in _build_refs(config)
},
}
@router.put("/versions")
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()
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
build_ver = f"{app_ver}.0"
build_ver = data.get("build_ver")
build_no = None
if build_ver is not None and str(build_ver).strip():
build_ver = str(build_ver).strip()
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", build_ver):
raise HTTPException(status_code=400, detail="Build_Ver 必须为四段数字,例如 2.196.0.3")
prefix, _, last = build_ver.rpartition(".")
if prefix != app_ver:
raise HTTPException(status_code=400, detail=f"Build_Ver 前三位须与 App_Ver 一致(应为 {app_ver}.x")
build_no = int(last)
async with _config_lock:
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track_name = track_name or _primary_track_name(config)
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)
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
+72 -14
View File
@@ -3,6 +3,7 @@ import os
import json
import shutil
import tempfile
import time
import uuid
from datetime import datetime
from typing import List
@@ -18,6 +19,10 @@ from ..deps import 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)
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -50,6 +55,10 @@ async def create_task(task: TaskCreate, request: Request, user: dict = Depends(g
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())
db_task = Task(
id=task_id,
@@ -60,12 +69,13 @@ async def create_task(task: TaskCreate, request: Request, user: dict = Depends(g
scheme_name=scheme.get("displayName") or scheme_name,
obfuscation=task.obfuscation,
branch=task.branch,
ref_type=ref_type,
status="pending",
)
db.add(db_task)
from ..services.audit_log import record_operation
record_operation(db, user, "build_created", request=request, task=db_task,
detail={"branch": task.branch, "build_type": task.build_type})
detail={"branch": task.branch, "ref_type": ref_type, "build_type": task.build_type})
db.commit()
db.refresh(db_task)
@@ -200,6 +210,25 @@ async def delete_task(task_id: str, request: Request, user: dict = Depends(get_c
except (json.JSONDecodeError, DistributionError) as 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"
if log_path.exists():
@@ -276,17 +305,28 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
)
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task,
resource_type="ipa", resource_name=task.oss_url,
detail={"controlled": True})
db.commit()
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")
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 zipfile
import tempfile
@@ -294,17 +334,35 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
db.commit()
dsym_path = task.dsym_path
if not os.path.exists(dsym_path):
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 文件已被清理")
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 再下载
if os.path.isdir(dsym_path):
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
+2
View File
@@ -22,6 +22,7 @@ class TaskResponse(BaseModel):
scheme_name: str
obfuscation: bool
branch: str
ref_type: Optional[str] = "branch"
status: str
current_step: Optional[str]
created_at: Optional[datetime]
@@ -31,6 +32,7 @@ class TaskResponse(BaseModel):
ipa_path: Optional[str]
oss_url: Optional[str]
dsym_path: Optional[str]
dsym_url: Optional[str] = None
obfuscation_maps_path: Optional[str]
qr_code_path: Optional[str]
build_dir: Optional[str]
+104 -53
View File
@@ -23,7 +23,7 @@ from ..config import (
mask_git_remote_url,
)
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 .project_patcher import ProjectPatchError, apply_project_config
@@ -291,8 +291,8 @@ def _db_update(db, task, **fields):
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_masked = mask_git_remote_url(remote_url)
git_env = _git_env()
@@ -321,7 +321,8 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if not cloned and not (source_dir / ".git").exists():
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:
process = await asyncio.create_subprocess_exec(
@@ -333,12 +334,22 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if process.returncode != 0:
raise Exception("git remote set-url 失败")
commands = [
(("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
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 = [
(("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
for command, error_message in commands:
process = await asyncio.create_subprocess_exec(
*_GIT_NO_CREDENTIAL_HELPER, *command[1:], env=git_env,
@@ -375,7 +386,7 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
source_dir = get_source_dir(task.branch)
if not source_dir.exists():
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}")
return await copy_source_code(task_id, task, source_dir), ""
@@ -384,7 +395,8 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
await log_streamer.emit(task_id, "等待共享源码准备队列...")
async with source_prepare_lock:
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)
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
build_dir = await copy_source_code(task_id, task, source_dir)
@@ -433,6 +445,7 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir)
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
await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False))
@@ -455,7 +468,7 @@ async def run_build_task(task_id: str):
ipa_path = await build_project(task_id, task, config_data, build_dir)
# 查找 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"
@@ -477,6 +490,13 @@ async def run_build_task(task_id: str):
upload_fields["oss_url"] = oss_url
if 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:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
@@ -491,22 +511,22 @@ async def run_build_task(task_id: str):
if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。
if task.build_type == "Ad_Hoc":
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
try:
notified = await asyncio.to_thread(
send_dingtalk_notification,
dingtalk_config,
config_data,
task.oss_url or oss_url,
task.qr_code_path or qr_code_path,
)
if notified:
await log_streamer.emit(task_id, "钉钉通知发送成功")
except NotificationError as exc:
await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn")
# Ad Hoc 发安装页二维码通知,App Store IPA 下载地址通知;
# 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
try:
notified = await asyncio.to_thread(
send_dingtalk_notification,
dingtalk_config,
config_data,
task.oss_url or oss_url,
task.qr_code_path or qr_code_path,
)
if notified:
await log_streamer.emit(task_id, "钉钉通知发送成功")
except NotificationError as exc:
await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn")
# 带超时执行打包
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
@@ -601,35 +621,44 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
"""生成打包配置"""
await log_streamer.emit_step(task_id, "生成配置")
from ..routers.config import load_config, _config_lock
from ..routers.config import (
load_config, _config_lock, get_track, get_track_name, track_build_ver,
is_release_track, next_build_no, _ensure_track,
)
config = load_config()
apps = config.get("apps", {})
schemes = config.get("schemes", {})
versions = config.get("versions", {})
app = apps.get(task.app_id, {})
scheme = schemes.get(task.scheme_id, {})
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
app_ver = versions.get("app_ver", "2.180.0")
build_ver = versions.get("build_ver", "2.180.0.0")
# 按分支所属轨道取版本号:每个上架分支(master、develop 等)一条独立轨道,
# 自测分支共用 feature 轨,各轨道的 App_Ver 互不影响。
track_name = get_track_name(config, task.branch)
track = get_track(config, track_name)
app_ver = track.get("app_ver") or "2.180.0"
# App_Store 打包时自动递增 Build_Ver 第四位版本号
if task.build_type == "App_Store":
# 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
build_ver = track_build_ver(track)
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:
parts = build_ver.split(".")
if len(parts) >= 4:
parts[3] = str(int(parts[3]) + 1)
else:
parts.append("1")
build_ver = ".".join(parts)
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
track = _ensure_track(config, track_name)
track.setdefault("app_ver", app_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
save_config(config)
@@ -675,7 +704,12 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
skin_extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(skin_zip, "r") as zf:
zf.extractall(skin_extract_dir)
entries = list(skin_extract_dir.iterdir())
# 过滤掉 macOS 压缩产生的 __MACOSX 及隐藏文件,避免把它们
# 当成皮肤资源目录一起复制进 Images.xcassets。
entries = [
e for e in skin_extract_dir.iterdir()
if e.name != "__MACOSX" and not e.name.startswith(".")
]
if len(entries) == 1 and entries[0].is_dir():
config_data["THEME"] = str(entries[0])
else:
@@ -701,7 +735,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 关联域名等配置
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:
config_data[key] = app[key]
@@ -900,15 +934,22 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
return ipa_path
async def find_dsym(task_id: str, build_dir: Path) -> Path:
"""查找 dSYM 文件"""
async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path:
"""查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM"""
derived_data = build_dir / "build" / "derived_data"
if not derived_data.exists():
return None
for dsym in derived_data.rglob("*.dSYM"):
return dsym
return None
dsyms = list(derived_data.rglob("*.dSYM"))
if not dsyms:
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:
@@ -924,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)
except DistributionError as 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 ""
+194 -14
View File
@@ -4,6 +4,7 @@ import plistlib
import posixpath
import re
import shutil
from html import escape
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
@@ -54,18 +55,149 @@ def _write_manifest(config: dict, manifest: Path, ipa_url: str):
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):
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&') }"
title = config.get("APPID_NAME", "iOS App")
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}"
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(
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">"
f"<title>{title}</title><body><h2>{title}</h2>"
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>"
"</body></html>",
DOWNLOAD_PAGE_TEMPLATE.format(
title=escape(title),
version=escape(str(config.get("VERSION", ""))),
install_url=escape(install_url, quote=True),
meta=meta,
),
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]:
stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
@@ -169,14 +301,21 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths)
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
if upload_config.get("mode") != "oss":
return published_url
def delete_published_dsym(build_config: dict, upload_config: dict):
"""删除任务对应的远端 dSYM 压缩包。
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
if not remote_paths:
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
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):
@@ -190,7 +329,29 @@ def get_published_download_url(build_config: dict, upload_config: dict, publishe
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"], oss["bucket_name"], connect_timeout=30,
)
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
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):
@@ -283,3 +444,22 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
qrcode.make(urls[".html"]).save(qr_path)
urls.update(uploader(upload, [(qr_path, qr_remote)]))
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))}"
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 内容。"""
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 = (
f"**环境:** {config_data.get('SERVER', '')}\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"**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:
text += f"\n![image]({qr_code_url})"
# 钉钉 markdown 消息必须在正文内出现 @手机号 才会真正 @ 到人,
# 仅靠 at.atMobiles 不生效。
if mobiles:
text += "\n\n" + " ".join(f"@{m}" for m in mobiles)
return {
"msgtype": "markdown",
"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:
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
payload = build_dingtalk_payload(config_data, download_url, qr_code_url)
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", [])
# Ad_Hoc 与 App_Store 使用各自的 @ 名单。
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(
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
json=payload,
+7
View File
@@ -35,11 +35,15 @@ def _update_plist(path: Path, config: dict):
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
values = {
"weixinlogin": config.get("weixinlogin", ""),
"wechatpay": config.get("weixinpay", ""),
"tencent": config.get("tencent", ""),
# 企业微信 Schema 与微信登录 AppID 同样存在 CFBundleURLTypes 中
# CFBundleURLName == "wxwork"),未配置则整条移除。
"wxwork": (config.get("wxworkSchema", "") or "").strip(),
"readoorUrlScheme": scheme,
}
url_types = []
@@ -68,6 +72,9 @@ def _apply_theme(build_dir: Path, theme_dir: str, messages: list[str]):
for item in source.iterdir():
if not item.is_dir():
continue
# 跳过 macOS 压缩包内的 __MACOSX 及隐藏目录,仅复制真实皮肤资源
if item.name == "__MACOSX" or item.name.startswith("."):
continue
destination = target_assets / item.name
if destination.exists():
shutil.rmtree(destination)
+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/示例分支"
}
}
+121 -50
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# iOS 自动打包服务 — 部署与管理脚本
# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop}
# 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop}
set -e
@@ -23,6 +23,11 @@ WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒)
WATCHDOG_STARTUP_GRACE="${WATCHDOG_STARTUP_GRACE:-10}" # watchdog 首次检查前的宽限(秒),避开正常启动窗口
STARTUP_READY_TIMEOUT="${STARTUP_READY_TIMEOUT:-20}" # 启动后等待 /api/health 就绪的最长时间(秒)
# 重启方式:nohup(默认,PID 文件托管)| launchd(交回 launchctl kickstart 托管,避免与 KeepAlive 抢端口)
WATCHDOG_RESTART_MODE="${WATCHDOG_RESTART_MODE:-nohup}"
APP_LAUNCHD_LABEL="${APP_LAUNCHD_LABEL:-com.readoor.buildserver}"
# ========== 颜色输出 ==========
RED='\033[0;31m'
@@ -64,6 +69,52 @@ get_server_pid() {
return 1
}
# 返回占用目标端口、但命令不属于本项目的进程 PID(若有),用于区分“端口冲突”与“本服务在跑”。
foreign_port_holder() {
local pid
for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do
if ! is_server_process "$pid"; then
echo "$pid"
return 0
fi
done
return 1
}
# 启动 uvicorn 后端进程(单点维护启动参数,供 start / watchdog 复用),PID 写入 PID_FILE。
_spawn_server() {
setup_venv
mkdir -p "$LOG_DIR"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
>> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"
}
# 轮询 /api/health 直到返回 200 或超时;就绪返回 0,否则返回 1。
# 健康检查固定走 127.0.0.1(与 check_health 一致),不受 BIND_HOST 影响。
_wait_ready() {
local timeout="${1:-$STARTUP_READY_TIMEOUT}"
local deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "$deadline" ]; do
# 进程若已退出则无需再等
if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
return 1
fi
local code
code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 2 --max-time 3 \
"http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000")
[ "$code" = "200" ] && return 0
sleep 1
done
return 1
}
# 日志同时写文件和终端
log_to_file() {
local level="$1"; shift
@@ -143,11 +194,23 @@ do_build() {
do_start() {
check_env
# 端口被非本项目进程占用:明确报错,避免 uvicorn 静默绑定失败。
local foreign
foreign=$(foreign_port_holder || true)
if [ -n "$foreign" ]; then
error "端口 $BACKEND_PORT 被非本项目进程占用 (PID: $foreign):"
ps -p "$foreign" -o pid,command= 2>/dev/null | sed 's/^/ /'
error "请先释放该端口,或在 .env 修改 BACKEND_PORT"
return 1
fi
# 本项目实例已在运行:强制重来(用户选定语义)。
# 注意:这会中断正在进行的打包任务。
local existing_pid
existing_pid=$(get_server_pid || true)
if [ -n "$existing_pid" ]; then
warn "服务已在运行 (PID: $existing_pid)"
return 0
warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..."
do_stop
fi
# 确保前端已构建
@@ -156,28 +219,15 @@ do_start() {
do_build
fi
setup_venv
mkdir -p "$LOG_DIR"
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
> "$LOG_DIR/server.log" 2>&1 &
_spawn_server
echo $! > "$PID_FILE"
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
info "服务已启动 (PID: $(cat "$PID_FILE"))"
if _wait_ready; then
info "服务已就绪 (PID: $(cat "$PID_FILE"))"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else
error "启动失败,请查看日志: $LOG_DIR/server.log"
rm -f "$PID_FILE"
error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log"
tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /'
return 1
fi
}
@@ -252,12 +302,15 @@ check_health() {
# ========== Watchdog(前台模式,用于调试) ==========
do_watchdog_fg() {
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s"
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s"
info "按 Ctrl+C 停止"
local restart_count=0
local last_restart_time=0
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do
local result
result=$(check_health)
@@ -338,7 +391,10 @@ do_watchdog_loop() {
local restart_count=0
local last_restart_time=0
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s"
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s, grace=${WATCHDOG_STARTUP_GRACE}s, mode=${WATCHDOG_RESTART_MODE}"
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do
local result
@@ -366,35 +422,33 @@ do_watchdog_loop() {
restart_count=$((restart_count + 1))
last_restart_time=$(date +%s)
log_to_file "WARN" "Restarting service (attempt: $restart_count)"
log_to_file "WARN" "Restarting service (attempt: $restart_count, mode: $WATCHDOG_RESTART_MODE)"
# 停止旧进程
if [ -f "$PID_FILE" ]; then
local old_pid
old_pid=$(cat "$PID_FILE")
kill "$old_pid" 2>/dev/null
sleep 2
kill -9 "$old_pid" 2>/dev/null || true
rm -f "$PID_FILE"
if [ "$WATCHDOG_RESTART_MODE" = "launchd" ]; then
# launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。
if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then
log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL"
else
log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL"
fi
else
# nohup 托管模式:停掉旧进程后重新拉起。
if [ -f "$PID_FILE" ]; then
local old_pid
old_pid=$(cat "$PID_FILE")
kill "$old_pid" 2>/dev/null
sleep 2
kill -9 "$old_pid" 2>/dev/null || true
rm -f "$PID_FILE"
fi
_spawn_server
fi
# 重新启动
setup_venv
mkdir -p "$LOG_DIR"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
>> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"
sleep 3
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))"
# 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。
if _wait_ready; then
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))"
else
log_to_file "ERROR" "Service restart failed"
log_to_file "ERROR" "Service restart failed or not ready in ${STARTUP_READY_TIMEOUT}s"
fi
fi
@@ -457,8 +511,21 @@ do_test() {
fi
}
# ========== 一键部署(强制重来)==========
# build → start(强制重启并等待就绪)→ watchdog,最后打印状态。
do_up() {
info "一键部署(强制重来):build → start → watchdog"
do_watchdog_stop >/dev/null 2>&1 || true # 先停监听,避免它在重启窗口内干预
do_build
do_start # do_start 内部会强制停旧服务并等待 health 就绪
do_watchdog
echo ""
do_status
}
# ========== 主入口 ==========
case "${1:-}" in
up) do_up ;;
build) do_build ;;
start) do_start ;;
stop) do_stop; do_watchdog_stop ;;
@@ -470,10 +537,11 @@ case "${1:-}" in
watchdog-fg) do_watchdog_fg ;;
_watchdog_loop) do_watchdog_loop ;;
*)
echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}"
echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}"
echo ""
echo " up 一键部署:build + start + watchdog(强制重来)"
echo " build 构建前端、安装依赖"
echo " start 启动服务(后台运行"
echo " start 启动服务(强制重启并等待就绪;端口被占用会报错"
echo " stop 停止服务(同时停止 watchdog"
echo " restart 重启服务"
echo " status 查看运行状态"
@@ -485,6 +553,9 @@ case "${1:-}" in
echo "环境变量(可在 .env 中配置):"
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)"
echo " WATCHDOG_STARTUP_GRACE watchdog 首次检查前宽限(秒,默认 10)"
echo " STARTUP_READY_TIMEOUT 启动后等待 health 就绪的最长时间(秒,默认 20)"
echo " WATCHDOG_RESTART_MODE 重启方式 nohup|launchd(默认 nohup"
echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3"
echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
exit 1
+2
View File
@@ -95,6 +95,8 @@ EOF
<dict>
<key>PATH</key>
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>WATCHDOG_RESTART_MODE</key>
<string>launchd</string>
</dict>
<key>RunAtLoad</key>
<true/>
+40
View File
@@ -73,6 +73,46 @@ describe('BuildView.vue', () => {
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 () => {
const wrapper = mountBuildView()
await flushPromises()
+115 -6
View File
@@ -23,8 +23,19 @@ function mockFetch(url) {
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/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/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'] },
}
return Promise.resolve({
@@ -168,19 +179,117 @@ describe('ConfigView.vue', () => {
expect(window.alert).toHaveBeenCalledWith('设置已保存')
})
it('普通账号可修改版本号但看不到打包参数', async () => {
it('普通账号只能管理 Apps,看不到打包设置与版本号', async () => {
const wrapper = mountConfigView({ admin: false })
await flushPromises()
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
expect(menuTexts).toEqual(['Apps 配置', '打包设置'])
expect(menuTexts).toEqual(['Apps 配置'])
expect(wrapper.text()).not.toContain('上架版本号')
expect(wrapper.text()).not.toContain('自测版本号')
expect(wrapper.text()).not.toContain('打包参数')
// 版本号接口为管理员专属,普通账号不再请求
expect(fetch).not.toHaveBeenCalledWith('/api/config/versions', expect.anything())
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps'])
})
it('每个分支的版本号各自独立展示与保存', async () => {
const calls = []
fetch.mockImplementation((url, opts) => {
if (url === '/api/config/versions' && opts?.method === 'PUT') {
calls.push(JSON.parse(opts.body))
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ app_ver: '2.101.0', build_ver: '2.101.0.0' }),
})
}
return mockFetch(url)
})
window.alert = vi.fn()
const wrapper = mountConfigView()
await flushPromises()
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
await buildMenu.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('应用版本号')
expect(wrapper.text()).not.toContain('打包参数')
expect(wrapper.findAll('input').length).toBe(1)
// 每个分支一块:main(上架,release 轨)、dev(自测,dev 轨)
expect(wrapper.text()).toContain('上架版本号(main')
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],
])
})
})
+13 -4
View File
@@ -41,9 +41,14 @@
</select>
</div>
<div class="form-group">
<label>代码分支</label>
<label>代码分支 / Tag</label>
<select v-model="form.branch">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
<optgroup label="分支">
<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>
</div>
<div class="form-group">
@@ -167,6 +172,7 @@ const authFetch = (url, options = {}) => {
const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
const tags = ref([])
const tasks = ref([])
const selectedServer = ref('')
const serverEnvironments = computed(() => {
@@ -314,15 +320,18 @@ onUnmounted(() => {
})
onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
const [appsRes, schemesRes, branchesRes, tagsRes, tasksRes] = await Promise.all([
authFetch('/api/apps'),
authFetch('/api/schemes'),
authFetch('/api/branches'),
authFetch('/api/tags'),
authFetch('/api/tasks'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
const allTasks = await tasksRes.json()
// taskId
@@ -436,7 +445,7 @@ const errorCategoryHint = (cat) => {
provisioning: '描述文件问题:描述文件过期、不匹配或未安装',
compilation: '代码编译错误:请检查代码语法和类型引用',
dependency: '依赖问题:Pod 依赖缺失或版本不兼容',
git: '代码拉取失败:请检查分支是否存在',
git: '代码拉取失败:请检查分支或 Tag 是否存在',
config: '配置替换失败:请检查打包配置',
build: '构建配置错误:请检查 Scheme 和 Workspace 配置',
unknown: '打包失败:请查看详细日志排查原因',
+266 -42
View File
@@ -15,7 +15,7 @@
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul>
</div>
@@ -151,27 +151,43 @@
<!-- 分支管理 -->
<div v-if="tab === 'branches'">
<h2>分支管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理可打包的代码分支打包时从对应分支目录获取源码</p>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">
管理可打包的代码分支和 Tag打包时从对应分支或 Tag 获取源码
每个分支 / Tag 各有一套独立的版本号版本轨道决定构建号行为上架App_Store 打包构建号自增自测构建号不自增新增 Tag 默认为上架
</p>
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
<select v-model="newRefType" style="width: auto; padding: 10px 12px;">
<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>
<table class="config-table">
<thead>
<tr>
<th>分支名称</th>
<th>名称</th>
<th>类型</th>
<th>版本轨道</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="b in branches" :key="b">
<td><strong>{{ b }}</strong></td>
<tr v-for="r in buildRefs" :key="`${r.type}:${r.name}`">
<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">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
<button class="action-btn delete" @click="deleteBranch(r)">删除</button>
</td>
</tr>
<tr v-if="!branches.length">
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td>
<tr v-if="!buildRefs.length">
<td colspan="4" style="text-align: center; color: #999;">暂无分支配置</td>
</tr>
</tbody>
</table>
@@ -258,6 +274,16 @@
<label>加签密钥可选</label>
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
</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>
@@ -269,13 +295,31 @@
<div v-if="tab === 'build'">
<h2>打包设置</h2>
<div style="max-width: 500px;">
<h4 class="section-title">应用版本号</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</div>
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
<template v-for="t in branchTracks" :key="t.name">
<h4 class="section-title">
{{ t.kind === 'release' ? '上架版本号' : '自测版本号' }}{{ t.branches.join(' / ') }}
</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<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;">
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }}轨道 {{ t.name }}与其它分支 / Tag 互不影响
</div>
</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">
<h4 class="section-title">打包参数</h4>
@@ -455,10 +499,16 @@
<input v-model="appForm.weixinpay" type="text" placeholder="wx...">
</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-group">
<label>腾讯 AppID</label>
<input v-model="appForm.tencent" type="text" placeholder="tencent...">
<input v-model="appForm.tencent" type="text" placeholder="tencent...(可只填数字,自动补前缀)" @blur="normalizeTencentAppId">
</div>
<div class="form-group">
<label>阿里云 License Key</label>
@@ -519,12 +569,21 @@
<div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span>
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
更新
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
</label>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div>
</div>
<div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }">
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }}
<label class="btn btn-outline skin-upload-btn"
:class="{ disabled: skinUploading, dragover: skinDragover }"
@dragover.prevent="skinDragover = true"
@dragenter.prevent="skinDragover = true"
@dragleave.prevent="skinDragover = false"
@drop.prevent="onSkinDrop">
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.zip,或拖拽到此处)') }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label>
</div>
@@ -594,12 +653,81 @@ const apps = ref({})
const schemes = ref({})
const servers = ref({})
const branches = ref([])
const tags = 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 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 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 editingServerName = ref(null)
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null })
@@ -615,6 +743,7 @@ const editingAppId = ref(null)
const appForm = ref({})
const appSkins = ref([])
const skinUploading = ref(false)
const skinDragover = ref(false)
const showSchemeModal = ref(false)
const editingSchemeId = ref(null)
@@ -626,20 +755,18 @@ onMounted(async () => {
const loadData = async () => {
if (!isAdmin.value) {
const [appsRes, versionsRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/versions'),
])
// Apps
const appsRes = await authFetch('/api/config/apps')
if (appsRes.ok) apps.value = await appsRes.json()
if (versionsRes.ok) versions.value = await versionsRes.json()
return
}
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/schemes'),
authFetch('/api/config/servers'),
authFetch('/api/config/branches'),
authFetch('/api/config/tags'),
authFetch('/api/config/build'),
authFetch('/api/config/upload'),
authFetch('/api/config'),
@@ -650,10 +777,12 @@ const loadData = async () => {
schemes.value = await schemesRes.json()
servers.value = await serversRes.json()
branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
buildSettings.value = await buildRes.json()
uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
versions.value = await versionsRes.json()
applyVersions(await versionsRes.json())
if (usersRes.ok) users.value = await usersRes.json()
}
@@ -818,6 +947,7 @@ const openAppModal = (id = null, app = null) => {
UniversalLink: '',
weixinlogin: '',
weixinpay: '',
wxworkSchema: '',
tencent: '',
AlivcLicenseKey: '',
app_id_prefix_override: '',
@@ -862,9 +992,9 @@ const loadSkins = async () => {
} catch { appSkins.value = [] }
}
const uploadSkin = async (event) => {
const file = event.target.files[0]
const uploadSkinFile = async (file) => {
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); return }
const uploadKey = appForm.value.upload_key
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
skinUploading.value = true
@@ -880,7 +1010,12 @@ const uploadSkin = async (event) => {
throw new Error(err.detail || '上传失败')
}
await loadSkins()
event.target.value = ''
//
const skinTheme = file.name.replace(/\.zip$/i, '')
const certs = appForm.value.certificates || {}
Object.keys(certs).forEach(certType => {
if (!certs[certType].theme) certs[certType].theme = skinTheme
})
} catch (e) {
alert('上传失败: ' + e.message)
} finally {
@@ -888,6 +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) => {
if (!confirm(`确定删除皮肤包 ${skinName}`)) return
const uploadKey = appForm.value.upload_key
@@ -906,12 +1079,20 @@ const formatSize = (bytes) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// AppID tencent ID
const normalizeTencentAppId = () => {
const value = (appForm.value.tencent || '').trim()
appForm.value.tencent = value && !value.startsWith('tencent') ? `tencent${value}` : value
}
const saveApp = async () => {
if (!appForm.value.name || !appForm.value.AppGuid) {
alert('请填写必填字段:App 名称、AppGuid')
return
}
normalizeTencentAppId()
//
if (appForm.value.certificates) {
Object.keys(appForm.value.certificates).forEach(key => {
@@ -948,9 +1129,18 @@ const saveApp = async () => {
const err = await res.json().catch(() => ({ detail: '保存失败' }))
throw new Error(err.detail || '保存失败')
}
showAppModal.value = false
await loadData()
alert('保存成功')
if (!editingAppId.value) {
// upload_key
const data = await res.json().catch(() => ({}))
editingAppId.value = data.id || editingAppId.value
if (data.upload_key) appForm.value.upload_key = data.upload_key
await loadSkins()
alert('保存成功,现在可以上传皮肤包了')
} else {
showAppModal.value = false
alert('保存成功')
}
} catch (e) {
alert('保存失败: ' + e.message)
}
@@ -1005,12 +1195,12 @@ const deleteScheme = async (id) => {
await loadData()
}
//
// Tag
const addBranch = async () => {
const name = newBranch.value.trim()
if (!name) return
try {
const res = await authFetch('/api/config/branches', {
const res = await authFetch(newRefType.value === 'tag' ? '/api/config/tags' : '/api/config/branches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@@ -1026,10 +1216,31 @@ const addBranch = async () => {
}
}
const deleteBranch = async (name) => {
if (!confirm(`确定删除分支「${name}」?`)) return
const setBranchTrack = async (name, track) => {
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('删除失败')
await loadData()
} catch (e) {
@@ -1047,16 +1258,25 @@ const saveBuildSettings = async () => {
alert('设置已保存')
}
const saveVersions = async () => {
const saveVersions = async (track) => {
try {
const res = await authFetch('/api/config/versions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_ver: versions.value.app_ver }),
body: JSON.stringify({
track,
app_ver: versions.value[track].app_ver,
build_ver: versions.value[track].build_ver,
}),
})
if (res.ok) {
const data = await res.json()
versions.value = { app_ver: data.app_ver, build_ver: data.build_ver }
// build_map
buildMap.value[track] = {
...(buildMap.value[track] || {}),
[data.app_ver]: Number((data.build_ver.split('.')[3]) || 0),
}
versions.value[track] = { app_ver: data.app_ver, build_ver: data.build_ver }
alert('版本号已保存')
} else {
const err = await res.json()
@@ -1160,9 +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-danger-icon { background: #fff1f0; color: #ff4d4f; }
.btn-danger-icon:hover { background: #ff4d4f; color: white; }
.skin-update-btn { font-size: 12px; color: #1890ff; cursor: pointer; padding: 2px 8px; border: 1px solid #91d5ff; border-radius: 4px; background: #e6f7ff; line-height: 1.4; }
.skin-update-btn:hover { background: #1890ff; color: white; }
.skin-update-btn.disabled { opacity: 0.5; pointer-events: none; }
.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; }
.btn-outline:hover { background: #e6f7ff; }
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
.skin-upload-btn { margin-top: 8px; }
.skin-upload-btn { margin-top: 8px; transition: background 0.15s, border-color 0.15s; }
.skin-upload-btn.dragover { border-color: #1890ff; border-style: dashed; background: #e6f7ff; color: #1890ff; }
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
</style>
+48 -7
View File
@@ -56,7 +56,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="showQrPreview(task)">
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
<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>
<span v-else class="text-muted">-</span>
</td>
@@ -110,7 +110,7 @@
<span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span>
</div>
<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>
</div>
<div class="meta-item">
@@ -159,7 +159,7 @@
</template>
<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 isAdmin = inject('isAdmin', ref(false))
@@ -182,6 +182,7 @@ const logContainer = ref(null)
const showVerboseLogs = ref(false)
const qrPreview = ref(null)
const showSuperseded = ref(false)
const downloadingIds = reactive({})
let logWs = null
const getAppVersion = (task) => {
@@ -308,11 +309,41 @@ onUnmounted(() => {
if (logWs) { logWs.close(); logWs = null }
})
const downloadDsym = (taskId) => {
window.open(`/api/tasks/${taskId}/dsym`)
const triggerBlobDownload = (blob, filename) => {
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 filenameFromDisposition = (res, fallback) => {
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()
@@ -320,11 +351,19 @@ const downloadIpa = async (taskId) => {
window.open(url, '_blank')
} catch {
alert('下载失败,请稍后重试')
} finally {
delete downloadingIds[taskId]
}
}
const downloadObfMaps = (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
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) => {
@@ -524,6 +563,8 @@ const errorCategoryLabel = (cat) => {
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.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:disabled { color: #999; cursor: default; }
.download-link:disabled:hover { text-decoration: none; }
.text-muted { color: #999; font-size: 13px; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
.qr-thumb:hover { border-color: #1890ff; }
+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"]
def test_create_app_prefixes_tencent_app_id(client, tmp_config):
resp = client.post("/api/config/apps", json={
"name": "腾讯App", "server": "测试环境", "AppGuid": "guid", "tencent": "123456",
})
assert resp.status_code == 200
app_id = resp.json()["id"]
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
def test_create_app_keeps_existing_tencent_prefix(client, tmp_config):
resp = client.post("/api/config/apps", json={
"name": "腾讯App2", "server": "测试环境", "AppGuid": "guid", "tencent": "tencent123456",
})
app_id = resp.json()["id"]
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
def test_update_app_prefixes_tencent_app_id(client, tmp_config):
resp = client.put("/api/config/apps/1", json={
"name": "改名App", "AppGuid": "test-guid", "tencent": " 987654 ",
})
assert resp.status_code == 200
assert client.get("/api/config/apps").json()["1"]["tencent"] == "tencent987654"
def test_update_app(client, tmp_config):
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
assert resp.status_code == 200
@@ -172,7 +197,8 @@ def test_update_build_settings(client, tmp_config):
# ---- Versions ----
def test_update_versions_resets_build_number(client, tmp_config):
def test_update_versions_keeps_per_version_build_number(client, tmp_config):
# 首次设置某版本号,构建号从 .0 起(尚未打包)
resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
assert resp.status_code == 200
assert resp.json()["app_ver"] == "2.196.0"
@@ -180,7 +206,176 @@ def test_update_versions_resets_build_number(client, tmp_config):
assert client.get("/api/config/versions").json()["build_ver"] == "2.196.0.0"
def test_update_versions_does_not_reset_used_build_number(client, tmp_config):
# 模拟 2.195.0 已打到 .5、并切到 2.196.0 后,再回退到 2.195.0
import json
with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f)
cfg["versions"] = {"app_ver": "2.196.0", "build_map": {"2.195.0": 5, "2.196.0": 3}}
with open(tmp_config, "w", encoding="utf-8") as f:
json.dump(cfg, f)
resp = client.put("/api/config/versions", json={"app_ver": "2.195.0"})
assert resp.status_code == 200
# 回退到旧版本号时,构建号保留其自身已用到的最大值,不被重置
assert resp.json()["build_ver"] == "2.195.0.5"
assert client.get("/api/config/versions").json()["build_ver"] == "2.195.0.5"
def test_update_versions_rejects_invalid_app_version(client, tmp_config):
resp = client.put("/api/config/versions", json={"app_ver": "2.196"})
assert resp.status_code == 400
assert "App_Ver" in resp.json()["detail"]
def test_versions_tracks_are_independent(client, tmp_config):
# 每个分支一条轨道(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
+17
View File
@@ -38,6 +38,23 @@ def test_create_task_default_branch(mock_queue, client, tmp_config):
})
assert resp.status_code == 200
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")
+170 -6
View File
@@ -112,6 +112,25 @@ async def test_update_source_existing(tmp_path, log_streamer):
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):
"""fetch 失败时抛异常"""
source_dir = tmp_path / "branches" / "dev"
@@ -136,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"), \
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), \
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"):
build_dir, commit = await prepare_source_snapshot("t1", task)
assert build_dir.exists()
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 ----
@@ -315,6 +349,23 @@ def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
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):
"""从服务配置读取版本号,不依赖分支源码脚本"""
build_dir = tmp_path / "build"
@@ -325,12 +376,9 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
task.scheme_id = "1"
task.build_type = "Ad_Hoc"
task.obfuscation = False
task.branch = "master"
mock_config = {
"apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
"versions": {"app_ver": "3.0.1", "build_ver": "3.0.1.0"},
}
mock_config = _versions_config({"release": _release("3.0.1"), "feature": _feature("9.9.9")})
with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir)
@@ -339,6 +387,122 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
assert result["BUILD_VERSION"] == "3.0.1.0"
async def test_generate_config_appstore_increments_per_version(tmp_path, log_streamer):
"""App_Store 打包时,按版本号在其自身已用构建号上 +1(回退旧版本号亦从自身最大值继续)"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
obfuscation=False, branch="master")
# 2.195.0 已打到 .5,切到 2.196.0 后又回退到 2.195.0
mock_config = _versions_config({
"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):
"""Web 打包不读取分支源码中的 AutoPacking 目录。"""
build_dir = tmp_path / "build"
+14
View File
@@ -66,6 +66,20 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
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):
+73
View File
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
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():
with patch("backend.services.notification.httpx.post") as post:
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"
)
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.mkdir(parents=True)
@@ -42,6 +44,7 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
"wxworkSchema": "wx1234567890abcdef",
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
"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 (build_dir / "exportOptions.plist").exists()
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()
def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config):
"""普通账号管理 Apps版本号,不能读取或修改管理员配置"""
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
"""普通账号只能管理 Apps版本号等其余配置均为管理员专属"""
response = client.post("/api/users", json={
"username": "builder",
"password": "builder-password-123",
@@ -57,10 +57,13 @@ def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_conf
assert client.post("/api/config/apps", json={
"name": "普通用户 App", "server": "测试环境",
}, headers=headers).status_code == 200
assert client.get("/api/config/versions", headers=headers).status_code == 200
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers)
assert versions.status_code == 200
assert versions.json()["build_ver"] == "2.196.0.0"
assert client.get("/api/config/versions", headers=headers).status_code == 403
assert client.put(
"/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers
).status_code == 403
assert client.put(
"/api/config/branches/track", json={"branch": "master", "track": "feature"}, headers=headers
).status_code == 403
assert client.get("/api/config", headers=headers).status_code == 403
assert client.get("/api/config/servers", headers=headers).status_code == 403
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403