feat: 构建号按版本号(App_Ver)独立记录,避免回退旧版本号被 App Store 拒

将 versions.build_ver 单值改为 build_map(每个 App_Ver 独立记录已用到的最大
构建号)。App Store 的构建号唯一性按 MARKETING_VERSION 分别计算,改为单一全局
计数器时回退到打过包的旧版本号会重复(如 2.195.0 已到 .5,切 2.196.0 后再回退,
重置/沿用全局计数都会 < .5 而被拒)。

- config.py: 新增 _ensure_versions 迁移旧 build_ver → build_map;PUT /versions
  不再重置构建号,改版本号仅切换当前 App_Ver,并支持可选手动设置 build_ver;
  GET /versions 返回 build_map 及当前版本号对应的 build_ver
- build_service.py: App_Store 打包时在 build_map[app_ver] 自身上 +1 并回写
- ConfigView.vue: 打包设置页显示可编辑的 Build_Ver,修改 App_Ver 时自动切换为
  该版本号已记录的构建号
- 迁移 config.json;更新前后端相关测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-07-29 16:49:14 +09:00
co-authored by Claude Opus 4.8
parent b6e56f5f9a
commit 91e9b027e8
7 changed files with 168 additions and 34 deletions
+3 -1
View File
@@ -450,6 +450,8 @@
},
"versions": {
"app_ver": "2.195.0",
"build_ver": "2.195.0.0"
"build_map": {
"2.195.0": 0
}
}
}
+77 -14
View File
@@ -92,9 +92,43 @@ DEFAULT_SERVERS = {
DEFAULT_VERSIONS = {
"app_ver": "2.180.0",
"build_ver": "2.180.0.0",
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
"build_map": {},
}
def _ensure_versions(config: dict) -> bool:
"""确保 versions 存在,并把旧式单值 build_ver 迁移为按版本号记录的 build_map。"""
versions = config.get("versions")
if not isinstance(versions, dict):
config["versions"] = DEFAULT_VERSIONS.copy()
config["versions"]["build_map"] = {}
return True
changed = False
if not versions.get("app_ver"):
versions["app_ver"] = DEFAULT_VERSIONS["app_ver"]
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)
changed = True
return changed
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
LEGACY_SERVER_PREFIXES = {
"测试环境": 1,
@@ -247,13 +281,12 @@ def load_config() -> dict:
config["branches"] = ["main"]
if "upload" not in config:
config["upload"] = DEFAULT_UPLOAD
if "versions" not in config:
config["versions"] = DEFAULT_VERSIONS.copy()
versions_changed = _ensure_versions(config)
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
prefixes_changed = _ensure_server_id_prefixes(config)
overrides_changed = _ensure_special_app_prefix_overrides(config)
keys_changed = _ensure_upload_keys(config)
if prefixes_changed or overrides_changed or keys_changed:
if versions_changed or prefixes_changed or overrides_changed or keys_changed:
save_config(config)
# 迁移旧式目录皮肤为 ZIP
if _migrate_old_themes(config):
@@ -606,32 +639,62 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
# 版本号配置 API
@router.get("/versions")
async def get_versions():
"""获取服务端统一管理的 App_Ver 和 Build_Ver"""
"""获取服务端统一管理的 App_Ver 及其当前构建号。
build_ver 为当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
仅供展示;实际递增在 App_Store 打包时按版本号独立进行。
"""
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 {}
return {
"app_ver": versions.get("app_ver", DEFAULT_VERSIONS["app_ver"]),
"build_ver": versions.get("build_ver", DEFAULT_VERSIONS["build_ver"]),
"app_ver": app_ver,
"build_ver": f"{app_ver}.{int(build_map.get(app_ver, 0))}",
"build_map": build_map,
}
@router.put("/versions")
async def update_versions(data: dict):
"""更新 App_Ver,并将 Build_Ver 重置为对应的 .0。"""
"""更新 App_Ver,并可选地手动设置该版本号的构建号。
每个 App_Ver 的构建号独立保存在 build_map 中各自递增,因此来回切换版本号
(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
- 不传 build_ver:保留该版本号已记录的构建号,仅更新当前 App_Ver。
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
"""
app_ver = data.get("app_ver", "").strip()
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
build_ver = f"{app_ver}.0"
build_ver = data.get("build_ver")
build_no = None
if build_ver is not None and str(build_ver).strip():
build_ver = str(build_ver).strip()
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", build_ver):
raise HTTPException(status_code=400, detail="Build_Ver 必须为四段数字,例如 2.196.0.3")
prefix, _, last = build_ver.rpartition(".")
if prefix != app_ver:
raise HTTPException(status_code=400, detail=f"Build_Ver 前三位须与 App_Ver 一致(应为 {app_ver}.x")
build_no = int(last)
async with _config_lock:
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
versions = config.setdefault("versions", DEFAULT_VERSIONS.copy())
versions["app_ver"] = app_ver
build_map = versions.setdefault("build_map", {})
if build_no is not None:
build_map[app_ver] = build_no
save_config(config)
return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver}
return {
"message": "版本号已更新",
"app_ver": app_ver,
"build_ver": f"{app_ver}.{int(build_map.get(app_ver, 0))}",
}
# 上传配置 API
+11 -13
View File
@@ -613,23 +613,21 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
app_ver = versions.get("app_ver", "2.180.0")
build_ver = versions.get("build_ver", "2.180.0.0")
# App_Store 打包时自动递增 Build_Ver 第四位版本号
# 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
build_map = versions.get("build_map", {}) or {}
build_ver = f"{app_ver}.{int(build_map.get(app_ver, 0))}"
# App_Store 打包时,在该版本号自己的构建号上 +1(回退到旧版本号也从它自己的最大值继续)
if task.build_type == "App_Store":
async with _config_lock:
parts = build_ver.split(".")
if len(parts) >= 4:
parts[3] = str(int(parts[3]) + 1)
else:
parts.append("1")
build_ver = ".".join(parts)
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
versions = config.setdefault("versions", {"app_ver": app_ver, "build_map": {}})
build_map = versions.setdefault("build_map", {})
n = int(build_map.get(app_ver, 0)) + 1
build_map[app_ver] = n
build_ver = f"{app_ver}.{n}"
from ..routers.config import save_config
save_config(config)