12 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
24 changed files with 1423 additions and 307 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu
- `backend/config.py` — loads .env, defines all config constants - `backend/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView - `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI - `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI
- Versions are per-track: `release` (master/develop, build number auto-increments on App_Store) and `feature` (self-test, never increments); each branch's track lives in `config["branch_track"]` - Versions are per-branch: every branch owns a track in `config["versions"]["tracks"]`, named after the branch (master/main keeps the historical name `release`); `config["branch_track"]` maps branch → track name, and each track's `kind` is `release` (build number auto-increments on App_Store) or `feature` (never increments)
- Tags are buildable refs too: `config["tags"]` (names never collide with branches), tracked in `branch_track` like branches (default kind `release`); tasks store the name in `branch` plus `ref_type` (`branch`/`tag`), and tag builds check out `refs/tags/<tag>` detached
## Conventions ## Conventions
+23 -7
View File
@@ -187,21 +187,37 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 | | App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
| Scheme 管理 | 打包 Scheme 配置 | | Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 | | 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道 | | 分支管理 | 可打包的代码分支和 Tag 列表,以及各自的版本轨道类型(上架 / 自测);Tag 打包时以游离 HEAD 检出 `refs/tags/<tag>` |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) | | 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 | | 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
### 版本号与版本轨道 ### 版本号与版本轨道
版本号按 **轨道(track** 分开管理,两条轨道的 App_Ver 各自独立填写、互不影响 **每个分支各有一条版本轨道(track**,App_Ver 与构建号完全独立、互不影响。轨道名即分支名(master / main 沿用历史名称 `release`),轨道类型 `kind` 决定构建号行为
| 轨道 | 适用分支 | 构建号行为 | | 轨道类型 | 默认适用分支 | 构建号行为 |
|------|----------|-----------| |----------|--------------|-----------|
| `release`(上架) | master、develop(共用同一套版本号与构建号序列) | App_Store 打包时按当前 App_Ver 自动 +1 | | `release`(上架) | master、main、develop、dev、release-*、hotfix-* | App_Store 打包时按当前 App_Ver 自动 +1 |
| `feature`(自测) | feature/* 等其他分支 | 永不自增,始终使用配置页填写的值(默认 `.0` | | `feature`(自测) | 其余分支 | 永不自增,始终使用配置页填写的值(默认 `.0` |
- 分支归属默认按名字推断(master/main/develop/dev/release-*/hotfix-* → release,其余 → feature),可在「分支管理」页逐个改。 ```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_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。
- App Store 的构建号唯一性按 App_Ver 全局判定,因此两个上架分支若临时用了同一个 App_Ver,递增时会跨轨道取最大值 +1,避免重号被拒。
- 旧配置(master/develop 共用的 `release` 轨、各自测分支共用的 `feature` 轨)首次启动时自动拆开:master 留在 `release` 轨,其余分支新建同名轨道并复制一份已用构建号。
- 需要两个分支共用同一条版本线时,可直接把 `branch_track` 里的值指向另一条轨道名。
### 服务端自动化资源 ### 服务端自动化资源
+2
View File
@@ -23,8 +23,10 @@ def _migrate_db():
"""简单的数据库迁移:为旧表添加缺失的列""" """简单的数据库迁移:为旧表添加缺失的列"""
migrations = [ migrations = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "ref_type", "VARCHAR DEFAULT 'branch'"),
("tasks", "error_category", "VARCHAR"), ("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"), ("tasks", "superseded_at", "TIMESTAMP"),
("tasks", "dsym_url", "VARCHAR"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
+3 -1
View File
@@ -15,7 +15,8 @@ class Task(Base):
scheme_id = Column(String, nullable=False) scheme_id = Column(String, nullable=False)
scheme_name = Column(String, nullable=False) scheme_name = Column(String, nullable=False)
obfuscation = Column(Boolean, default=False) obfuscation = Column(Boolean, default=False)
branch = Column(String, default="main") branch = Column(String, default="main") # 分支名或 Tag 名,由 ref_type 区分
ref_type = Column(String, default="branch") # branch/tag
# 状态 # 状态
status = Column(String, default="pending") # pending/running/completed/failed/cancelled status = Column(String, default="pending") # pending/running/completed/failed/cancelled
@@ -32,6 +33,7 @@ class Task(Base):
ipa_path = Column(String) ipa_path = Column(String)
oss_url = Column(String) oss_url = Column(String)
dsym_path = Column(String) dsym_path = Column(String)
dsym_url = Column(String)
obfuscation_maps_path = Column(String) obfuscation_maps_path = Column(String)
qr_code_path = Column(String) qr_code_path = Column(String)
build_dir = Column(String) build_dir = Column(String)
+7 -1
View File
@@ -1,7 +1,7 @@
"""打包选择 API""" """打包选择 API"""
from fastapi import APIRouter from fastapi import APIRouter
from .config import load_config from .config import get_tags, load_config
router = APIRouter(prefix="/api", tags=["apps"]) router = APIRouter(prefix="/api", tags=["apps"])
@@ -44,3 +44,9 @@ async def get_branches_for_build():
"""获取分支列表(供打包选择)""" """获取分支列表(供打包选择)"""
config = load_config() config = load_config()
return config.get("branches", ["main"]) return config.get("branches", ["main"])
@router.get("/tags")
async def get_tags_for_build():
"""获取 Tag 列表(供打包选择)"""
return get_tags(load_config())
+327 -76
View File
@@ -58,6 +58,8 @@ DEFAULT_UPLOAD = {
"enabled": False, "enabled": False,
"webhook_url": "", "webhook_url": "",
"secret": "", "secret": "",
"at_mobiles": [],
"at_mobiles_app_store": [],
}, },
} }
@@ -91,74 +93,180 @@ DEFAULT_SERVERS = {
DEFAULT_APP_VER = "2.180.0" DEFAULT_APP_VER = "2.180.0"
# 版本轨道:上架分支(master/develop)共用 release,自测分支(feature/*)用 feature。 # 版本轨道:每个分支各有一条独立轨道,轨道名即分支名(master/main 沿用历史名称 release),
# 两条轨道的 App_Ver 各自独立填写,互不影响。 # 各分支的 App_Ver 与构建号互不影响。轨道类型(kind)决定构建号行为:
TRACK_RELEASE = "release" # release(上架)— App_Store 打包时自增;feature(自测)— 永不自增。
TRACK_FEATURE = "feature" KIND_RELEASE = "release"
TRACKS = (TRACK_RELEASE, TRACK_FEATURE) KIND_FEATURE = "feature"
KINDS = (KIND_RELEASE, KIND_FEATURE)
TRACK_RELEASE = "release" # master/main 的轨道名(沿用旧名,历史构建号原地保留)
TRACK_FEATURE = "feature" # 旧版所有自测分支共用的轨道名,迁移时按分支拆开
FIXED_TRACKS = (TRACK_RELEASE, TRACK_FEATURE)
# 按名字推断轨道的默认规则;config["branch_track"] 中的显式配置优先 # 按名字推断轨道类型的默认规则;轨道记录里的 kind 为准,此处只用于新建轨道
RELEASE_BRANCH_NAMES = {"master", "main", "develop", "dev"} MASTER_BRANCH_NAMES = {"master", "main"}
RELEASE_BRANCH_NAMES = MASTER_BRANCH_NAMES | {"develop", "dev"}
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-") 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: def _default_track_for_branch(branch: str) -> str:
"""分支名推断所属版本轨道。""" """分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。
name = (branch or "").strip().lower()
if name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES): 分支恰好叫 release / feature(且不是 master)时加前缀避让,免得撞上固定轨道名。
"""
if (branch or "").strip().lower() in MASTER_BRANCH_NAMES:
return TRACK_RELEASE return TRACK_RELEASE
return TRACK_FEATURE return f"branch:{branch}" if branch in FIXED_TRACKS else branch
def _new_track(app_ver: str = DEFAULT_APP_VER) -> dict: def _new_track(app_ver: str = DEFAULT_APP_VER, kind: str = KIND_RELEASE) -> dict:
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。 # 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算, # App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。 # 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
return {"app_ver": app_ver, "build_map": {}} return {"kind": kind, "app_ver": app_ver, "build_map": {}}
def _ensure_versions(config: dict) -> bool: def _seed_track(tracks: dict, kind: str, legacy: dict = None) -> dict:
"""确保 versions 为双轨结构,并迁移旧式单轨/单值 build_ver 配置。 """新建轨道的初值:优先沿用被拆分的旧共用轨(含已用构建号),
否则沿用任一同类型轨道的 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)
旧式 ``{"app_ver": ..., "build_map": ...}`` 整体迁入 release 轨;
feature 轨用同一个 App_Ver 初始化,构建号从 .0 起且永不自增。
"""
versions = config.get("versions")
changed = False
if not isinstance(versions, dict): def _ensure_track(config: dict, track_name: str, kind: str = None) -> dict:
config["versions"] = {"tracks": {TRACK_RELEASE: _new_track(), TRACK_FEATURE: _new_track()}} """确保轨道存在(新建时沿用同类型轨道的 App_Ver,构建号从 .0 起);
return True 显式传 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
tracks = versions.get("tracks")
if not isinstance(tracks, dict): def _default_kind_for_track(track_name: str) -> str:
# 旧式单轨结构 → release 轨 """轨道记录缺 kind 时按轨道名推断(轨道名即分支名)。"""
legacy_app_ver = versions.get("app_ver") or DEFAULT_APP_VER if track_name == TRACK_RELEASE:
legacy_build_map = versions.get("build_map") return KIND_RELEASE
if not isinstance(legacy_build_map, dict): if track_name == TRACK_FEATURE:
legacy_build_map = {} 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} # 更旧的单值形式 "2.195.0.3" → {"2.195.0": 3}
old = versions.get("build_ver") old = versions.get("build_ver")
if isinstance(old, str): if isinstance(old, str):
parts = old.split(".") parts = old.split(".")
if len(parts) >= 4 and parts[3].isdigit(): if len(parts) >= 4 and parts[3].isdigit():
legacy_build_map[".".join(parts[:3])] = int(parts[3]) build_map[".".join(parts[:3])] = int(parts[3])
versions.pop("app_ver", None) versions.pop("app_ver", None)
versions.pop("build_map", None) versions.pop("build_map", None)
versions.pop("build_ver", None) versions.pop("build_ver", None)
tracks = { return {"app_ver": app_ver, "build_map": build_map}
TRACK_RELEASE: {"app_ver": legacy_app_ver, "build_map": legacy_build_map},
TRACK_FEATURE: _new_track(legacy_app_ver),
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 {},
} }
versions["tracks"] = tracks
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 changed = True
for name in TRACKS: # 待拆分到各分支的旧共用轨(按类型各一条)
track = tracks.get(name) 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): if not isinstance(track, dict):
tracks[name] = _new_track() 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 changed = True
continue
if not track.get("app_ver"): if not track.get("app_ver"):
track["app_ver"] = DEFAULT_APP_VER track["app_ver"] = DEFAULT_APP_VER
changed = True changed = True
@@ -166,33 +274,111 @@ def _ensure_versions(config: dict) -> bool:
track["build_map"] = {} track["build_map"] = {}
changed = True changed = True
# 分支归属:只存显式配置,缺省按名字规则推断 # 分支归属:值为轨道名,缺省用分支自己的轨道
branch_track = config.get("branch_track") branch_track = config.get("branch_track")
if not isinstance(branch_track, dict): if not isinstance(branch_track, dict):
branch_track = {} branch_track = {}
config["branch_track"] = branch_track config["branch_track"] = branch_track
changed = True changed = True
for branch in config.get("branches", []) or []: for branch in _build_refs(config):
if branch_track.get(branch) not in TRACKS: name = branch_track.get(branch)
branch_track[branch] = _default_track_for_branch(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 changed = True
return changed 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: def get_track_name(config: dict, branch: str) -> str:
"""取分支所属的版本轨道名。""" """取分支所属的版本轨道名。"""
track = (config.get("branch_track") or {}).get(branch) name = (config.get("branch_track") or {}).get(branch)
return track if track in TRACKS else _default_track_for_branch(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: def get_track(config: dict, track_name: str) -> dict:
"""取某条轨道的版本记录(不存在时返回默认值,不写回配置)。""" """取某条轨道的版本记录副本(不存在时返回默认值;只读,不写回配置)。
未迁移的旧记录没有 kind,按轨道名(即分支名)推断补上。
"""
tracks = (config.get("versions") or {}).get("tracks") or {} tracks = (config.get("versions") or {}).get("tracks") or {}
track = tracks.get(track_name) track = tracks.get(track_name)
kind = _default_kind_for_track(track_name)
if not isinstance(track, dict): if not isinstance(track, dict):
return _new_track() return _new_track(kind=kind)
return track 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: def track_build_ver(track: dict) -> str:
@@ -201,6 +387,7 @@ def track_build_ver(track: dict) -> str:
build_map = track.get("build_map") or {} build_map = track.get("build_map") or {}
return f"{app_ver}.{int(build_map.get(app_ver, 0))}" return f"{app_ver}.{int(build_map.get(app_ver, 0))}"
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。 # 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
LEGACY_SERVER_PREFIXES = { LEGACY_SERVER_PREFIXES = {
"测试环境": 1, "测试环境": 1,
@@ -300,8 +487,14 @@ def _ensure_upload_keys(config: dict) -> bool:
def _migrate_old_themes(config: dict) -> bool: def _migrate_old_themes(config: dict) -> bool:
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更""" """将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更
changed = False
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
"""
if config.get("_legacy_themes_migrated"):
return False
themes_dir = AUTOMATION_DIR / "themes" themes_dir = AUTOMATION_DIR / "themes"
for app_id, app in config.get("apps", {}).items(): for app_id, app in config.get("apps", {}).items():
upload_key = app.get("upload_key", "") upload_key = app.get("upload_key", "")
@@ -336,8 +529,8 @@ def _migrate_old_themes(config: dict) -> bool:
app["skins"] = skins app["skins"] = skins
if theme != skin_name: if theme != skin_name:
cert["theme"] = skin_name cert["theme"] = skin_name
changed = True config["_legacy_themes_migrated"] = True
return changed return True
def load_config() -> dict: def load_config() -> dict:
@@ -644,43 +837,50 @@ async def get_branches():
@router.post("/branches") @router.post("/branches")
async def add_branch(data: dict): async def add_branch(data: dict):
"""新增分支,可指定所属版本轨道(缺省按分支名推断)""" """新增分支,可指定版本轨道类型(缺省按分支名推断)
每个分支都有自己独立的一条轨道(轨道名即分支名):track 传 release 表示上架用、
App_Store 打包时构建号自增,feature 表示自测用、构建号不自增;
也可直接传已有轨道名,让该分支与另一个分支共用同一条版本线。
"""
name = data.get("name", "").strip() name = data.get("name", "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="分支名称不能为空") raise HTTPException(status_code=400, detail="分支名称不能为空")
track = (data.get("track") or "").strip() or _default_track_for_branch(name)
if track not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
branches = config.get("branches", ["main"]) branches = config.get("branches", ["main"])
if name in branches: if name in branches:
raise HTTPException(status_code=400, detail="分支已存在") raise HTTPException(status_code=400, detail="分支已存在")
if name in get_tags(config):
raise HTTPException(status_code=400, detail="已存在同名 Tag")
track, kind = resolve_track(config, name, data.get("track"))
branches.append(name) branches.append(name)
config["branches"] = branches config["branches"] = branches
config.setdefault("branch_track", {})[name] = track config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track, kind)
save_config(config) save_config(config)
return {"message": "分支已添加", "track": track} return {"message": "分支已添加", "track": track}
@router.put("/branches/track") @router.put("/branches/track")
async def update_branch_track(data: dict): async def update_branch_track(data: dict):
"""修改分支所属的版本轨道(release=上架,feature=自测)。 """修改分支或 Tag 的版本轨道类型release=上架,构建号自增;feature=自测,不自增)。
每个分支用自己的一条轨道,切换类型只改这条轨道的行为,版本号仍与其它分支互不影响。
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。 分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
""" """
branch = (data.get("branch") or "").strip() branch = (data.get("branch") or "").strip()
track = (data.get("track") or "").strip() if not (data.get("track") or "").strip():
if track not in TRACKS: raise HTTPException(status_code=400, detail="未指定版本轨道")
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
if branch not in config.get("branches", []): if branch not in _build_refs(config):
raise HTTPException(status_code=404, detail="分支不存在") raise HTTPException(status_code=404, detail="分支不存在")
track, kind = resolve_track(config, branch, data.get("track"))
config.setdefault("branch_track", {})[branch] = track config.setdefault("branch_track", {})[branch] = track
_ensure_track(config, track, kind)
save_config(config) save_config(config)
return {"message": "分支版本轨道已更新", "branch": branch, "track": track} return {"message": "分支版本轨道已更新", "branch": branch, "track": track}
@@ -700,6 +900,51 @@ async def delete_branch(branch_name: str):
return {"message": "分支已删除"} return {"message": "分支已删除"}
# Tag 管理 API:打包时检出指定 Tag 的代码,版本轨道与分支一样按名字各自独立
@router.get("/tags")
async def get_tags_list():
"""获取 Tag 列表"""
return get_tags(load_config())
@router.post("/tags")
async def add_tag(data: dict):
"""新增 Tag,可指定版本轨道类型(缺省为上架轨,App_Store 打包构建号自增)。"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag 名称不能为空")
async with _config_lock:
config = load_config()
tags = get_tags(config)
if name in tags:
raise HTTPException(status_code=400, detail="Tag 已存在")
if name in config.get("branches", []):
raise HTTPException(status_code=400, detail="已存在同名分支")
track, kind = resolve_track(config, name, data.get("track"), default_kind=KIND_RELEASE)
tags.append(name)
config["tags"] = tags
config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track, kind)
save_config(config)
return {"message": "Tag 已添加", "track": track}
@router.delete("/tags/{tag_name:path}")
async def delete_tag(tag_name: str):
"""删除 Tag"""
async with _config_lock:
config = load_config()
tags = get_tags(config)
if tag_name not in tags:
raise HTTPException(status_code=404, detail="Tag 不存在")
tags.remove(tag_name)
config["tags"] = tags
config.get("branch_track", {}).pop(tag_name, None)
save_config(config)
return {"message": "Tag 已删除"}
@router.get("/build") @router.get("/build")
async def get_build_settings(db: Session = Depends(get_db)): async def get_build_settings(db: Session = Depends(get_db)):
"""获取打包设置""" """获取打包设置"""
@@ -747,27 +992,32 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
# 版本号配置 API # 版本号配置 API
@router.get("/versions") @router.get("/versions")
async def get_versions(): async def get_versions():
"""获取条版本轨道各自的 App_Ver 及其当前构建号。 """获取条版本轨道的 App_Ver 及其当前构建号。
每个上架分支(master / develop 等)一条独立轨道,轨道名即分支名;自测分支共用 feature 轨。
build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0), build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
仅供展示;实际递增只发生在 release 轨的 App_Store 打包时,feature 轨永不自增。 仅供展示;实际递增只发生在上架轨的 App_Store 打包时,feature 轨永不自增。
顶层 app_ver / build_ver 为 release 轨的镜像,保留给旧客户端。 顶层 app_ver / build_ver 为主上架轨的镜像,保留给旧客户端。
""" """
config = load_config() config = load_config()
tracks = {} tracks = {
for name in TRACKS: name: {
track = get_track(config, name) "kind": track.get("kind") if track.get("kind") in KINDS else _default_kind_for_track(name),
tracks[name] = {
"app_ver": track.get("app_ver") or DEFAULT_APP_VER, "app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_ver": track_build_ver(track), "build_ver": track_build_ver(track),
"build_map": track.get("build_map") or {}, "build_map": track.get("build_map") or {},
} }
for name, track in ((config.get("versions") or {}).get("tracks") or {}).items()
if isinstance(track, dict)
}
primary = _primary_track_name(config)
return { return {
**tracks[TRACK_RELEASE], **{k: v for k, v in tracks.get(primary, {}).items() if k != "kind"},
"tracks": tracks, "tracks": tracks,
"primary_track": primary,
"branch_track": { "branch_track": {
branch: get_track_name(config, branch) branch: get_track_name(config, branch)
for branch in config.get("branches", []) or [] for branch in _build_refs(config)
}, },
} }
@@ -779,14 +1029,12 @@ async def update_versions(data: dict):
每个 App_Ver 的构建号在所属轨道内独立保存于 build_map 各自递增,因此来回切换 每个 App_Ver 的构建号在所属轨道内独立保存于 build_map 各自递增,因此来回切换
版本号(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。 版本号(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
- trackreleasemaster/develop,上架用)或 feature(自测用),缺省 release - track轨道名,上架分支为分支名(如 masterdevelop),自测为 feature;缺省为主上架轨
- 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。 - 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号; - 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。 直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
""" """
track_name = (data.get("track") or TRACK_RELEASE).strip() track_name = (data.get("track") or "").strip()
if track_name not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track_name}")
app_ver = data.get("app_ver", "").strip() app_ver = data.get("app_ver", "").strip()
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver): if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
@@ -806,7 +1054,10 @@ async def update_versions(data: dict):
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
tracks = config.setdefault("versions", {}).setdefault("tracks", {}) tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track = tracks.setdefault(track_name, _new_track()) 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 track["app_ver"] = app_ver
build_map = track.setdefault("build_map", {}) build_map = track.setdefault("build_map", {})
if build_no is not None: if build_no is not None:
+67 -9
View File
@@ -3,6 +3,7 @@ import os
import json import json
import shutil import shutil
import tempfile import tempfile
import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import List from typing import List
@@ -18,6 +19,10 @@ from ..deps import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
_recent_download_logs: dict[str, float] = {}
@router.post("", response_model=TaskResponse) @router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)): 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)}", detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
) )
# 分支与 Tag 不允许重名,按名字即可确定要检出的引用类型
from .config import get_ref_type
ref_type = get_ref_type(config, task.branch)
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
db_task = Task( db_task = Task(
id=task_id, id=task_id,
@@ -60,12 +69,13 @@ async def create_task(task: TaskCreate, request: Request, user: dict = Depends(g
scheme_name=scheme.get("displayName") or scheme_name, scheme_name=scheme.get("displayName") or scheme_name,
obfuscation=task.obfuscation, obfuscation=task.obfuscation,
branch=task.branch, branch=task.branch,
ref_type=ref_type,
status="pending", status="pending",
) )
db.add(db_task) db.add(db_task)
from ..services.audit_log import record_operation from ..services.audit_log import record_operation
record_operation(db, user, "build_created", request=request, task=db_task, 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.commit()
db.refresh(db_task) 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: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
# dSYM 压缩包的远端对象键与 IPA 独立,同一版本/分支重复打包会复用并覆盖同一个远端文件,
# 仍有其他历史记录引用时不能删除。
has_shared_dsym = bool(
task.dsym_url and db.query(Task).filter(
Task.id != task.id,
Task.dsym_url == task.dsym_url,
).first()
)
if task.dsym_url and not has_shared_dsym:
if not task.config_json:
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端 dSYM 文件")
try:
build_config = json.loads(task.config_json)
from .config import load_config
from ..services.distribution import DistributionError, delete_published_dsym
delete_published_dsym(build_config, load_config().get("upload", {}))
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端 dSYM 删除失败,记录未删除:{exc}") from exc
# 删除日志文件 # 删除日志文件
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists(): if log_path.exists():
@@ -276,6 +305,17 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
) )
except (json.JSONDecodeError, DistributionError) as exc: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
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 from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task, record_operation(db, user, "ipa_download_requested", request=request, task=task,
resource_type="ipa", resource_name=task.oss_url, resource_type="ipa", resource_name=task.oss_url,
@@ -286,7 +326,7 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
@router.get("/{task_id}/dsym") @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)): async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载 dSYM 文件""" """下载 dSYM 文件:本地文件已被清理时回退到构建时上传保存的远端地址"""
import shutil import shutil
import zipfile import zipfile
import tempfile import tempfile
@@ -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() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
db.commit()
dsym_path = task.dsym_path dsym_path = task.dsym_path
if not os.path.exists(dsym_path): local_exists = bool(dsym_path) and os.path.exists(dsym_path)
if not local_exists and not task.dsym_url:
if not dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
raise HTTPException(status_code=404, detail="dSYM 文件已被清理") raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
from ..services.audit_log import record_operation
resource_name = os.path.basename(dsym_path) if dsym_path else os.path.basename(task.dsym_url)
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=resource_name)
db.commit()
if not local_exists:
# dSYM 需要携带认证信息才能访问该接口,浏览器无法直接跳转到本接口的
# 302 目标,因此改为返回 JSON 让前端自行发起下载。
try:
from .config import load_config
from ..services.distribution import DistributionError, get_published_dsym_url
download_url = get_published_dsym_url(
json.loads(task.config_json or "{}"),
load_config().get("upload", {}),
task.dsym_url,
)
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
return {"url": download_url}
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载 # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
if os.path.isdir(dsym_path): if os.path.isdir(dsym_path):
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip") zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
+2
View File
@@ -22,6 +22,7 @@ class TaskResponse(BaseModel):
scheme_name: str scheme_name: str
obfuscation: bool obfuscation: bool
branch: str branch: str
ref_type: Optional[str] = "branch"
status: str status: str
current_step: Optional[str] current_step: Optional[str]
created_at: Optional[datetime] created_at: Optional[datetime]
@@ -31,6 +32,7 @@ class TaskResponse(BaseModel):
ipa_path: Optional[str] ipa_path: Optional[str]
oss_url: Optional[str] oss_url: Optional[str]
dsym_path: Optional[str] dsym_path: Optional[str]
dsym_url: Optional[str] = None
obfuscation_maps_path: Optional[str] obfuscation_maps_path: Optional[str]
qr_code_path: Optional[str] qr_code_path: Optional[str]
build_dir: Optional[str] build_dir: Optional[str]
+63 -26
View File
@@ -23,7 +23,7 @@ from ..config import (
mask_git_remote_url, mask_git_remote_url,
) )
from .log_streamer import log_streamer from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa from .distribution import DistributionError, publish_dsym, publish_ipa
from .notification import NotificationError, send_dingtalk_notification from .notification import NotificationError, send_dingtalk_notification
from .project_patcher import ProjectPatchError, apply_project_config from .project_patcher import ProjectPatchError, apply_project_config
@@ -291,8 +291,8 @@ def _db_update(db, task, **fields):
db.commit() db.commit()
async def update_source(task_id: str, source_dir: Path, branch: str): async def update_source(task_id: str, source_dir: Path, branch: str, ref_type: str = "branch"):
"""将共享源码工作目录强制同步到指定远程分支。""" """将共享源码工作目录强制同步到指定远程分支或 Tagref_type="tag" 时 branch 为 Tag 名)"""
remote_url = get_git_remote_url(with_credentials=True) remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url) remote_url_masked = mask_git_remote_url(remote_url)
git_env = _git_env() git_env = _git_env()
@@ -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(): if not cloned and not (source_dir / ".git").exists():
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}") raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}") is_tag = ref_type == "tag"
await log_streamer.emit(task_id, f"同步共享源码到{'Tag' if is_tag else '分支'}: {branch}")
if remote_url: if remote_url:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@@ -333,6 +334,16 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if process.returncode != 0: if process.returncode != 0:
raise Exception("git remote set-url 失败") raise Exception("git remote set-url 失败")
if is_tag:
# Tag 检出为游离 HEAD--force 让远端被移动过的 Tag 覆盖本地旧 Tag
tag_ref = f"refs/tags/{branch}"
commands = [
(("git", "fetch", "origin", "--tags", "--force"), "git fetch 失败"),
(("git", "checkout", "--detach", tag_ref), f"git checkout Tag {branch} 失败"),
(("git", "reset", "--hard", tag_ref), f"git reset Tag {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
else:
commands = [ commands = [
(("git", "fetch", "origin"), "git fetch 失败"), (("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"), (("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
@@ -375,7 +386,7 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
source_dir = get_source_dir(task.branch) source_dir = get_source_dir(task.branch)
if not source_dir.exists(): if not source_dir.exists():
raise Exception( raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL" f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支(或 Tag代码,或配置 GIT_REMOTE_URL"
) )
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}") await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
return await copy_source_code(task_id, task, source_dir), "" return await copy_source_code(task_id, task, source_dir), ""
@@ -384,7 +395,8 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
await log_streamer.emit(task_id, "等待共享源码准备队列...") await log_streamer.emit(task_id, "等待共享源码准备队列...")
async with source_prepare_lock: async with source_prepare_lock:
await log_streamer.emit(task_id, "开始准备共享源码快照") await log_streamer.emit(task_id, "开始准备共享源码快照")
await update_source(task_id, source_dir, task.branch) ref_type = "tag" if task.ref_type == "tag" else "branch"
await update_source(task_id, source_dir, task.branch, ref_type)
commit = await get_source_commit(source_dir) commit = await get_source_commit(source_dir)
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}") await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
build_dir = await copy_source_code(task_id, task, source_dir) build_dir = await copy_source_code(task_id, task, source_dir)
@@ -433,6 +445,7 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, current_step="config") await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir) config_data = await generate_config(task_id, task, build_dir)
config_data["SOURCE_BRANCH"] = task.branch config_data["SOURCE_BRANCH"] = task.branch
config_data["SOURCE_REF_TYPE"] = "tag" if task.ref_type == "tag" else "branch"
config_data["SOURCE_COMMIT"] = source_commit config_data["SOURCE_COMMIT"] = source_commit
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False)) config_json=json.dumps(config_data, ensure_ascii=False))
@@ -455,7 +468,7 @@ async def run_build_task(task_id: str):
ipa_path = await build_project(task_id, task, config_data, build_dir) ipa_path = await build_project(task_id, task, config_data, build_dir)
# 查找 dSYM # 查找 dSYM
dsym_path = await find_dsym(task_id, build_dir) dsym_path = await find_dsym(task_id, build_dir, config_data.get("SCHEME", ""))
# 查找混淆映射表 # 查找混淆映射表
obf_maps_path = build_dir / "obfuscation_maps" obf_maps_path = build_dir / "obfuscation_maps"
@@ -477,6 +490,13 @@ async def run_build_task(task_id: str):
upload_fields["oss_url"] = oss_url upload_fields["oss_url"] = oss_url
if qr_code_path: if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path upload_fields["qr_code_path"] = qr_code_path
# dSYM 用于后续崩溃符号化,按上传配置一并保存到远端;失败不影响本次打包结果。
if dsym_path:
dsym_url = await upload_dsym(task_id, task, config_data, dsym_path, build_dir)
if dsym_url:
upload_fields["dsym_url"] = dsym_url
if upload_fields: if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields) await asyncio.to_thread(_db_update, db, task, **upload_fields)
@@ -491,8 +511,8 @@ async def run_build_task(task_id: str):
if task.oss_url: if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。 # Ad Hoc 发安装页二维码通知,App Store IPA 下载地址通知;
if task.build_type == "Ad_Hoc": # 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。 # 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {}) dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
try: try:
@@ -603,7 +623,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
from ..routers.config import ( from ..routers.config import (
load_config, _config_lock, get_track, get_track_name, track_build_ver, load_config, _config_lock, get_track, get_track_name, track_build_ver,
TRACK_RELEASE, _new_track, is_release_track, next_build_no, _ensure_track,
) )
config = load_config() config = load_config()
@@ -614,29 +634,29 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
scheme = schemes.get(task.scheme_id, {}) scheme = schemes.get(task.scheme_id, {})
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。 # 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
# 按分支所属轨道取版本号:master/develop 走 release 轨(上架用),feature 走 feature 轨(自测用) # 按分支所属轨道取版本号:每个上架分支(masterdevelop 等)一条独立轨道
# 两条轨道的 App_Ver 各自独立 # 自测分支共用 feature 轨,各轨道的 App_Ver 互不影响
track_name = get_track_name(config, task.branch) track_name = get_track_name(config, task.branch)
track = get_track(config, track_name) track = get_track(config, track_name)
app_ver = track.get("app_ver") or "2.180.0" app_ver = track.get("app_ver") or "2.180.0"
# 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。 # 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
build_ver = track_build_ver(track) build_ver = track_build_ver(track)
is_release = is_release_track(track)
await log_streamer.emit( await log_streamer.emit(
task_id, task_id,
f"版本轨道: {track_name}{'上架,App_Store 打包自增' if track_name == TRACK_RELEASE else '自测,构建号不自增'}", f"版本轨道: {track_name}{'上架,App_Store 打包自增' if is_release else '自测,构建号不自增'}",
) )
# release 轨的 App_Store 打包,在该版本号自己的构建号上 +1 # 上架轨的 App_Store 打包,在该版本号的构建号上 +1(回退到旧版本号也从它自己的最大值继续);
# (回退到旧版本号也从它自己的最大值继续);feature 轨始终使用手动设定的构建号。 # feature 轨始终使用手动设定的构建号。
if track_name == TRACK_RELEASE and task.build_type == "App_Store": if is_release and task.build_type == "App_Store":
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
tracks = config.setdefault("versions", {}).setdefault("tracks", {}) track = _ensure_track(config, track_name)
track = tracks.setdefault(track_name, _new_track(app_ver)) track.setdefault("app_ver", app_ver)
build_map = track.setdefault("build_map", {}) n = next_build_no(config["versions"]["tracks"], app_ver)
n = int(build_map.get(app_ver, 0)) + 1 track.setdefault("build_map", {})[app_ver] = n
build_map[app_ver] = n
build_ver = f"{app_ver}.{n}" build_ver = f"{app_ver}.{n}"
from ..routers.config import save_config from ..routers.config import save_config
@@ -715,7 +735,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 关联域名等配置 # 关联域名等配置
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "") config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
for key in ["weixinlogin", "weixinpay", "tencent", "UniversalLink", "AlivcLicenseKey"]: for key in ["weixinlogin", "weixinpay", "wxworkSchema", "tencent", "UniversalLink", "AlivcLicenseKey"]:
if key in app: if key in app:
config_data[key] = app[key] config_data[key] = app[key]
@@ -914,16 +934,23 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
return ipa_path return ipa_path
async def find_dsym(task_id: str, build_dir: Path) -> Path: async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path:
"""查找 dSYM 文件""" """查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM"""
derived_data = build_dir / "build" / "derived_data" derived_data = build_dir / "build" / "derived_data"
if not derived_data.exists(): if not derived_data.exists():
return None return None
for dsym in derived_data.rglob("*.dSYM"): dsyms = list(derived_data.rglob("*.dSYM"))
return dsym if not dsyms:
return None return None
if scheme:
for dsym in dsyms:
if dsym.name == f"{scheme}.app.dSYM":
return dsym
return dsyms[0]
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple: async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
"""上传 IPA,返回 (download_url, qr_code_path)""" """上传 IPA,返回 (download_url, qr_code_path)"""
@@ -938,3 +965,13 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil
return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir) return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir)
except DistributionError as exc: except DistributionError as exc:
raise BuildError(str(exc), category="upload") from exc raise BuildError(str(exc), category="upload") from exc
async def upload_dsym(task_id: str, task, config_data: dict, dsym_path: Path, build_dir: Path) -> str:
"""上传 dSYM,返回下载 URL;上传失败仅记录警告,不影响已完成的打包结果"""
await log_streamer.emit_step(task_id, "上传 dSYM")
try:
return await asyncio.to_thread(publish_dsym, config_data, dsym_path, build_dir)
except DistributionError as exc:
await log_streamer.emit(task_id, f"dSYM 上传失败: {exc}", level="warn")
return ""
+193 -13
View File
@@ -4,6 +4,7 @@ import plistlib
import posixpath import posixpath
import re import re
import shutil import shutil
from html import escape
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
@@ -54,18 +55,149 @@ def _write_manifest(config: dict, manifest: Path, ipa_url: str):
plistlib.dump(data, f) plistlib.dump(data, f)
DOWNLOAD_PAGE_TEMPLATE = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="format-detection" content="telephone=no">
<title>{title} - 安装</title>
<style>
:root {{
--bg: #f2f3f7;
--card: #ffffff;
--text: #1c1c1e;
--muted: #8a8a8e;
--line: rgba(60, 60, 67, .12);
--brand: #50a0ff;
}}
@media (prefers-color-scheme: dark) {{
:root {{
--bg: #000000;
--card: #1c1c1e;
--text: #f5f5f7;
--muted: #98989d;
--line: rgba(255, 255, 255, .12);
}}
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
padding: 10vh 20px calc(32px + env(safe-area-inset-bottom));
display: flex;
justify-content: center;
align-items: flex-start;
background: var(--bg);
color: var(--text);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
}}
.card {{
width: 100%;
max-width: 420px;
background: var(--card);
border-radius: 20px;
padding: 28px 24px 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, .08);
text-align: center;
}}
.name {{
margin: 0;
font-size: 24px;
font-weight: 600;
word-break: break-word;
}}
.version {{
margin: 6px 0 22px;
color: var(--muted);
font-size: 15px;
}}
.install {{
display: block;
padding: 15px;
border-radius: 14px;
background: var(--brand);
color: #fff;
font-size: 17px;
font-weight: 600;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}}
.install:active {{ opacity: .75; }}
.meta {{
margin: 22px 0 0;
padding-top: 16px;
border-top: 1px solid var(--line);
text-align: left;
font-size: 14px;
}}
.meta div {{
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
}}
.meta dt {{ color: var(--muted); flex: none; }}
.meta dd {{
margin: 0;
text-align: right;
word-break: break-all;
font-variant-numeric: tabular-nums;
}}
.tips {{
margin: 18px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.7;
text-align: left;
}}
</style>
</head>
<body>
<div class="card">
<h1 class="name">{title}</h1>
<p class="version">版本 {version}</p>
<a class="install" href="{install_url}">安装 App</a>
<dl class="meta">{meta}</dl>
<p class="tips">请使用 iPhone 自带的 Safari 浏览器打开本页面安装。<br>
安装后如提示“未受信任的开发者”,请前往「设置 → 通用 → VPN 与设备管理」信任该描述文件。</p>
</div>
</body>
</html>
"""
def _write_download_page(config: dict, html: Path, manifest_url: str): def _write_download_page(config: dict, html: Path, manifest_url: str):
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}" install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}"
title = config.get("APPID_NAME", "iOS App") title = config.get("APPID_NAME") or "iOS App"
meta_items = [
("构建版本", config.get("BUILD_VERSION", "")),
("Bundle ID", config.get("BUNDLE_ID", "")),
("Tag" if config.get("SOURCE_REF_TYPE") == "tag" else "分支", config.get("SOURCE_BRANCH", "")),
]
meta = "".join(
f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
for label, value in meta_items
if value
)
html.write_text( html.write_text(
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">" DOWNLOAD_PAGE_TEMPLATE.format(
f"<title>{title}</title><body><h2>{title}</h2>" title=escape(title),
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>" version=escape(str(config.get("VERSION", ""))),
"</body></html>", install_url=escape(install_url, quote=True),
meta=meta,
),
encoding="utf-8", encoding="utf-8",
) )
def _dsym_remote_path(config: dict) -> str:
stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
return posixpath.join(folder, "iOS", f"{stem}.dSYM.zip")
def _remote_paths(config: dict) -> tuple[str, str, str, str]: def _remote_paths(config: dict) -> tuple[str, str, str, str]:
stem = _artifact_stem(config) stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds" folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
@@ -169,14 +301,21 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths) deleter(upload_config, remote_paths)
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str: def delete_published_dsym(build_config: dict, upload_config: dict):
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。""" """删除任务对应的远端 dSYM 压缩包。
if upload_config.get("mode") != "oss":
return published_url
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url) dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定,
if not remote_paths: 无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接") """
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", {}) oss = upload_config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"] required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required): 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"]), oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"], oss["bucket_name"], connect_timeout=30, 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): 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) qrcode.make(urls[".html"]).save(qr_path)
urls.update(uploader(upload, [(qr_path, qr_remote)])) urls.update(uploader(upload, [(qr_path, qr_remote)]))
return urls[".html"], urls[".png"] return urls[".html"], urls[".png"]
def publish_dsym(config: dict, dsym_path: Path, build_dir: Path) -> str:
"""压缩并上传主 target 的 dSYM,返回下载 URL。"""
upload = config.get("_upload_config", {})
mode = upload.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
output_dir = build_dir / "distribution"
output_dir.mkdir(parents=True, exist_ok=True)
zip_base = output_dir / f"{_artifact_stem(config)}.dSYM"
zip_path = Path(shutil.make_archive(
str(zip_base), "zip", root_dir=str(dsym_path.parent), base_dir=dsym_path.name,
))
uploader = _upload_oss if mode == "oss" else _upload_webdav
urls = uploader(upload, [(zip_path, _dsym_remote_path(config))])
return urls[".zip"]
+37 -5
View File
@@ -27,8 +27,33 @@ def _signed_webhook_url(webhook_url: str, secret: str) -> str:
return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}" return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}"
def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: str = "") -> dict: def _normalize_mobiles(at_mobiles) -> list:
"""把手机号配置统一成去重、去空的字符串列表,兼容列表或逗号/换行分隔的字符串。"""
if not at_mobiles:
return []
if isinstance(at_mobiles, str):
raw = at_mobiles.replace("\n", ",").replace("", ",").replace(" ", ",").split(",")
else:
raw = at_mobiles
seen = []
for item in raw:
mobile = str(item).strip()
if mobile and mobile not in seen:
seen.append(mobile)
return seen
def build_dingtalk_payload(
config_data: dict,
download_url: str,
qr_code_url: str = "",
at_mobiles=None,
) -> dict:
"""生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。""" """生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。"""
mobiles = _normalize_mobiles(at_mobiles)
# App_Store 包与 Ad Hoc 用不同标题,App_Store 只有 IPA 下载地址、无二维码。
# 标题必须保留「打包」二字:钉钉机器人启用了自定义关键词,缺少关键词会被拒收。
heading = "【iOS】App_Store 打包信息" if config_data.get("BUILD_TYPE") == "App_Store" else "【iOS】打包信息"
details = ( details = (
f"**环境:** {config_data.get('SERVER', '')}\n\n" f"**环境:** {config_data.get('SERVER', '')}\n\n"
f"**版本:** {config_data.get('VERSION', '')}\n\n" f"**版本:** {config_data.get('VERSION', '')}\n\n"
@@ -36,14 +61,18 @@ def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: st
f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n" f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n"
f"**App Guid** {config_data.get('APPID', '')}" f"**App Guid** {config_data.get('APPID', '')}"
) )
text = f"## 【iOS】打包信息\n\n{details}\n\n**iOS 下载链接:** {download_url}\n" text = f"## {heading}\n\n{details}\n\n**iOS 下载链接:** {download_url}\n"
if qr_code_url: if qr_code_url:
text += f"\n![image]({qr_code_url})" text += f"\n![image]({qr_code_url})"
# 钉钉 markdown 消息必须在正文内出现 @手机号 才会真正 @ 到人,
# 仅靠 at.atMobiles 不生效。
if mobiles:
text += "\n\n" + " ".join(f"@{m}" for m in mobiles)
return { return {
"msgtype": "markdown", "msgtype": "markdown",
"markdown": {"title": "iOS应用下载", "text": text}, "markdown": {"title": "iOS应用下载", "text": text},
"at": {"atMobiles": [], "isAtAll": False}, "at": {"atMobiles": mobiles, "isAtAll": False},
} }
@@ -61,8 +90,11 @@ def send_dingtalk_notification(
if not webhook_url: if not webhook_url:
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL") raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
payload = build_dingtalk_payload(config_data, download_url, qr_code_url) # Ad_Hoc 与 App_Store 使用各自的 @ 名单。
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", []) mobiles_key = "at_mobiles_app_store" if config_data.get("BUILD_TYPE") == "App_Store" else "at_mobiles"
payload = build_dingtalk_payload(
config_data, download_url, qr_code_url, dingtalk_config.get(mobiles_key, [])
)
response = httpx.post( response = httpx.post(
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")), _signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
json=payload, json=payload,
+4
View File
@@ -35,11 +35,15 @@ def _update_plist(path: Path, config: dict):
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "") plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or "" plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower() scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
values = { values = {
"weixinlogin": config.get("weixinlogin", ""), "weixinlogin": config.get("weixinlogin", ""),
"wechatpay": config.get("weixinpay", ""), "wechatpay": config.get("weixinpay", ""),
"tencent": config.get("tencent", ""), "tencent": config.get("tencent", ""),
# 企业微信 Schema 与微信登录 AppID 同样存在 CFBundleURLTypes 中
# CFBundleURLName == "wxwork"),未配置则整条移除。
"wxwork": (config.get("wxworkSchema", "") or "").strip(),
"readoorUrlScheme": scheme, "readoorUrlScheme": scheme,
} }
url_types = [] url_types = []
+13 -5
View File
@@ -11,6 +11,7 @@
"tencent": "", "tencent": "",
"weixinlogin": "", "weixinlogin": "",
"weixinpay": "", "weixinpay": "",
"wxworkSchema": "",
"skins": ["default.zip"], "skins": ["default.zip"],
"certificates": { "certificates": {
"Ad_Hoc": { "Ad_Hoc": {
@@ -46,7 +47,7 @@
} }
}, },
"next_app_id_prefix": 3, "next_app_id_prefix": 3,
"branches": ["master", "develop"], "branches": ["master", "develop", "feature/示例分支"],
"upload": { "upload": {
"mode": "webdav", "mode": "webdav",
"oss": { "oss": {
@@ -66,14 +67,21 @@
"dingtalk": { "dingtalk": {
"enabled": false, "enabled": false,
"webhook_url": "", "webhook_url": "",
"secret": "" "secret": "",
"at_mobiles": [],
"at_mobiles_app_store": []
} }
}, },
"versions": { "versions": {
"tracks": { "tracks": {
"release": { "app_ver": "2.180.0", "build_map": {} }, "release": { "kind": "release", "app_ver": "2.180.0", "build_map": {} },
"feature": { "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": "release" } "branch_track": {
"master": "release",
"develop": "develop",
"feature/示例分支": "feature/示例分支"
}
} }
+40
View File
@@ -73,6 +73,46 @@ describe('BuildView.vue', () => {
expect(branchSelect).toBeTruthy() expect(branchSelect).toBeTruthy()
}) })
it('分支和 Tag 分组显示,可选择 Tag 提交', async () => {
fetch.mockImplementation((url, opts) => {
if (url === '/api/tasks' && opts?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'tag-task', status: 'pending' }) })
}
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tags': ['v3.2.0'],
'/api/tasks': [],
}
return Promise.resolve({ json: () => Promise.resolve(responses[url] || {}) })
})
const wrapper = mountBuildView()
await flushPromises()
const groups = wrapper.findAll('optgroup')
expect(groups.map(g => g.attributes('label'))).toEqual(['分支', 'Tag'])
expect(groups[1].findAll('option').map(o => o.text())).toEqual(['v3.2.0'])
wrapper.vm.selectedServer = '测试环境'
await wrapper.vm.$nextTick()
wrapper.vm.form.app_id = '1'
wrapper.vm.form.branch = 'v3.2.0'
await wrapper.vm.$nextTick()
await wrapper.find('.btn-primary').trigger('click')
await flushPromises()
const post = fetch.mock.calls.find(([url, opts]) => url === '/api/tasks' && opts?.method === 'POST')
expect(JSON.parse(post[1].body).branch).toBe('v3.2.0')
})
it('没有 Tag 时不显示 Tag 分组', async () => {
const wrapper = mountBuildView()
await flushPromises()
expect(wrapper.findAll('optgroup').map(g => g.attributes('label'))).toEqual(['分支'])
})
it('默认值正确', async () => { it('默认值正确', async () => {
const wrapper = mountBuildView() const wrapper = mountBuildView()
await flushPromises() await flushPromises()
+53 -9
View File
@@ -23,15 +23,18 @@ function mockFetch(url) {
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } }, '/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'], '/api/config/branches': ['main', 'dev'],
'/api/config/tags': ['v3.2.0'],
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' }, '/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
'/api/config/versions': { '/api/config/versions': {
app_ver: '2.195.0', app_ver: '2.195.0',
build_ver: '2.195.0.0', build_ver: '2.195.0.0',
primary_track: 'release',
tracks: { tracks: {
release: { app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} }, release: { kind: 'release', app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} },
feature: { app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} }, 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: 'release' }, branch_track: { main: 'release', dev: 'dev', 'v3.2.0': 'v3.2.0' },
}, },
'/api/config': { apps: {}, schemes: {}, branches: ['main'] }, '/api/config': { apps: {}, schemes: {}, branches: ['main'] },
} }
@@ -191,7 +194,7 @@ describe('ConfigView.vue', () => {
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps']) expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps'])
}) })
it('两条版本轨道各自独立展示与保存', async () => { it('每个分支的版本号各自独立展示与保存', async () => {
const calls = [] const calls = []
fetch.mockImplementation((url, opts) => { fetch.mockImplementation((url, opts) => {
if (url === '/api/config/versions' && opts?.method === 'PUT') { if (url === '/api/config/versions' && opts?.method === 'PUT') {
@@ -212,16 +215,20 @@ describe('ConfigView.vue', () => {
await buildMenu.trigger('click') await buildMenu.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
// 每个分支一块:main(上架,release 轨)、dev(自测,dev 轨)
expect(wrapper.text()).toContain('上架版本号(main')
expect(wrapper.text()).toContain('自测版本号(dev')
const inputs = wrapper.findAll('input[type="text"]') const inputs = wrapper.findAll('input[type="text"]')
expect(inputs[0].element.value).toBe('2.195.0') // release App_Ver expect(inputs[0].element.value).toBe('2.195.0') // main 分支 App_Ver
expect(inputs[2].element.value).toBe('2.100.0') // feature App_Ver expect(inputs[2].element.value).toBe('2.100.0') // dev 分支 App_Ver
await inputs[2].setValue('2.101.0') await inputs[2].setValue('2.101.0')
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存自测版本号') const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存 dev 版本号')
await saveBtn.trigger('click') await saveBtn.trigger('click')
await flushPromises() await flushPromises()
expect(calls).toEqual([{ track: 'feature', app_ver: '2.101.0', build_ver: '2.101.0.0' }]) expect(calls).toEqual([{ track: 'dev', app_ver: '2.101.0', build_ver: '2.101.0.0' }])
}) })
it('分支管理可切换版本轨道', async () => { it('分支管理可切换版本轨道', async () => {
@@ -242,10 +249,47 @@ describe('ConfigView.vue', () => {
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const select = wrapper.find('.config-table select') const select = wrapper.find('.config-table select')
expect(select.element.value).toBe('release') expect(select.element.value).toBe('release') // main → release 轨(上架)
await select.setValue('feature') await select.setValue('feature')
await flushPromises() await flushPromises()
expect(calls).toEqual([{ branch: 'main', track: 'feature' }]) 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],
])
})
}) })
+12 -3
View File
@@ -41,9 +41,14 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>代码分支</label> <label>代码分支 / Tag</label>
<select v-model="form.branch"> <select v-model="form.branch">
<optgroup label="分支">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option> <option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
</optgroup>
<optgroup v-if="tags.length" label="Tag">
<option v-for="t in tags" :key="`tag:${t}`" :value="t">{{ t }}</option>
</optgroup>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -167,6 +172,7 @@ const authFetch = (url, options = {}) => {
const apps = ref({}) const apps = ref({})
const schemes = ref({}) const schemes = ref({})
const branches = ref(['main']) const branches = ref(['main'])
const tags = ref([])
const tasks = ref([]) const tasks = ref([])
const selectedServer = ref('') const selectedServer = ref('')
const serverEnvironments = computed(() => { const serverEnvironments = computed(() => {
@@ -314,15 +320,18 @@ onUnmounted(() => {
}) })
onMounted(async () => { onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([ const [appsRes, schemesRes, branchesRes, tagsRes, tasksRes] = await Promise.all([
authFetch('/api/apps'), authFetch('/api/apps'),
authFetch('/api/schemes'), authFetch('/api/schemes'),
authFetch('/api/branches'), authFetch('/api/branches'),
authFetch('/api/tags'),
authFetch('/api/tasks'), authFetch('/api/tasks'),
]) ])
apps.value = await appsRes.json() apps.value = await appsRes.json()
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
branches.value = await branchesRes.json() branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
const allTasks = await tasksRes.json() const allTasks = await tasksRes.json()
// 从历史页跳转过来时,读取 taskId 查询参数 // 从历史页跳转过来时,读取 taskId 查询参数
@@ -436,7 +445,7 @@ const errorCategoryHint = (cat) => {
provisioning: '描述文件问题:描述文件过期、不匹配或未安装', provisioning: '描述文件问题:描述文件过期、不匹配或未安装',
compilation: '代码编译错误:请检查代码语法和类型引用', compilation: '代码编译错误:请检查代码语法和类型引用',
dependency: '依赖问题:Pod 依赖缺失或版本不兼容', dependency: '依赖问题:Pod 依赖缺失或版本不兼容',
git: '代码拉取失败:请检查分支是否存在', git: '代码拉取失败:请检查分支或 Tag 是否存在',
config: '配置替换失败:请检查打包配置', config: '配置替换失败:请检查打包配置',
build: '构建配置错误:请检查 Scheme 和 Workspace 配置', build: '构建配置错误:请检查 Scheme 和 Workspace 配置',
unknown: '打包失败:请查看详细日志排查原因', unknown: '打包失败:请查看详细日志排查原因',
+121 -53
View File
@@ -152,36 +152,42 @@
<div v-if="tab === 'branches'"> <div v-if="tab === 'branches'">
<h2>分支管理</h2> <h2>分支管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;"> <p style="color: #666; margin-bottom: 16px; font-size: 14px;">
管理可打包的代码分支打包时从对应分支目录获取源码 管理可打包的代码分支 Tag打包时从对应分支 Tag 获取源码
版本轨道决定该分支使用哪一套版本号上架master/develop 共用App_Store 打包构建号自增自测feature 构建号不自增 每个分支 / Tag 各有一套独立的版本号版本轨道决定构建号行为上架App_Store 打包构建号自增自测构建号不自增新增 Tag 默认为上架
</p> </p>
<div style="display: flex; gap: 12px; margin-bottom: 16px;"> <div style="display: flex; gap: 12px; margin-bottom: 16px;">
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;"> <select v-model="newRefType" style="width: auto; padding: 10px 12px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button> <option value="branch">分支</option>
<option value="tag">Tag</option>
</select>
<input v-model="newBranch" type="text" :placeholder="newRefType === 'tag' ? '输入 Tag 名称' : '输入分支名称'" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">{{ newRefType === 'tag' ? '添加 Tag' : '添加分支' }}</button>
</div> </div>
<table class="config-table"> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>分支名称</th> <th>名称</th>
<th>类型</th>
<th>版本轨道</th> <th>版本轨道</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="b in branches" :key="b"> <tr v-for="r in buildRefs" :key="`${r.type}:${r.name}`">
<td><strong>{{ b }}</strong></td> <td><strong>{{ r.name }}</strong></td>
<td>{{ r.type === 'tag' ? 'Tag' : '分支' }}</td>
<td> <td>
<select :value="branchTrack[b] || 'feature'" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)"> <select :value="branchKind(r.name)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(r.name, $event.target.value)">
<option value="release">上架master/develop</option> <option value="release">上架App_Store 打包构建号自增</option>
<option value="feature">自测feature</option> <option value="feature">自测构建号不自增</option>
</select> </select>
</td> </td>
<td class="action-btns"> <td class="action-btns">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button> <button class="action-btn delete" @click="deleteBranch(r)">删除</button>
</td> </td>
</tr> </tr>
<tr v-if="!branches.length"> <tr v-if="!buildRefs.length">
<td colspan="3" style="text-align: center; color: #999;">暂无分支配置</td> <td colspan="4" style="text-align: center; color: #999;">暂无分支配置</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -268,6 +274,16 @@
<label>加签密钥可选</label> <label>加签密钥可选</label>
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签"> <input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
</div> </div>
<div class="form-group">
<label>Ad_Hoc @ 开发人员手机号可选</label>
<textarea v-model="dingtalkAtMobiles" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 4px;">须是钉钉账号绑定的手机号Ad_Hoc 打包完成的通知会 @ 这些人</div>
</div>
<div class="form-group">
<label>App_Store @ 开发人员手机号可选</label>
<textarea v-model="dingtalkAtMobilesAppStore" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 4px;">App_Store 打包完成的通知会 @ 这些人</div>
</div>
</div> </div>
</div> </div>
@@ -279,36 +295,31 @@
<div v-if="tab === 'build'"> <div v-if="tab === 'build'">
<h2>打包设置</h2> <h2>打包设置</h2>
<div style="max-width: 500px;"> <div style="max-width: 500px;">
<h4 class="section-title">上架版本号master / develop</h4> <template v-for="t in branchTracks" :key="t.name">
<h4 class="section-title">
{{ t.kind === 'release' ? '上架版本号' : '自测版本号' }}{{ t.branches.join(' / ') }}
</h4>
<div class="form-group"> <div class="form-group">
<label>App_Ver应用版本</label> <label>App_Ver应用版本</label>
<input type="text" v-model="versions.release.app_ver" placeholder="2.180.0" @input="onAppVerInput('release')"> <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;">格式主版本.次版本.修订号master develop 共用这一套版本号</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }}轨道 {{ t.name }}与其它分支 / Tag 互不影响
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Build_Ver构建号</label> <label>Build_Ver构建号</label>
<input type="text" v-model="versions.release.build_ver" placeholder="2.180.0.0"> <input type="text" v-model="versions[t.name].build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;"> <div v-if="t.kind === 'release'" style="font-size: 12px; color: #999; margin-top: 4px;">
四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号 四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号
每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改 每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改
</div> </div>
</div> <div v-else style="font-size: 12px; color: #999; margin-top: 4px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('release')">保存上架版本号</button> 四段数字前三位须与 App_Ver 一致自测轨打包时<strong>不会自动递增</strong>
始终使用这里填写的值默认 .0需要区分时请手动修改
<h4 class="section-title">自测版本号feature 分支</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<input type="text" v-model="versions.feature.app_ver" placeholder="2.180.0" @input="onAppVerInput('feature')">
<div style="font-size: 12px; color: #999; margin-top: 4px;">与上架版本号各自独立互不影响</div>
</div>
<div class="form-group">
<label>Build_Ver构建号</label>
<input type="text" v-model="versions.feature.build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅供自测打包时<strong>不会自动递增</strong>始终使用这里填写的值默认 .0需要区分时请手动修改
</div> </div>
</div> </div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('feature')">保存自测版本号</button> <button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions(t.name)">保存 {{ t.branches.join(' / ') }} 版本号</button>
</template>
<template v-if="isAdmin"> <template v-if="isAdmin">
<h4 class="section-title">打包参数</h4> <h4 class="section-title">打包参数</h4>
@@ -488,6 +499,12 @@
<input v-model="appForm.weixinpay" type="text" placeholder="wx..."> <input v-model="appForm.weixinpay" type="text" placeholder="wx...">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label>企业微信 Schema</label>
<input v-model="appForm.wxworkSchema" type="text" placeholder="不填则不写入 Info.plist(与微信登录 AppID 相同)">
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>腾讯 AppID</label> <label>腾讯 AppID</label>
@@ -636,29 +653,58 @@ const apps = ref({})
const schemes = ref({}) const schemes = ref({})
const servers = ref({}) const servers = ref({})
const branches = ref([]) const branches = ref([])
const tags = ref([])
const newBranch = ref('') const newBranch = ref('')
const newRefType = ref('branch') // 新增的是分支还是 Tag
// 分支管理表格:分支在前,Tag 在后
const buildRefs = computed(() => [
...branches.value.map(name => ({ name, type: 'branch' })),
...tags.value.map(name => ({ name, type: 'tag' })),
])
const buildSettings = ref({}) const buildSettings = ref({})
// 两条版本轨道:releasemaster/develop,上架用)与 feature(自测用),各自独立 // 版本轨道:每个分支一条独立轨道,轨道名即分支名(master/main 沿用历史名 release)。
const versions = ref({ // 轨道类型 kind 决定构建号行为:release=上架、App_Store 打包自增;feature=自测、不自增。
release: { app_ver: '', build_ver: '' }, const versions = ref({}) // 轨道名 -> { app_ver, build_ver }
feature: { app_ver: '', build_ver: '' }, const buildMap = ref({}) // 轨道名 -> { App_Ver: 已用构建号 }
}) const trackKind = ref({}) // 轨道名 -> release | feature
const buildMap = ref({ release: {}, feature: {} }) const branchTrack = ref({}) // 分支名 -> 轨道名
const branchTrack = ref({})
const applyVersions = (data) => { const applyVersions = (data) => {
const tracks = data.tracks || {} const tracks = data.tracks || {}
for (const track of ['release', 'feature']) { const nextVersions = {}
const item = tracks[track] || {} const nextMap = {}
buildMap.value[track] = item.build_map || {} const nextKind = {}
versions.value[track] = { app_ver: item.app_ver || '', build_ver: item.build_ver || '' } 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 || {} 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) // 修改 App_Ver 时,自动把 Build_Ver 切换为该版本号已记录的构建号(新版本号则为 .0)
const onAppVerInput = (track) => { const onAppVerInput = (track) => {
const appVer = (versions.value[track].app_ver || '').trim() const appVer = (versions.value[track]?.app_ver || '').trim()
if (/^\d+\.\d+\.\d+$/.test(appVer)) { if (/^\d+\.\d+\.\d+$/.test(appVer)) {
const n = Number((buildMap.value[track] || {})[appVer] || 0) const n = Number((buildMap.value[track] || {})[appVer] || 0)
versions.value[track].build_ver = `${appVer}.${n}` versions.value[track].build_ver = `${appVer}.${n}`
@@ -667,6 +713,21 @@ const onAppVerInput = (track) => {
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} }) const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
const jsonContent = ref('{}') const jsonContent = ref('{}')
// 钉钉 @ 手机号:配置里存字符串数组,输入框按逗号/换行分隔编辑。
// Ad_Hoc 与 App_Store 各一份名单。
const dingtalkAtMobilesModel = (key) => computed({
get: () => (uploadConfig.value.dingtalk?.[key] || []).join('\n'),
set: (val) => {
if (!uploadConfig.value.dingtalk) uploadConfig.value.dingtalk = {}
uploadConfig.value.dingtalk[key] = val
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
},
})
const dingtalkAtMobiles = dingtalkAtMobilesModel('at_mobiles')
const dingtalkAtMobilesAppStore = dingtalkAtMobilesModel('at_mobiles_app_store')
const showServerModal = ref(false) const showServerModal = ref(false)
const editingServerName = ref(null) const editingServerName = ref(null)
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null }) const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null })
@@ -700,11 +761,12 @@ const loadData = async () => {
return return
} }
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([ const [appsRes, schemesRes, serversRes, branchesRes, tagsRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'), authFetch('/api/config/apps'),
authFetch('/api/config/schemes'), authFetch('/api/config/schemes'),
authFetch('/api/config/servers'), authFetch('/api/config/servers'),
authFetch('/api/config/branches'), authFetch('/api/config/branches'),
authFetch('/api/config/tags'),
authFetch('/api/config/build'), authFetch('/api/config/build'),
authFetch('/api/config/upload'), authFetch('/api/config/upload'),
authFetch('/api/config'), authFetch('/api/config'),
@@ -715,6 +777,8 @@ const loadData = async () => {
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
servers.value = await serversRes.json() servers.value = await serversRes.json()
branches.value = await branchesRes.json() branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
buildSettings.value = await buildRes.json() buildSettings.value = await buildRes.json()
uploadConfig.value = await uploadRes.json() uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2) jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
@@ -883,6 +947,7 @@ const openAppModal = (id = null, app = null) => {
UniversalLink: '', UniversalLink: '',
weixinlogin: '', weixinlogin: '',
weixinpay: '', weixinpay: '',
wxworkSchema: '',
tencent: '', tencent: '',
AlivcLicenseKey: '', AlivcLicenseKey: '',
app_id_prefix_override: '', app_id_prefix_override: '',
@@ -1130,12 +1195,12 @@ const deleteScheme = async (id) => {
await loadData() await loadData()
} }
// 分支管理 // 分支管理(分支与 Tag 共用一张表)
const addBranch = async () => { const addBranch = async () => {
const name = newBranch.value.trim() const name = newBranch.value.trim()
if (!name) return if (!name) return
try { try {
const res = await authFetch('/api/config/branches', { const res = await authFetch(newRefType.value === 'tag' ? '/api/config/tags' : '/api/config/branches', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
@@ -1162,17 +1227,20 @@ const setBranchTrack = async (name, track) => {
const err = await res.json() const err = await res.json()
throw new Error(err.detail || '修改失败') throw new Error(err.detail || '修改失败')
} }
branchTrack.value = { ...branchTrack.value, [name]: track } // 切到上架轨可能新建了轨道,重新拉取版本号
applyVersions(await (await authFetch('/api/config/versions')).json())
} catch (e) { } catch (e) {
alert('修改失败: ' + e.message) alert('修改失败: ' + e.message)
await loadData() await loadData()
} }
} }
const deleteBranch = async (name) => { const deleteBranch = async ({ name, type }) => {
if (!confirm(`确定删除分支「${name}」?`)) return const isTag = type === 'tag'
if (!confirm(`确定删除${isTag ? ' Tag ' : '分支'}${name}」?`)) return
try { try {
const res = await authFetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' }) const base = isTag ? '/api/config/tags' : '/api/config/branches'
const res = await authFetch(`${base}/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败') if (!res.ok) throw new Error('删除失败')
await loadData() await loadData()
} catch (e) { } catch (e) {
@@ -1190,7 +1258,7 @@ const saveBuildSettings = async () => {
alert('设置已保存') alert('设置已保存')
} }
const saveVersions = async (track = 'release') => { const saveVersions = async (track) => {
try { try {
const res = await authFetch('/api/config/versions', { const res = await authFetch('/api/config/versions', {
method: 'PUT', method: 'PUT',
+48 -7
View File
@@ -56,7 +56,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path" <img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb" :src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="showQrPreview(task)"> @click="showQrPreview(task)">
<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> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
@@ -110,7 +110,7 @@
<span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span> <span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<span class="meta-label">分支</span> <span class="meta-label">{{ logTask?.ref_type === 'tag' ? 'Tag' : '分支' }}</span>
<span class="meta-value">{{ logTask?.branch || '-' }}</span> <span class="meta-value">{{ logTask?.branch || '-' }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
@@ -159,7 +159,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, reactive, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken', () => '') const getToken = inject('getToken', () => '')
const isAdmin = inject('isAdmin', ref(false)) const isAdmin = inject('isAdmin', ref(false))
@@ -182,6 +182,7 @@ const logContainer = ref(null)
const showVerboseLogs = ref(false) const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
const showSuperseded = ref(false) const showSuperseded = ref(false)
const downloadingIds = reactive({})
let logWs = null let logWs = null
const getAppVersion = (task) => { const getAppVersion = (task) => {
@@ -308,11 +309,41 @@ onUnmounted(() => {
if (logWs) { logWs.close(); logWs = null } if (logWs) { logWs.close(); logWs = null }
}) })
const downloadDsym = (taskId) => { const triggerBlobDownload = (blob, filename) => {
window.open(`/api/tasks/${taskId}/dsym`) const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename || ''
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const 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) => { const downloadIpa = async (taskId) => {
if (downloadingIds[taskId]) return
downloadingIds[taskId] = true
try { try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`) const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error() if (!res.ok) throw new Error()
@@ -320,11 +351,19 @@ const downloadIpa = async (taskId) => {
window.open(url, '_blank') window.open(url, '_blank')
} catch { } catch {
alert('下载失败,请稍后重试') alert('下载失败,请稍后重试')
} finally {
delete downloadingIds[taskId]
} }
} }
const downloadObfMaps = (taskId) => { const downloadObfMaps = async (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`) try {
const res = await authFetch(`/api/tasks/${taskId}/obfuscation-maps`)
if (!res.ok) throw new Error()
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'obfuscation_maps.zip'))
} catch {
alert('下载失败,请稍后重试')
}
} }
const deleteTask = async (taskId) => { const deleteTask = async (taskId) => {
@@ -524,6 +563,8 @@ const errorCategoryLabel = (cat) => {
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; } .btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; } .download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
.download-link:hover { text-decoration: underline; } .download-link:hover { text-decoration: underline; }
.download-link:disabled { color: #999; cursor: default; }
.download-link:disabled:hover { text-decoration: none; }
.text-muted { color: #999; font-size: 13px; } .text-muted { color: #999; font-size: 13px; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; } .qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
.qr-thumb:hover { border-color: #1890ff; } .qr-thumb:hover { border-color: #1890ff; }
+107 -15
View File
@@ -229,15 +229,22 @@ def test_update_versions_rejects_invalid_app_version(client, tmp_config):
def test_versions_tracks_are_independent(client, tmp_config): def test_versions_tracks_are_independent(client, tmp_config):
# 两条轨道的 App_Ver 各自独立,互不影响 # 每个分支一条轨道(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": "release", "app_ver": "2.196.0"}).status_code == 200
assert client.put("/api/config/versions", json={"track": "feature", "app_ver": "2.100.0"}).status_code == 200 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() 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"]["release"]["app_ver"] == "2.196.0"
assert data["tracks"]["feature"]["app_ver"] == "2.100.0" assert data["tracks"]["dev"]["app_ver"] == "2.198.0"
assert data["tracks"]["feature"]["build_ver"] == "2.100.0.0" assert data["tracks"]["feature/login"]["app_ver"] == "2.100.0"
# 顶层字段镜像 release 轨,兼容旧客户端 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" assert data["app_ver"] == "2.196.0"
@@ -246,7 +253,8 @@ def test_update_versions_rejects_unknown_track(client, tmp_config):
assert resp.status_code == 400 assert resp.status_code == 400
def test_legacy_versions_migrate_to_release_track(client, tmp_config): def test_legacy_versions_split_per_branch(client, tmp_config):
"""最旧的单轨配置:拆成每个分支一条轨,各自沿用原来的已用构建号"""
import json import json
with open(tmp_config, "r", encoding="utf-8") as f: with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f) cfg = json.load(f)
@@ -255,28 +263,78 @@ def test_legacy_versions_migrate_to_release_track(client, tmp_config):
json.dump(cfg, f) json.dump(cfg, f)
data = client.get("/api/config/versions").json() data = client.get("/api/config/versions").json()
assert data["tracks"]["release"] == { # 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", "app_ver": "2.195.0",
"build_ver": "2.195.0.7", "build_ver": "2.195.0.7",
"build_map": {"2.195.0": 7}, "build_map": {"2.195.0": 7},
} }
# feature 轨以同一 App_Ver 初始化,构建号从 .0 起 # 拆分后各轨独立:改 release 不影响 dev
assert data["tracks"]["feature"]["app_ver"] == "2.195.0" assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200
assert data["tracks"]["feature"]["build_ver"] == "2.195.0.0" 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): 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": "develop"}).status_code == 200
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200 assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
tracks = client.get("/api/config/versions").json()["branch_track"] data = client.get("/api/config/versions").json()
assert tracks["develop"] == "release" # 每个分支一条自己的轨道,类型按分支名推断
assert tracks["feature/login"] == "feature" 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"}) resp = client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "release"})
assert resp.status_code == 200 assert resp.status_code == 200
assert client.get("/api/config/versions").json()["branch_track"]["feature/login"] == "release" 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": "nope", "track": "release"}).status_code == 404
assert client.put("/api/config/branches/track", json={"branch": "develop", "track": "beta"}).status_code == 400 assert client.put("/api/config/branches/track", json={"branch": "develop", "track": "beta"}).status_code == 400
@@ -287,3 +345,37 @@ def test_delete_branch_with_slash_in_name(client, tmp_config):
resp = client.delete("/api/config/branches/feature%2Flogin") resp = client.delete("/api/config/branches/feature%2Flogin")
assert resp.status_code == 200 assert resp.status_code == 200
assert "feature/login" not in client.get("/api/config/branches").json() 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.status_code == 200
assert resp.json()["branch"] == "main" assert resp.json()["branch"] == "main"
assert resp.json()["ref_type"] == "branch"
@patch("backend.services.build_queue.build_queue")
def test_create_task_from_tag(mock_queue, client, tmp_config):
"""选择已配置的 Tag 打包时,任务记录为 tag 类型"""
mock_queue.submit = AsyncMock()
assert client.post("/api/config/tags", json={"name": "v3.2.0"}).status_code == 200
resp = client.post("/api/tasks", json={
"app_id": "1",
"build_type": "Ad_Hoc",
"scheme_id": "1",
"branch": "v3.2.0",
})
assert resp.status_code == 200
assert resp.json()["branch"] == "v3.2.0"
assert resp.json()["ref_type"] == "tag"
@patch("backend.services.build_queue.build_queue") @patch("backend.services.build_queue.build_queue")
+96 -30
View File
@@ -112,6 +112,25 @@ async def test_update_source_existing(tmp_path, log_streamer):
assert call_count == 4 # fetch, checkout, reset, clean assert call_count == 4 # fetch, checkout, reset, clean
async def test_update_source_tag_checks_out_detached(tmp_path, log_streamer):
"""Tag 打包:拉取 Tag 后以游离 HEAD 检出 refs/tags/<tag>,不依赖同名远程分支"""
source_dir = tmp_path / "workspace"
source_dir.mkdir()
(source_dir / ".git").mkdir()
with patch("backend.services.build_service.get_git_remote_url", return_value=""):
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
await update_source("t1", source_dir, "v3.2.0", "tag")
git_args = [call.args[3:] for call in mock_exec.call_args_list]
assert git_args == [
("fetch", "origin", "--tags", "--force"),
("checkout", "--detach", "refs/tags/v3.2.0"),
("reset", "--hard", "refs/tags/v3.2.0"),
("clean", "-ffdx"),
]
async def test_update_source_fetch_failure(tmp_path, log_streamer): async def test_update_source_fetch_failure(tmp_path, log_streamer):
"""fetch 失败时抛异常""" """fetch 失败时抛异常"""
source_dir = tmp_path / "branches" / "dev" source_dir = tmp_path / "branches" / "dev"
@@ -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"), \ with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \ patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \ patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \ patch("backend.services.build_service.update_source", new_callable=AsyncMock) as mock_update, \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"): patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
build_dir, commit = await prepare_source_snapshot("t1", task) build_dir, commit = await prepare_source_snapshot("t1", task)
assert build_dir.exists() assert build_dir.exists()
assert commit == "abc123" assert commit == "abc123"
assert mock_update.call_args.args[2:] == ("main", "branch")
async def test_prepare_source_snapshot_passes_tag_ref_type(tmp_dirs, log_streamer):
source_dir, build_dir_parent = tmp_dirs
task = MagicMock(id="tag-task", branch="v3.2.0", ref_type="tag")
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock) as mock_update, \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
await prepare_source_snapshot("t1", task)
assert mock_update.call_args.args[2:] == ("v3.2.0", "tag")
# ---- copy_source_code ---- # ---- copy_source_code ----
@@ -315,14 +349,23 @@ def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W") assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W")
def _versions_config(release: dict, feature: dict) -> dict: def _versions_config(tracks: dict) -> dict:
"""构造带版本轨道的配置;tracks 为 轨道名 -> {kind, app_ver, build_map}。"""
return { return {
"apps": {"1": {"name": "App", "certificates": {}}}, "apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}}, "schemes": {"1": {"name": "sch", "ossFloder": "f"}},
"versions": {"tracks": {"release": release, "feature": feature}}, "versions": {"tracks": tracks},
} }
def _release(app_ver: str, build_map: dict = None) -> dict:
return {"kind": "release", "app_ver": app_ver, "build_map": build_map or {}}
def _feature(app_ver: str, build_map: dict = None) -> dict:
return {"kind": "feature", "app_ver": app_ver, "build_map": build_map or {}}
async def test_generate_config_uses_saved_version(tmp_path, log_streamer): async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
"""从服务配置读取版本号,不依赖分支源码脚本""" """从服务配置读取版本号,不依赖分支源码脚本"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"
@@ -335,10 +378,7 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
task.obfuscation = False task.obfuscation = False
task.branch = "master" task.branch = "master"
mock_config = _versions_config( mock_config = _versions_config({"release": _release("3.0.1"), "feature": _feature("9.9.9")})
{"app_ver": "3.0.1", "build_map": {}},
{"app_ver": "9.9.9", "build_map": {}},
)
with patch("backend.routers.config.load_config", return_value=mock_config): with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir) result = await generate_config("t1", task, build_dir)
@@ -356,10 +396,10 @@ async def test_generate_config_appstore_increments_per_version(tmp_path, log_str
obfuscation=False, branch="master") obfuscation=False, branch="master")
# 2.195.0 已打到 .5,切到 2.196.0 后又回退到 2.195.0 # 2.195.0 已打到 .5,切到 2.196.0 后又回退到 2.195.0
mock_config = _versions_config( mock_config = _versions_config({
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5, "2.196.0": 3}}, "release": _release("2.195.0", {"2.195.0": 5, "2.196.0": 3}),
{"app_ver": "2.195.0", "build_map": {}}, "feature": _feature("2.195.0"),
) })
with patch("backend.routers.config.load_config", return_value=mock_config), \ with patch("backend.routers.config.load_config", return_value=mock_config), \
patch("backend.routers.config.save_config") as save_mock: patch("backend.routers.config.save_config") as save_mock:
@@ -373,39 +413,64 @@ async def test_generate_config_appstore_increments_per_version(tmp_path, log_str
save_mock.assert_called() save_mock.assert_called()
async def test_generate_config_develop_shares_release_track(tmp_path, log_streamer): async def test_generate_config_release_branches_are_independent(tmp_path, log_streamer):
"""develop 与 master 共用 release 轨的版本号与构建号序列""" """master 与 develop 各用自己的轨道,版本号与构建号互不影响"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"
build_dir.mkdir() build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store", task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
obfuscation=False, branch="develop") obfuscation=False, branch="develop")
mock_config = _versions_config( mock_config = _versions_config({
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5}}, "release": _release("2.195.0", {"2.195.0": 5}), # master
{"app_ver": "2.100.0", "build_map": {}}, "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), \ with patch("backend.routers.config.load_config", return_value=mock_config), \
patch("backend.routers.config.save_config"): patch("backend.routers.config.save_config"):
result = await generate_config("t1", task, build_dir) result = await generate_config("t1", task, build_dir)
assert result["VERSION"] == "2.195.0" assert result["VERSION"] == "2.196.0"
assert result["BUILD_VERSION"] == "2.195.0.6" 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): async def test_generate_config_feature_branch_never_increments(tmp_path, log_streamer):
"""feature 分支用自己的版本号,且构建号永不自增(即使是 App_Store 打包)""" """自测分支用自己那条轨道的版本号,且构建号永不自增(即使是 App_Store 打包)"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"
build_dir.mkdir() build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store", task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
obfuscation=False, branch="feature/login") obfuscation=False, branch="feature/login")
mock_config = _versions_config( mock_config = _versions_config({
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5}}, "release": _release("2.195.0", {"2.195.0": 5}),
{"app_ver": "2.100.0", "build_map": {}}, "feature/login": _feature("2.100.0"),
) })
with patch("backend.routers.config.load_config", return_value=mock_config), \ with patch("backend.routers.config.load_config", return_value=mock_config), \
patch("backend.routers.config.save_config") as save_mock: patch("backend.routers.config.save_config") as save_mock:
@@ -413,7 +478,7 @@ async def test_generate_config_feature_branch_never_increments(tmp_path, log_str
assert result["VERSION"] == "2.100.0" assert result["VERSION"] == "2.100.0"
assert result["BUILD_VERSION"] == "2.100.0.0" assert result["BUILD_VERSION"] == "2.100.0.0"
assert mock_config["versions"]["tracks"]["feature"]["build_map"] == {} assert mock_config["versions"]["tracks"]["feature/login"]["build_map"] == {}
save_mock.assert_not_called() save_mock.assert_not_called()
@@ -425,11 +490,12 @@ async def test_generate_config_branch_track_override(tmp_path, log_streamer):
task = MagicMock(app_id="1", scheme_id="1", build_type="Ad_Hoc", task = MagicMock(app_id="1", scheme_id="1", build_type="Ad_Hoc",
obfuscation=False, branch="release/2.196") obfuscation=False, branch="release/2.196")
mock_config = _versions_config( mock_config = _versions_config({
{"app_ver": "2.196.0", "build_map": {}}, "release": _release("2.196.0"),
{"app_ver": "2.100.0", "build_map": {}}, "feature/login": _feature("2.100.0"),
) })
mock_config["branch_track"] = {"release/2.196": "feature"} # 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): with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir) result = await generate_config("t1", task, build_dir)
+14
View File
@@ -66,6 +66,20 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
assert resp.status_code == 404 assert resp.status_code == 404
def test_download_dsym_remote_fallback(client, tmp_config):
"""本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向,
因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)"""
task_id = _create_task_with_files(
client, tmp_config,
dsym_path="/tmp/nonexistent.dSYM",
dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip",
)
with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"):
resp = client.get(f"/api/tasks/{task_id}/dsym")
assert resp.status_code == 200
assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1"
# ---- 混淆映射表下载 ---- # ---- 混淆映射表下载 ----
def test_download_obfuscation_maps_not_exist(client, tmp_config): def test_download_obfuscation_maps_not_exist(client, tmp_config):
+73
View File
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
assert "![image](https://download.example.com/app.png)" in text assert "![image](https://download.example.com/app.png)" in text
def test_dingtalk_payload_at_mobiles_appended_to_text_and_at_field():
payload = build_dingtalk_payload(
_build_config(),
"https://download.example.com/app.html",
at_mobiles=["13800000000", " 13900000001 ", "13800000000", ""],
)
# 去重去空后写入 at.atMobiles,并在正文追加 @手机号(钉钉 markdown 必需)
assert payload["at"]["atMobiles"] == ["13800000000", "13900000001"]
assert "@13800000000" in payload["markdown"]["text"]
assert "@13900000001" in payload["markdown"]["text"]
def test_dingtalk_payload_without_at_mobiles_has_empty_at():
payload = build_dingtalk_payload(_build_config(), "https://download.example.com/app.html")
assert payload["at"]["atMobiles"] == []
assert "@" not in payload["markdown"]["text"]
def test_dingtalk_notification_forwards_at_mobiles():
response = MagicMock(status_code=200)
response.json.return_value = {"errcode": 0}
with patch("backend.services.notification.httpx.post", return_value=response) as post:
send_dingtalk_notification(
{
"enabled": True,
"webhook_url": "https://example.com/robot",
"at_mobiles": ["13800000000", "13900000001"],
},
_build_config(),
"https://download.example.com/app.html",
)
sent = post.call_args.kwargs["json"]
assert sent["at"]["atMobiles"] == ["13800000000", "13900000001"]
assert "@13800000000" in sent["markdown"]["text"]
def test_dingtalk_payload_app_store_heading_and_no_qr():
config = {**_build_config(), "BUILD_TYPE": "App_Store"}
# App_Store 流程不传二维码地址(publish_ipa 返回空串)
payload = build_dingtalk_payload(
config, "https://oss.example.com/readoor/iOS/app_2_197_1.ipa"
)
text = payload["markdown"]["text"]
# 标题保留「打包」二字,避免命中钉钉自定义关键词拦截
assert "## 【iOS】App_Store 打包信息" in text
assert "**iOS 下载链接:** https://oss.example.com/readoor/iOS/app_2_197_1.ipa" in text
assert "![image]" not in text
def test_dingtalk_notification_app_store_uses_own_at_list():
response = MagicMock(status_code=200)
response.json.return_value = {"errcode": 0}
dingtalk_config = {
"enabled": True,
"webhook_url": "https://example.com/robot",
"at_mobiles": ["13800000000"],
"at_mobiles_app_store": ["13911111111", "13922222222"],
}
with patch("backend.services.notification.httpx.post", return_value=response) as post:
send_dingtalk_notification(
dingtalk_config,
{**_build_config(), "BUILD_TYPE": "App_Store"},
"https://oss.example.com/app.ipa",
)
sent = post.call_args.kwargs["json"]
assert sent["at"]["atMobiles"] == ["13911111111", "13922222222"]
assert "## 【iOS】App_Store 打包信息" in sent["markdown"]["text"]
assert "@13800000000" not in sent["markdown"]["text"]
def test_dingtalk_notification_skips_when_disabled(): def test_dingtalk_notification_skips_when_disabled():
with patch("backend.services.notification.httpx.post") as post: with patch("backend.services.notification.httpx.post") as post:
assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False
+54 -1
View File
@@ -33,7 +33,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
"<string>applinks:old</string>\n", encoding="utf-8" "<string>applinks:old</string>\n", encoding="utf-8"
) )
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f: with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f:
plistlib.dump({"CFBundleURLTypes": []}, f) plistlib.dump({"CFBundleURLTypes": [
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
]}, f)
theme = tmp_path / "theme" / "AppIcon.appiconset" theme = tmp_path / "theme" / "AppIcon.appiconset"
theme.mkdir(parents=True) theme.mkdir(parents=True)
@@ -42,6 +44,7 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
config = { config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid", "VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test", "API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
"wxworkSchema": "wx1234567890abcdef",
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile", "BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc", "ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
"CERTIFICATE": "Apple Distribution", "CERTIFICATE": "Apple Distribution",
@@ -58,3 +61,53 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
assert "iPhone Developer" not in project_content assert "iPhone Developer" not in project_content
assert (build_dir / "exportOptions.plist").exists() assert (build_dir / "exportOptions.plist").exists()
assert (logo / "icon-1024.png").read_bytes() == b"icon" assert (logo / "icon-1024.png").read_bytes() == b"icon"
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("rb") as f:
url_types = plistlib.load(f)["CFBundleURLTypes"]
wxwork = next(i for i in url_types if i["CFBundleURLName"] == "wxwork")
assert wxwork["CFBundleURLSchemes"] == ["wx1234567890abcdef"]
def _make_minimal_project(build_dir):
"""构造 apply_project_config 所需的最小工程骨架,返回关键文件路径。"""
project = build_dir / "readoor.xcodeproj"
swift = build_dir / "readoor" / "3.0" / "AppConfig"
entitlements_dir = build_dir / "readoor" / "3.0"
for path in [project, swift, entitlements_dir]:
path.mkdir(parents=True, exist_ok=True)
(project / "project.pbxproj").write_text(
"MARKETING_VERSION = 1.0;\nCURRENT_PROJECT_VERSION = 1;\n"
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
encoding="utf-8",
)
(swift / "RDAppConfiguration.swift").write_text(
'let RD_APP_GUID: String = "old"\nlet RD_API_DOMAIN: String = "old"\n'
'let RD_WECHAT_PAY_ID: String = "old"\nlet RD_SOURCE_VERSION: String = "old"\n',
encoding="utf-8",
)
(entitlements_dir / "readoor31.entitlements").write_text(
"<string>applinks:old</string>\n", encoding="utf-8"
)
return entitlements_dir / "readoor31.plist"
def test_wxwork_schema_removed_when_not_configured(tmp_path):
build_dir = tmp_path / "build"
plist_path = _make_minimal_project(build_dir)
with plist_path.open("wb") as f:
plistlib.dump({"CFBundleURLTypes": [
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
]}, f)
config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "APPID_NAME": "Test",
"ASSOCIATED_DOMAINS": "applinks:example.com", "BUILD_TYPE": "Ad_Hoc",
"CERTIFICATE": "Apple Distribution",
}
apply_project_config(build_dir, config)
with plist_path.open("rb") as f:
url_types = plistlib.load(f)["CFBundleURLTypes"]
assert all(i.get("CFBundleURLName") != "wxwork" for i in url_types)