feat: 版本号按分支轨道分开管理,版本号配置收归管理员

master/develop 共用 release 轨(App_Store 打包构建号自增),feature 分支走
feature 轨(构建号永不自增,始终使用手填值),两条轨道的 App_Ver 各自独立。

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-07-31 17:56:10 +09:00
co-authored by Claude Opus 5
parent 3cb4053ab5
commit 21542c88d7
10 changed files with 511 additions and 115 deletions
+160 -46
View File
@@ -26,10 +26,9 @@ from ..deps import get_current_user, require_admin
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
"""普通用户可管理 Apps版本号其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/")
if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
or path == "/api/config/versions"):
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
return user
return require_admin(user)
@@ -90,45 +89,118 @@ DEFAULT_SERVERS = {
}
}
DEFAULT_VERSIONS = {
"app_ver": "2.180.0",
DEFAULT_APP_VER = "2.180.0"
# 版本轨道:上架分支(master/develop)共用 release,自测分支(feature/*)用 feature。
# 两条轨道的 App_Ver 各自独立填写,互不影响。
TRACK_RELEASE = "release"
TRACK_FEATURE = "feature"
TRACKS = (TRACK_RELEASE, TRACK_FEATURE)
# 按名字推断轨道的默认规则;config["branch_track"] 中的显式配置优先。
RELEASE_BRANCH_NAMES = {"master", "main", "develop", "dev"}
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-")
def _default_track_for_branch(branch: str) -> str:
"""按分支名推断所属版本轨道。"""
name = (branch or "").strip().lower()
if name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES):
return TRACK_RELEASE
return TRACK_FEATURE
def _new_track(app_ver: str = DEFAULT_APP_VER) -> dict:
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
"build_map": {},
}
return {"app_ver": app_ver, "build_map": {}}
def _ensure_versions(config: dict) -> bool:
"""确保 versions 存在,并把旧式单值 build_ver 迁移为按版本号记录的 build_map。"""
"""确保 versions 为双轨结构,并迁移旧式单轨/单值 build_ver 配置。
旧式 ``{"app_ver": ..., "build_map": ...}`` 整体迁入 release 轨;
feature 轨用同一个 App_Ver 初始化,构建号从 .0 起且永不自增。
"""
versions = config.get("versions")
changed = False
if not isinstance(versions, dict):
config["versions"] = DEFAULT_VERSIONS.copy()
config["versions"]["build_map"] = {}
config["versions"] = {"tracks": {TRACK_RELEASE: _new_track(), TRACK_FEATURE: _new_track()}}
return True
changed = False
if not versions.get("app_ver"):
versions["app_ver"] = DEFAULT_VERSIONS["app_ver"]
tracks = versions.get("tracks")
if not isinstance(tracks, dict):
# 旧式单轨结构 → release 轨
legacy_app_ver = versions.get("app_ver") or DEFAULT_APP_VER
legacy_build_map = versions.get("build_map")
if not isinstance(legacy_build_map, dict):
legacy_build_map = {}
# 更旧的单值形式 "2.195.0.3" → {"2.195.0": 3}
old = versions.get("build_ver")
if isinstance(old, str):
parts = old.split(".")
if len(parts) >= 4 and parts[3].isdigit():
legacy_build_map[".".join(parts[:3])] = int(parts[3])
versions.pop("app_ver", None)
versions.pop("build_map", None)
versions.pop("build_ver", None)
tracks = {
TRACK_RELEASE: {"app_ver": legacy_app_ver, "build_map": legacy_build_map},
TRACK_FEATURE: _new_track(legacy_app_ver),
}
versions["tracks"] = tracks
changed = True
if "build_map" not in versions:
build_map = {}
old = versions.pop("build_ver", None)
# 旧式形如 "2.195.0.3" → build_map = {"2.195.0": 3}
if isinstance(old, str):
parts = old.split(".")
if len(parts) >= 4 and parts[3].isdigit():
build_map[".".join(parts[:3])] = int(parts[3])
versions["build_map"] = build_map
changed = True
elif "build_ver" in versions:
# build_map 已存在但残留旧字段,清理掉
versions.pop("build_ver", None)
for name in TRACKS:
track = tracks.get(name)
if not isinstance(track, dict):
tracks[name] = _new_track()
changed = True
continue
if not track.get("app_ver"):
track["app_ver"] = DEFAULT_APP_VER
changed = True
if not isinstance(track.get("build_map"), dict):
track["build_map"] = {}
changed = True
# 分支归属:只存显式配置,缺省按名字规则推断
branch_track = config.get("branch_track")
if not isinstance(branch_track, dict):
branch_track = {}
config["branch_track"] = branch_track
changed = True
for branch in config.get("branches", []) or []:
if branch_track.get(branch) not in TRACKS:
branch_track[branch] = _default_track_for_branch(branch)
changed = True
return changed
def get_track_name(config: dict, branch: str) -> str:
"""取分支所属的版本轨道名。"""
track = (config.get("branch_track") or {}).get(branch)
return track if track in TRACKS else _default_track_for_branch(branch)
def get_track(config: dict, track_name: str) -> dict:
"""取某条轨道的版本记录(不存在时返回默认值,不写回配置)。"""
tracks = (config.get("versions") or {}).get("tracks") or {}
track = tracks.get(track_name)
if not isinstance(track, dict):
return _new_track()
return track
def track_build_ver(track: dict) -> str:
"""该轨道当前 App_Ver 已用到的构建号,形如 2.195.0.5(尚未打包则 .0)。"""
app_ver = track.get("app_ver") or DEFAULT_APP_VER
build_map = track.get("build_map") or {}
return f"{app_ver}.{int(build_map.get(app_ver, 0))}"
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
LEGACY_SERVER_PREFIXES = {
"测试环境": 1,
@@ -562,11 +634,15 @@ async def get_branches():
@router.post("/branches")
async def add_branch(data: dict):
"""新增分支"""
"""新增分支,可指定所属版本轨道(缺省按分支名推断)"""
name = data.get("name", "").strip()
if not name:
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:
config = load_config()
branches = config.get("branches", ["main"])
@@ -574,11 +650,32 @@ async def add_branch(data: dict):
raise HTTPException(status_code=400, detail="分支已存在")
branches.append(name)
config["branches"] = branches
config.setdefault("branch_track", {})[name] = track
save_config(config)
return {"message": "分支已添加"}
return {"message": "分支已添加", "track": track}
@router.delete("/branches/{branch_name}")
@router.put("/branches/track")
async def update_branch_track(data: dict):
"""修改分支所属的版本轨道(release=上架,feature=自测)。
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
"""
branch = (data.get("branch") or "").strip()
track = (data.get("track") or "").strip()
if track not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
async with _config_lock:
config = load_config()
if branch not in config.get("branches", []):
raise HTTPException(status_code=404, detail="分支不存在")
config.setdefault("branch_track", {})[branch] = track
save_config(config)
return {"message": "分支版本轨道已更新", "branch": branch, "track": track}
@router.delete("/branches/{branch_name:path}")
async def delete_branch(branch_name: str):
"""删除分支"""
async with _config_lock:
@@ -588,6 +685,7 @@ async def delete_branch(branch_name: str):
raise HTTPException(status_code=404, detail="分支不存在")
branches.remove(branch_name)
config["branches"] = branches
config.get("branch_track", {}).pop(branch_name, None)
save_config(config)
return {"message": "分支已删除"}
@@ -639,33 +737,47 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
# 版本号配置 API
@router.get("/versions")
async def get_versions():
"""获取服务端统一管理的 App_Ver 及其当前构建号。
"""获取两条版本轨道各自的 App_Ver 及其当前构建号。
build_ver 为当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
仅供展示;实际递增在 App_Store 打包时按版本号独立进行
build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
仅供展示;实际递增只发生在 release 轨的 App_Store 打包时,feature 轨永不自增
顶层 app_ver / build_ver 为 release 轨的镜像,保留给旧客户端。
"""
config = load_config()
versions = config.get("versions", DEFAULT_VERSIONS.copy())
app_ver = versions.get("app_ver", DEFAULT_VERSIONS["app_ver"])
build_map = versions.get("build_map", {}) or {}
tracks = {}
for name in TRACKS:
track = get_track(config, name)
tracks[name] = {
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_ver": track_build_ver(track),
"build_map": track.get("build_map") or {},
}
return {
"app_ver": app_ver,
"build_ver": f"{app_ver}.{int(build_map.get(app_ver, 0))}",
"build_map": build_map,
**tracks[TRACK_RELEASE],
"tracks": tracks,
"branch_track": {
branch: get_track_name(config, branch)
for branch in config.get("branches", []) or []
},
}
@router.put("/versions")
async def update_versions(data: dict):
"""更新 App_Ver,并可选地手动设置该版本号的构建号。
"""更新某条轨道的 App_Ver,并可选地手动设置该版本号的构建号。
每个 App_Ver 的构建号独立保存 build_map 各自递增,因此来回切换版本号
(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
每个 App_Ver 的构建号在所属轨道内独立保存 build_map 各自递增,因此来回切换
版本号(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
- 不传 build_ver:保留该版本号已记录的构建号,仅更新当前 App_Ver
- trackreleasemaster/develop,上架用)或 feature(自测用),缺省 release
- 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
"""
track_name = (data.get("track") or TRACK_RELEASE).strip()
if track_name not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track_name}")
app_ver = data.get("app_ver", "").strip()
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
@@ -683,17 +795,19 @@ async def update_versions(data: dict):
async with _config_lock:
config = load_config()
versions = config.setdefault("versions", DEFAULT_VERSIONS.copy())
versions["app_ver"] = app_ver
build_map = versions.setdefault("build_map", {})
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track = tracks.setdefault(track_name, _new_track())
track["app_ver"] = app_ver
build_map = track.setdefault("build_map", {})
if build_no is not None:
build_map[app_ver] = build_no
save_config(config)
return {
"message": "版本号已更新",
"track": track_name,
"app_ver": app_ver,
"build_ver": f"{app_ver}.{int(build_map.get(app_ver, 0))}",
"build_ver": track_build_ver(track),
}