Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5582aadc09 | ||
|
|
6867a3cb01 | ||
|
|
9b4667cab5 | ||
|
|
bf7a5a0449 | ||
|
|
dae14a8a10 | ||
|
|
9ace2c8aca | ||
|
|
fa559101b9 | ||
|
|
e10afb180d | ||
|
|
082c487cf8 | ||
|
|
1466e24c83 | ||
|
|
c17b7d5ad9 | ||
|
|
84a2d3db01 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` 里的值指向另一条轨道名。
|
||||||
|
|
||||||
### 服务端自动化资源
|
### 服务端自动化资源
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 拒绝。
|
||||||
|
|
||||||
- track:release(master/develop,上架用)或 feature(自测用),缺省 release。
|
- track:轨道名,上架分支为分支名(如 master、develop),自测为 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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"):
|
||||||
"""将共享源码工作目录强制同步到指定远程分支。"""
|
"""将共享源码工作目录强制同步到指定远程分支或 Tag(ref_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 轨(自测用),
|
# 按分支所属轨道取版本号:每个上架分支(master、develop 等)一条独立轨道,
|
||||||
# 两条轨道的 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 ""
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
text += f"\n"
|
||||||
|
# 钉钉 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,
|
||||||
|
|||||||
@@ -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
@@ -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/示例分支"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '打包失败:请查看详细日志排查原因',
|
||||||
|
|||||||
@@ -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({})
|
||||||
// 两条版本轨道:release(master/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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
|
|||||||
assert "" in text
|
assert "" 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user