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:
+160
-46
@@ -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。
|
||||
- track:release(master/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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user