原来 master 与 develop 共用一条 release 轨,版本号和构建号绑在一起。 现改为每个上架分支各有一条轨道(轨道名即分支名),自测分支仍共用 feature 轨。 - 轨道记录新增 kind(release/feature),branch_track 存轨道名 - 旧的共用 release 轨及更早的单轨结构,加载时自动按上架分支拆开, 已用构建号原样复制,拆分后各分支从原来的号继续递增 - 构建号递增时跨上架轨取同一 App_Ver 的最大值 +1:App Store 的构建号 唯一性按 MARKETING_VERSION 全局判定,避免两分支同版本号时重号被拒 - 分支管理页轨道选择改为「上架(本分支独立版本号)/ 自测(共用)」, 打包设置页按上架分支分块填写版本号 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJaZ913U5GVdTkYHiRpvDU
1067 lines
39 KiB
Python
1067 lines
39 KiB
Python
"""配置管理 API"""
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import zipfile
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..models import BuildConfig
|
||
from ..schemas import BuildConfigUpdate
|
||
from ..config import (
|
||
BUILD_BASE_DIR,
|
||
BOOTSTRAP_CONFIG_PATH,
|
||
AUTOMATION_DIR,
|
||
CONFIG_JSON_PATH,
|
||
DEFAULT_MAX_CONCURRENT_BUILDS,
|
||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||
SKINS_DIR,
|
||
)
|
||
from ..deps import get_current_user, require_admin
|
||
|
||
|
||
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
|
||
"""普通用户只可管理 Apps;版本号、其余配置及凭据只允许管理员访问。"""
|
||
path = request.url.path.rstrip("/")
|
||
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
|
||
return user
|
||
return require_admin(user)
|
||
|
||
|
||
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(_require_config_permission)])
|
||
|
||
_config_lock = asyncio.Lock()
|
||
|
||
# 默认上传配置
|
||
DEFAULT_UPLOAD = {
|
||
"mode": "oss",
|
||
"oss": {
|
||
"access_key_id": "",
|
||
"access_key_secret": "",
|
||
"endpoint": "oss-cn-beijing.aliyuncs.com",
|
||
"bucket_name": "",
|
||
"base_url": "",
|
||
},
|
||
"webdav": {
|
||
"server_url": "",
|
||
"username": "",
|
||
"password": "",
|
||
"base_path": "/ios-builds",
|
||
"public_url": "",
|
||
},
|
||
"dingtalk": {
|
||
"enabled": False,
|
||
"webhook_url": "",
|
||
"secret": "",
|
||
"at_mobiles": [],
|
||
"at_mobiles_app_store": [],
|
||
},
|
||
}
|
||
|
||
# 默认服务器环境配置
|
||
DEFAULT_SERVERS = {
|
||
"测试环境": {
|
||
"api": "https://api3-dev.readoor.cn",
|
||
"assDom": "applinks:dev-data1.readoor.cn",
|
||
"universalLink": "https://dev-data1.readoor.cn",
|
||
"app_id_prefix": 1,
|
||
},
|
||
"正式环境": {
|
||
"api": "https://api3.readoor.cn",
|
||
"assDom": "applinks:data1.readoor.cn",
|
||
"universalLink": "https://data1.readoor.cn",
|
||
"app_id_prefix": 2,
|
||
},
|
||
"华师大环境": {
|
||
"api": "https://api3.ecnupress.com.cn",
|
||
"assDom": "applinks:data1.ecnupress.com.cn",
|
||
"universalLink": "https://data1.ecnupress.com.cn",
|
||
"app_id_prefix": 3,
|
||
},
|
||
"外教环境": {
|
||
"api": "https://weread-api3.sflep.com/api3",
|
||
"assDom": "applinks:wereadossda.sflep.com",
|
||
"universalLink": "https://wereadossda.sflep.com",
|
||
"app_id_prefix": 4,
|
||
}
|
||
}
|
||
|
||
DEFAULT_APP_VER = "2.180.0"
|
||
|
||
# 版本轨道:每个上架分支(master / develop / release-* 等)各有一条独立轨道,
|
||
# 轨道名即分支名,App_Store 打包时构建号自增;所有自测分支共用一条 feature 轨,
|
||
# 构建号永不自增。各轨道的 App_Ver 互不影响。
|
||
KIND_RELEASE = "release"
|
||
KIND_FEATURE = "feature"
|
||
TRACK_FEATURE = "feature" # 自测轨的固定轨道名
|
||
LEGACY_RELEASE_TRACK = "release" # 旧版 master/develop 共用的上架轨,迁移时按分支拆开
|
||
|
||
# 按名字推断轨道的默认规则;config["branch_track"] 中的显式配置优先。
|
||
RELEASE_BRANCH_NAMES = {"master", "main", "develop", "dev"}
|
||
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-")
|
||
|
||
|
||
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 _release_track_name(branch: str) -> str:
|
||
"""上架轨道名即分支名;与固定的自测轨重名时加前缀避让。"""
|
||
return f"release:{branch}" if branch == TRACK_FEATURE else branch
|
||
|
||
|
||
def _default_track_for_branch(branch: str) -> str:
|
||
"""按分支名推断所属版本轨道名。"""
|
||
return _release_track_name(branch) if _is_release_branch(branch) else TRACK_FEATURE
|
||
|
||
|
||
def _new_track(app_ver: str = DEFAULT_APP_VER, kind: str = KIND_RELEASE) -> dict:
|
||
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
|
||
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
|
||
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
|
||
return {"kind": kind, "app_ver": app_ver, "build_map": {}}
|
||
|
||
|
||
def _seed_release_track(tracks: dict, legacy: dict = None) -> dict:
|
||
"""新建上架轨的初值:优先沿用旧共用轨(含已用构建号),否则沿用任一上架轨的 App_Ver。"""
|
||
if legacy:
|
||
return {"kind": KIND_RELEASE, "app_ver": legacy["app_ver"], "build_map": dict(legacy["build_map"])}
|
||
for track in tracks.values():
|
||
if track.get("kind") == KIND_RELEASE:
|
||
return _new_track(track.get("app_ver") or DEFAULT_APP_VER)
|
||
return _new_track()
|
||
|
||
|
||
def _ensure_track(config: dict, track_name: str) -> dict:
|
||
"""确保轨道存在;新建的上架轨沿用已有上架轨的 App_Ver,构建号从 .0 起。"""
|
||
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
|
||
track = tracks.get(track_name)
|
||
if not isinstance(track, dict):
|
||
track = tracks[track_name] = (
|
||
_new_track(kind=KIND_FEATURE) if track_name == TRACK_FEATURE
|
||
else _seed_release_track(tracks)
|
||
)
|
||
return track
|
||
|
||
|
||
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}
|
||
old = versions.get("build_ver")
|
||
if isinstance(old, str):
|
||
parts = old.split(".")
|
||
if len(parts) >= 4 and parts[3].isdigit():
|
||
build_map[".".join(parts[:3])] = int(parts[3])
|
||
versions.pop("app_ver", None)
|
||
versions.pop("build_map", None)
|
||
versions.pop("build_ver", None)
|
||
return {"app_ver": app_ver, "build_map": build_map}
|
||
|
||
|
||
def _ensure_versions(config: dict) -> bool:
|
||
"""确保 versions 为「每个上架分支一条轨 + 一条 feature 轨」结构,并迁移旧配置。
|
||
|
||
旧式单轨 ``{"app_ver": ..., "build_map": ...}`` 和旧的 master/develop 共用
|
||
``release`` 轨,都会按上架分支拆成各自独立的轨道,并复制已用构建号,
|
||
使拆分后每个分支都从原来的构建号继续递增。
|
||
"""
|
||
versions = config.get("versions")
|
||
changed = False
|
||
if not isinstance(versions, dict):
|
||
versions = {}
|
||
config["versions"] = versions
|
||
changed = True
|
||
|
||
legacy = None # 需要拆分到各上架分支的旧共用轨
|
||
tracks = versions.get("tracks")
|
||
if not isinstance(tracks, dict):
|
||
legacy = _legacy_single_track(versions)
|
||
tracks = {}
|
||
versions["tracks"] = tracks
|
||
changed = True
|
||
else:
|
||
# 迁移后的轨道都带 kind;没有 kind 的 release 轨即旧的共用上架轨
|
||
old = tracks.get(LEGACY_RELEASE_TRACK)
|
||
if isinstance(old, dict) and "kind" not in old:
|
||
build_map = old.get("build_map")
|
||
legacy = {
|
||
"app_ver": old.get("app_ver") or DEFAULT_APP_VER,
|
||
"build_map": build_map if isinstance(build_map, dict) else {},
|
||
}
|
||
tracks.pop(LEGACY_RELEASE_TRACK)
|
||
changed = True
|
||
|
||
feature = tracks.get(TRACK_FEATURE)
|
||
if not isinstance(feature, dict):
|
||
tracks[TRACK_FEATURE] = _new_track((legacy or {}).get("app_ver") or DEFAULT_APP_VER, KIND_FEATURE)
|
||
changed = True
|
||
|
||
for name, track in tracks.items():
|
||
if not isinstance(track, dict):
|
||
track = tracks[name] = _new_track()
|
||
changed = True
|
||
kind = KIND_FEATURE if name == TRACK_FEATURE else KIND_RELEASE
|
||
if track.get("kind") != kind:
|
||
track["kind"] = kind
|
||
changed = True
|
||
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 []:
|
||
name = branch_track.get(branch)
|
||
if name == LEGACY_RELEASE_TRACK and LEGACY_RELEASE_TRACK not in tracks:
|
||
# 旧的共用上架轨 → 该分支自己的轨
|
||
name = _release_track_name(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:
|
||
tracks[name] = _seed_release_track(tracks, legacy)
|
||
changed = True
|
||
|
||
return changed
|
||
|
||
|
||
def _primary_track_name(config: dict) -> str:
|
||
"""主上架轨:第一个上架分支所属的轨道;没有上架分支时退回 feature 轨。
|
||
|
||
仅用于顶层 app_ver/build_ver 的兼容字段和未指定轨道时的缺省值。
|
||
"""
|
||
for branch in config.get("branches", []) or []:
|
||
name = get_track_name(config, branch)
|
||
if name != TRACK_FEATURE:
|
||
return name
|
||
return TRACK_FEATURE
|
||
|
||
|
||
def get_track_name(config: dict, branch: str) -> str:
|
||
"""取分支所属的版本轨道名。"""
|
||
name = (config.get("branch_track") or {}).get(branch)
|
||
tracks = (config.get("versions") or {}).get("tracks") or {}
|
||
if isinstance(name, str) and name in tracks:
|
||
return name
|
||
if name == LEGACY_RELEASE_TRACK: # 尚未迁移的旧配置
|
||
return _release_track_name(branch)
|
||
return _default_track_for_branch(branch)
|
||
|
||
|
||
def get_track(config: dict, track_name: str) -> dict:
|
||
"""取某条轨道的版本记录副本(不存在时返回默认值;只读,不写回配置)。
|
||
|
||
未迁移的旧记录没有 kind,按轨道名补上:只有固定的 feature 轨是自测轨。
|
||
"""
|
||
tracks = (config.get("versions") or {}).get("tracks") or {}
|
||
kind = KIND_FEATURE if track_name == TRACK_FEATURE else KIND_RELEASE
|
||
track = tracks.get(track_name)
|
||
if not isinstance(track, dict):
|
||
return _new_track(kind=kind)
|
||
return {**track, "kind": track.get("kind") or 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) -> str:
|
||
"""把用户传入的轨道值解析为轨道名。
|
||
|
||
兼容按类型指定:``release`` 表示该分支自己的上架轨,``feature`` 表示共用自测轨;
|
||
也可直接传已存在的轨道名(多个分支共用同一条版本线)。
|
||
"""
|
||
value = (track or "").strip()
|
||
if not value:
|
||
return _default_track_for_branch(branch)
|
||
if value == KIND_FEATURE:
|
||
return TRACK_FEATURE
|
||
if value == KIND_RELEASE:
|
||
return _release_track_name(branch)
|
||
if value in ((config.get("versions") or {}).get("tracks") or {}):
|
||
return value
|
||
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {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,
|
||
"正式环境": 2,
|
||
"华师大环境": 3,
|
||
"外教环境": 4,
|
||
}
|
||
|
||
|
||
def _ensure_server_id_prefixes(config: dict) -> bool:
|
||
"""为旧配置补齐环境 ID 前缀和下一个可分配前缀。"""
|
||
changed = False
|
||
servers = config.get("servers", {})
|
||
used_prefixes = set()
|
||
for name, server in servers.items():
|
||
prefix = server.get("app_id_prefix")
|
||
if prefix is None and name in LEGACY_SERVER_PREFIXES:
|
||
prefix = LEGACY_SERVER_PREFIXES[name]
|
||
server["app_id_prefix"] = prefix
|
||
changed = True
|
||
if isinstance(prefix, int) and prefix > 0:
|
||
used_prefixes.add(prefix)
|
||
|
||
next_prefix = max(used_prefixes, default=0) + 1
|
||
for server in servers.values():
|
||
prefix = server.get("app_id_prefix")
|
||
if not isinstance(prefix, int) or prefix <= 0:
|
||
server["app_id_prefix"] = next_prefix
|
||
used_prefixes.add(next_prefix)
|
||
next_prefix += 1
|
||
changed = True
|
||
if not isinstance(config.get("next_app_id_prefix"), int) or config["next_app_id_prefix"] < next_prefix:
|
||
config["next_app_id_prefix"] = next_prefix
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
def _ensure_special_app_prefix_overrides(config: dict) -> bool:
|
||
"""将英汉大词典的历史 1xx 规则迁移为显式的 App 前缀覆盖。"""
|
||
changed = False
|
||
for app in config.get("apps", {}).values():
|
||
if "英汉大词典" in app.get("name", "") and not app.get("app_id_prefix_override"):
|
||
app["app_id_prefix_override"] = 1
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
def _apply_special_app_prefix_override(app: dict) -> None:
|
||
"""英汉大词典沿用历史 1xx 编号段。"""
|
||
if "英汉大词典" in app.get("name", "") and not app.get("app_id_prefix_override"):
|
||
app["app_id_prefix_override"] = 1
|
||
|
||
|
||
def _normalize_tencent_app_id(app: dict) -> None:
|
||
"""腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)。"""
|
||
value = str(app.get("tencent") or "").strip()
|
||
if not value:
|
||
return
|
||
app["tencent"] = value if value.startswith("tencent") else f"tencent{value}"
|
||
|
||
|
||
def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
|
||
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
|
||
prefix = app.get("app_id_prefix_override")
|
||
if prefix in (None, ""):
|
||
server = servers.get(app.get("server", ""), {})
|
||
prefix = server.get("app_id_prefix")
|
||
try:
|
||
prefix = int(prefix)
|
||
except (TypeError, ValueError):
|
||
prefix = 0
|
||
if prefix <= 0:
|
||
raise HTTPException(status_code=400, detail="请选择已配置 Apps ID 规则的服务器环境")
|
||
|
||
prefix_text = str(prefix)
|
||
serials = [
|
||
int(app_id[len(prefix_text):])
|
||
for app_id in apps
|
||
if (app_id.isdigit() and app_id.startswith(prefix_text)
|
||
and len(app_id) == len(prefix_text) + 2)
|
||
]
|
||
next_serial = max(serials, default=-1) + 1
|
||
if next_serial > 99:
|
||
raise HTTPException(status_code=400, detail=f"Apps ID 前缀 {prefix} 的编号已用完")
|
||
return f"{prefix}{next_serial:02d}"
|
||
|
||
|
||
def _ensure_upload_keys(config: dict) -> bool:
|
||
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
|
||
import uuid
|
||
changed = False
|
||
for app_id, app in config.get("apps", {}).items():
|
||
if not app.get("upload_key"):
|
||
app["upload_key"] = uuid.uuid4().hex[:8]
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
def _migrate_old_themes(config: dict) -> bool:
|
||
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更。
|
||
|
||
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
|
||
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
|
||
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
|
||
"""
|
||
if config.get("_legacy_themes_migrated"):
|
||
return False
|
||
themes_dir = AUTOMATION_DIR / "themes"
|
||
for app_id, app in config.get("apps", {}).items():
|
||
upload_key = app.get("upload_key", "")
|
||
if not upload_key:
|
||
continue
|
||
for cert_type, cert in app.get("certificates", {}).items():
|
||
theme = cert.get("theme", "")
|
||
if not theme:
|
||
continue
|
||
# 已经是 ZIP 皮肤(upload_key 目录下有对应 ZIP),跳过
|
||
if (SKINS_DIR / upload_key / f"{theme}.zip").exists():
|
||
continue
|
||
# 在服务自带的默认皮肤目录下查找旧主题
|
||
skin_name = Path(theme).name # "AutoPacking/ymh" → "ymh", "ymh" → "ymh"
|
||
theme_dir = themes_dir / skin_name
|
||
if not theme_dir.is_dir():
|
||
continue
|
||
app_skins_dir = SKINS_DIR / upload_key
|
||
app_skins_dir.mkdir(parents=True, exist_ok=True)
|
||
zip_path = app_skins_dir / f"{skin_name}.zip"
|
||
# 将目录打包为 ZIP
|
||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
for root, _, files in os.walk(theme_dir):
|
||
for f in files:
|
||
file_path = Path(root) / f
|
||
arcname = file_path.relative_to(theme_dir.parent)
|
||
zf.write(file_path, arcname)
|
||
# 更新 skins 列表和 theme 引用
|
||
skins = app.get("skins", [])
|
||
if f"{skin_name}.zip" not in skins:
|
||
skins.append(f"{skin_name}.zip")
|
||
app["skins"] = skins
|
||
if theme != skin_name:
|
||
cert["theme"] = skin_name
|
||
config["_legacy_themes_migrated"] = True
|
||
return True
|
||
|
||
|
||
def load_config() -> dict:
|
||
"""读取 config.json"""
|
||
if not CONFIG_JSON_PATH.exists():
|
||
# 首次部署:优先从项目根目录下的 config.json 导入
|
||
if BOOTSTRAP_CONFIG_PATH.exists():
|
||
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
|
||
else:
|
||
return {
|
||
"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS,
|
||
"next_app_id_prefix": 5, "branches": ["main"], "upload": DEFAULT_UPLOAD,
|
||
}
|
||
with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
|
||
config = json.load(f)
|
||
# 确保必要字段存在
|
||
if "servers" not in config:
|
||
config["servers"] = DEFAULT_SERVERS
|
||
if "branches" not in config:
|
||
config["branches"] = ["main"]
|
||
if "upload" not in config:
|
||
config["upload"] = DEFAULT_UPLOAD
|
||
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 versions_changed or prefixes_changed or overrides_changed or keys_changed:
|
||
save_config(config)
|
||
# 迁移旧式目录皮肤为 ZIP
|
||
if _migrate_old_themes(config):
|
||
save_config(config)
|
||
return config
|
||
|
||
|
||
def save_config(config: dict):
|
||
"""保存 config.json(自动备份)"""
|
||
if CONFIG_JSON_PATH.exists():
|
||
backup_path = CONFIG_JSON_PATH.with_suffix(".json.bak")
|
||
shutil.copy2(CONFIG_JSON_PATH, backup_path)
|
||
with open(CONFIG_JSON_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||
|
||
|
||
def get_build_config_value(db: Session, key: str, default=None) -> str:
|
||
"""获取打包配置值"""
|
||
config = db.query(BuildConfig).filter(BuildConfig.key == key).first()
|
||
return config.value if config else default
|
||
|
||
|
||
@router.get("")
|
||
async def get_full_config():
|
||
"""获取完整 config.json"""
|
||
return load_config()
|
||
|
||
|
||
@router.put("")
|
||
async def update_full_config(config: dict):
|
||
"""更新整个 config.json"""
|
||
async with _config_lock:
|
||
save_config(config)
|
||
return {"message": "配置已更新"}
|
||
|
||
|
||
@router.get("/apps")
|
||
async def get_apps():
|
||
"""获取 apps 配置"""
|
||
config = load_config()
|
||
return config.get("apps", {})
|
||
|
||
|
||
@router.post("/apps")
|
||
async def create_app(app: dict):
|
||
"""新增 app"""
|
||
import uuid
|
||
async with _config_lock:
|
||
config = load_config()
|
||
apps = config.get("apps", {})
|
||
|
||
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
|
||
_apply_special_app_prefix_override(app)
|
||
_normalize_tencent_app_id(app)
|
||
new_id = _next_app_id(apps, config.get("servers", {}), app)
|
||
|
||
# 自动生成 upload_key
|
||
if not app.get("upload_key"):
|
||
app["upload_key"] = uuid.uuid4().hex[:8]
|
||
|
||
apps[new_id] = app
|
||
config["apps"] = apps
|
||
save_config(config)
|
||
return {"id": new_id, "upload_key": app["upload_key"], "message": "App 已创建"}
|
||
|
||
|
||
@router.put("/apps/{app_id}")
|
||
async def update_app(app_id: str, app: dict):
|
||
"""更新指定 app"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
apps = config.get("apps", {})
|
||
|
||
if app_id not in apps:
|
||
raise HTTPException(status_code=404, detail="App 不存在")
|
||
|
||
_normalize_tencent_app_id(app)
|
||
apps[app_id] = app
|
||
config["apps"] = apps
|
||
save_config(config)
|
||
return {"message": "App 已更新"}
|
||
|
||
|
||
@router.delete("/apps/{app_id}")
|
||
async def delete_app(app_id: str):
|
||
"""删除指定 app"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
apps = config.get("apps", {})
|
||
|
||
if app_id not in apps:
|
||
raise HTTPException(status_code=404, detail="App 不存在")
|
||
|
||
del apps[app_id]
|
||
config["apps"] = apps
|
||
save_config(config)
|
||
return {"message": "App 已删除"}
|
||
|
||
|
||
def _find_app_by_upload_key(config: dict, upload_key: str):
|
||
"""根据 upload_key 查找 app,返回 (app_id, app) 或 (None, None)"""
|
||
for app_id, app in config.get("apps", {}).items():
|
||
if app.get("upload_key") == upload_key:
|
||
return app_id, app
|
||
return None, None
|
||
|
||
|
||
@router.get("/skins/{upload_key}")
|
||
async def list_skins(upload_key: str):
|
||
"""列出指定 app 的皮肤包"""
|
||
config = load_config()
|
||
_, app = _find_app_by_upload_key(config, upload_key)
|
||
if not app:
|
||
raise HTTPException(status_code=404, detail="App 不存在")
|
||
|
||
app_skins_dir = SKINS_DIR / upload_key
|
||
skins = []
|
||
if app_skins_dir.exists():
|
||
for f in sorted(app_skins_dir.iterdir()):
|
||
if f.suffix.lower() == ".zip":
|
||
skins.append({
|
||
"name": f.name,
|
||
"size": f.stat().st_size,
|
||
})
|
||
return skins
|
||
|
||
|
||
@router.post("/skins/{upload_key}")
|
||
async def upload_skin(upload_key: str, file: UploadFile = File(...)):
|
||
"""上传皮肤包(ZIP 文件)"""
|
||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
||
|
||
config = load_config()
|
||
app_id, _ = _find_app_by_upload_key(config, upload_key)
|
||
if not app_id:
|
||
raise HTTPException(status_code=404, detail="App 不存在")
|
||
|
||
app_skins_dir = SKINS_DIR / upload_key
|
||
app_skins_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
dest = app_skins_dir / file.filename
|
||
with open(dest, "wb") as f:
|
||
content = await file.read()
|
||
f.write(content)
|
||
|
||
# 更新 config.json 中的 skins 列表
|
||
async with _config_lock:
|
||
config = load_config()
|
||
skins = config["apps"][app_id].get("skins", [])
|
||
if file.filename not in skins:
|
||
skins.append(file.filename)
|
||
config["apps"][app_id]["skins"] = skins
|
||
save_config(config)
|
||
|
||
return {"message": "皮肤包上传成功", "name": file.filename}
|
||
|
||
|
||
@router.put("/skins/{upload_key}/{skin_name}")
|
||
async def replace_skin(upload_key: str, skin_name: str, file: UploadFile = File(...)):
|
||
"""替换已有皮肤包内容,保持原文件名不变。
|
||
|
||
APP 配置的 theme 通过文件名(去掉 .zip)引用皮肤包,若上传新文件名会新增一个皮肤而非更新,
|
||
导致原引用失效。此接口就地覆盖指定皮肤,确保被引用的皮肤可以更新。
|
||
"""
|
||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
||
|
||
config = load_config()
|
||
app_id, _ = _find_app_by_upload_key(config, upload_key)
|
||
if not app_id:
|
||
raise HTTPException(status_code=404, detail="App 不存在")
|
||
|
||
app_skins_dir = SKINS_DIR / upload_key
|
||
skin_path = app_skins_dir / skin_name
|
||
if not skin_path.exists():
|
||
raise HTTPException(status_code=404, detail="皮肤包不存在")
|
||
|
||
content = await file.read()
|
||
with open(skin_path, "wb") as f:
|
||
f.write(content)
|
||
|
||
return {"message": "皮肤包已更新", "name": skin_name}
|
||
|
||
|
||
@router.delete("/skins/{upload_key}/{skin_name}")
|
||
async def delete_skin(upload_key: str, skin_name: str):
|
||
"""删除指定皮肤包"""
|
||
config = load_config()
|
||
app_id, _ = _find_app_by_upload_key(config, upload_key)
|
||
if not app_id:
|
||
raise HTTPException(status_code=404, detail="App 不存在")
|
||
|
||
app_skins_dir = SKINS_DIR / upload_key
|
||
skin_path = app_skins_dir / skin_name
|
||
if not skin_path.exists():
|
||
raise HTTPException(status_code=404, detail="皮肤包不存在")
|
||
|
||
skin_path.unlink()
|
||
|
||
# 更新 config.json 中的 skins 列表
|
||
async with _config_lock:
|
||
config = load_config()
|
||
skins = config["apps"][app_id].get("skins", [])
|
||
if skin_name in skins:
|
||
skins.remove(skin_name)
|
||
config["apps"][app_id]["skins"] = skins
|
||
save_config(config)
|
||
|
||
return {"message": "皮肤包已删除"}
|
||
|
||
|
||
@router.get("/schemes")
|
||
async def get_schemes():
|
||
"""获取 schemes 配置"""
|
||
config = load_config()
|
||
return config.get("schemes", {})
|
||
|
||
|
||
@router.post("/schemes")
|
||
async def create_scheme(scheme: dict):
|
||
"""新增 scheme"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
schemes = config.get("schemes", {})
|
||
|
||
numeric_keys = [int(k) for k in schemes.keys() if k.isdigit()]
|
||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||
|
||
schemes[new_id] = scheme
|
||
config["schemes"] = schemes
|
||
save_config(config)
|
||
return {"id": new_id, "message": "Scheme 已创建"}
|
||
|
||
|
||
@router.put("/schemes/{scheme_id}")
|
||
async def update_scheme(scheme_id: str, scheme: dict):
|
||
"""更新指定 scheme"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
schemes = config.get("schemes", {})
|
||
|
||
if scheme_id not in schemes:
|
||
raise HTTPException(status_code=404, detail="Scheme 不存在")
|
||
|
||
schemes[scheme_id] = scheme
|
||
config["schemes"] = schemes
|
||
save_config(config)
|
||
return {"message": "Scheme 已更新"}
|
||
|
||
|
||
@router.delete("/schemes/{scheme_id}")
|
||
async def delete_scheme(scheme_id: str):
|
||
"""删除指定 scheme"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
schemes = config.get("schemes", {})
|
||
|
||
if scheme_id not in schemes:
|
||
raise HTTPException(status_code=404, detail="Scheme 不存在")
|
||
|
||
del schemes[scheme_id]
|
||
config["schemes"] = schemes
|
||
save_config(config)
|
||
return {"message": "Scheme 已删除"}
|
||
|
||
|
||
# 分支管理 API
|
||
@router.get("/branches")
|
||
async def get_branches():
|
||
"""获取分支列表"""
|
||
config = load_config()
|
||
return config.get("branches", ["main"])
|
||
|
||
|
||
@router.post("/branches")
|
||
async def add_branch(data: dict):
|
||
"""新增分支,可指定所属版本轨道(缺省按分支名推断)。
|
||
|
||
track 传 release 表示这个分支用自己独立的上架版本号,feature 表示共用自测版本号;
|
||
也可直接传已有轨道名,让该分支与另一个分支共用同一条版本线。
|
||
"""
|
||
name = data.get("name", "").strip()
|
||
if not name:
|
||
raise HTTPException(status_code=400, detail="分支名称不能为空")
|
||
|
||
async with _config_lock:
|
||
config = load_config()
|
||
branches = config.get("branches", ["main"])
|
||
if name in branches:
|
||
raise HTTPException(status_code=400, detail="分支已存在")
|
||
track = resolve_track(config, name, data.get("track"))
|
||
branches.append(name)
|
||
config["branches"] = branches
|
||
config.setdefault("branch_track", {})[name] = track
|
||
_ensure_track(config, track)
|
||
save_config(config)
|
||
return {"message": "分支已添加", "track": track}
|
||
|
||
|
||
@router.put("/branches/track")
|
||
async def update_branch_track(data: dict):
|
||
"""修改分支所属的版本轨道(release=该分支独立的上架版本号,feature=共用自测版本号)。
|
||
|
||
改成 release 时会为这个分支建一条以分支名命名的轨道(初值沿用已有上架轨的 App_Ver),
|
||
此后它的版本号与其它分支互不影响。分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
|
||
"""
|
||
branch = (data.get("branch") or "").strip()
|
||
if not (data.get("track") or "").strip():
|
||
raise HTTPException(status_code=400, detail="未指定版本轨道")
|
||
|
||
async with _config_lock:
|
||
config = load_config()
|
||
if branch not in config.get("branches", []):
|
||
raise HTTPException(status_code=404, detail="分支不存在")
|
||
track = resolve_track(config, branch, data.get("track"))
|
||
config.setdefault("branch_track", {})[branch] = track
|
||
_ensure_track(config, 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:
|
||
config = load_config()
|
||
branches = config.get("branches", ["main"])
|
||
if branch_name not in branches:
|
||
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": "分支已删除"}
|
||
|
||
|
||
@router.get("/build")
|
||
async def get_build_settings(db: Session = Depends(get_db)):
|
||
"""获取打包设置"""
|
||
return {
|
||
"max_concurrent_builds": int(get_build_config_value(db, "max_concurrent_builds", DEFAULT_MAX_CONCURRENT_BUILDS)),
|
||
"build_dir_retention_hours": int(get_build_config_value(db, "build_dir_retention_hours", DEFAULT_BUILD_DIR_RETENTION_HOURS)),
|
||
"build_base_dir": get_build_config_value(db, "build_base_dir", str(BUILD_BASE_DIR)),
|
||
}
|
||
|
||
|
||
@router.put("/build")
|
||
async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depends(get_db)):
|
||
"""更新打包设置"""
|
||
if settings.max_concurrent_builds is not None:
|
||
config = db.query(BuildConfig).filter(BuildConfig.key == "max_concurrent_builds").first()
|
||
if config:
|
||
config.value = str(settings.max_concurrent_builds)
|
||
else:
|
||
db.add(BuildConfig(key="max_concurrent_builds", value=str(settings.max_concurrent_builds)))
|
||
|
||
if settings.build_dir_retention_hours is not None:
|
||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||
if config:
|
||
config.value = str(settings.build_dir_retention_hours)
|
||
else:
|
||
db.add(BuildConfig(key="build_dir_retention_hours", value=str(settings.build_dir_retention_hours)))
|
||
|
||
if settings.build_base_dir is not None:
|
||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_base_dir").first()
|
||
if config:
|
||
config.value = settings.build_base_dir
|
||
else:
|
||
db.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir))
|
||
|
||
db.commit()
|
||
|
||
# 运行时更新并发数
|
||
if settings.max_concurrent_builds is not None:
|
||
from ..services.build_queue import build_queue
|
||
build_queue.update_max_concurrent(settings.max_concurrent_builds)
|
||
|
||
return {"message": "打包设置已更新"}
|
||
|
||
|
||
# 版本号配置 API
|
||
@router.get("/versions")
|
||
async def get_versions():
|
||
"""获取各条版本轨道的 App_Ver 及其当前构建号。
|
||
|
||
每个上架分支(master / develop 等)一条独立轨道,轨道名即分支名;自测分支共用 feature 轨。
|
||
build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
|
||
仅供展示;实际递增只发生在上架轨的 App_Store 打包时,feature 轨永不自增。
|
||
顶层 app_ver / build_ver 为主上架轨的镜像,保留给旧客户端。
|
||
"""
|
||
config = load_config()
|
||
tracks = {
|
||
name: {
|
||
"kind": KIND_FEATURE if name == TRACK_FEATURE else KIND_RELEASE,
|
||
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
|
||
"build_ver": track_build_ver(track),
|
||
"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 {
|
||
**{k: v for k, v in tracks.get(primary, {}).items() if k != "kind"},
|
||
"tracks": tracks,
|
||
"primary_track": primary,
|
||
"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 的构建号在所属轨道内独立保存于 build_map 各自递增,因此来回切换
|
||
版本号(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
|
||
|
||
- track:轨道名,上架分支为分支名(如 master、develop),自测为 feature;缺省为主上架轨。
|
||
- 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。
|
||
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
|
||
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
|
||
"""
|
||
track_name = (data.get("track") or "").strip()
|
||
|
||
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 = 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()
|
||
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
|
||
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
|
||
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": track_build_ver(track),
|
||
}
|
||
|
||
|
||
# 上传配置 API
|
||
@router.get("/upload")
|
||
async def get_upload_config():
|
||
"""获取上传配置"""
|
||
config = load_config()
|
||
return config.get("upload", DEFAULT_UPLOAD)
|
||
|
||
|
||
@router.put("/upload")
|
||
async def update_upload_config(upload_data: dict):
|
||
"""更新上传配置"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
config["upload"] = upload_data
|
||
save_config(config)
|
||
return {"message": "上传配置已更新"}
|
||
|
||
|
||
# 服务器环境管理 API
|
||
@router.get("/servers")
|
||
async def get_servers():
|
||
"""获取服务器环境配置"""
|
||
config = load_config()
|
||
return config.get("servers", DEFAULT_SERVERS)
|
||
|
||
|
||
@router.post("/servers")
|
||
async def create_server(server_data: dict):
|
||
"""新增服务器环境"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
servers = config.get("servers", DEFAULT_SERVERS)
|
||
|
||
name = server_data.get("name", "").strip()
|
||
if not name:
|
||
raise HTTPException(status_code=400, detail="环境名称不能为空")
|
||
if name in servers:
|
||
raise HTTPException(status_code=400, detail="环境名称已存在")
|
||
|
||
prefix = config.get("next_app_id_prefix", 1)
|
||
servers[name] = {
|
||
"api": server_data.get("api", ""),
|
||
"assDom": server_data.get("assDom", ""),
|
||
"universalLink": server_data.get("universalLink", ""),
|
||
"app_id_prefix": prefix,
|
||
}
|
||
config["servers"] = servers
|
||
config["next_app_id_prefix"] = prefix + 1
|
||
save_config(config)
|
||
return {"message": "服务器环境已创建"}
|
||
|
||
|
||
@router.put("/servers/{server_name}")
|
||
async def update_server(server_name: str, server_data: dict):
|
||
"""更新服务器环境"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
servers = config.get("servers", DEFAULT_SERVERS)
|
||
|
||
if server_name not in servers:
|
||
raise HTTPException(status_code=404, detail="环境不存在")
|
||
|
||
# 如果名称变了,需要删除旧的
|
||
new_name = server_data.get("name", server_name).strip()
|
||
if new_name != server_name:
|
||
if new_name in servers:
|
||
raise HTTPException(status_code=400, detail="新环境名称已存在")
|
||
servers[new_name] = servers.pop(server_name)
|
||
else:
|
||
new_name = server_name
|
||
|
||
servers[new_name] = {
|
||
"api": server_data.get("api", ""),
|
||
"assDom": server_data.get("assDom", ""),
|
||
"universalLink": server_data.get("universalLink", ""),
|
||
"app_id_prefix": servers[new_name].get("app_id_prefix"),
|
||
}
|
||
config["servers"] = servers
|
||
save_config(config)
|
||
return {"message": "服务器环境已更新"}
|
||
|
||
|
||
@router.delete("/servers/{server_name}")
|
||
async def delete_server(server_name: str):
|
||
"""删除服务器环境"""
|
||
async with _config_lock:
|
||
config = load_config()
|
||
servers = config.get("servers", DEFAULT_SERVERS)
|
||
|
||
if server_name not in servers:
|
||
raise HTTPException(status_code=404, detail="环境不存在")
|
||
|
||
# 检查是否有 App 在使用此环境
|
||
apps = config.get("apps", {})
|
||
for app in apps.values():
|
||
if app.get("server") == server_name:
|
||
raise HTTPException(status_code=400, detail=f"无法删除:有 App 正在使用此环境")
|
||
|
||
del servers[server_name]
|
||
config["servers"] = servers
|
||
save_config(config)
|
||
return {"message": "服务器环境已删除"}
|