"""配置管理 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/main 沿用历史名称 release), # 各分支的 App_Ver 与构建号互不影响。轨道类型(kind)决定构建号行为: # release(上架)— App_Store 打包时自增;feature(自测)— 永不自增。 KIND_RELEASE = "release" KIND_FEATURE = "feature" KINDS = (KIND_RELEASE, KIND_FEATURE) TRACK_RELEASE = "release" # master/main 的轨道名(沿用旧名,历史构建号原地保留) TRACK_FEATURE = "feature" # 旧版所有自测分支共用的轨道名,迁移时按分支拆开 FIXED_TRACKS = (TRACK_RELEASE, TRACK_FEATURE) # 按名字推断轨道类型的默认规则;轨道记录里的 kind 为准,此处只用于新建轨道。 MASTER_BRANCH_NAMES = {"master", "main"} RELEASE_BRANCH_NAMES = MASTER_BRANCH_NAMES | {"develop", "dev"} 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: """分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。 分支恰好叫 release / feature(且不是 master)时加前缀避让,免得撞上固定轨道名。 """ if (branch or "").strip().lower() in MASTER_BRANCH_NAMES: return TRACK_RELEASE return f"branch:{branch}" if branch in FIXED_TRACKS else branch 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_track(tracks: dict, kind: str, legacy: dict = None) -> dict: """新建轨道的初值:优先沿用被拆分的旧共用轨(含已用构建号), 否则沿用任一同类型轨道的 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) def _ensure_track(config: dict, track_name: str, kind: str = None) -> dict: """确保轨道存在(新建时沿用同类型轨道的 App_Ver,构建号从 .0 起); 显式传 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 def _default_kind_for_track(track_name: str) -> str: """轨道记录缺 kind 时按轨道名推断(轨道名即分支名)。""" if track_name == TRACK_RELEASE: return KIND_RELEASE if track_name == TRACK_FEATURE: 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} 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 _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 {}, } 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 # 待拆分到各分支的旧共用轨(按类型各一条) 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): 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 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 _build_refs(config): name = branch_track.get(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 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: """取分支所属的版本轨道名。""" 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 return _default_track_for_branch(branch) def get_track(config: dict, track_name: str) -> dict: """取某条轨道的版本记录副本(不存在时返回默认值;只读,不写回配置)。 未迁移的旧记录没有 kind,按轨道名(即分支名)推断补上。 """ tracks = (config.get("versions") or {}).get("tracks") or {} track = tracks.get(track_name) kind = _default_kind_for_track(track_name) if not isinstance(track, dict): return _new_track(kind=kind) 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: """该轨道当前 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 表示上架用、 App_Store 打包时构建号自增,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="分支已存在") 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) config["branches"] = branches config.setdefault("branch_track", {})[name] = track _ensure_track(config, track, kind) save_config(config) return {"message": "分支已添加", "track": track} @router.put("/branches/track") async def update_branch_track(data: dict): """修改分支或 Tag 的版本轨道类型(release=上架,构建号自增;feature=自测,不自增)。 每个分支用自己的一条轨道,切换类型只改这条轨道的行为,版本号仍与其它分支互不影响。 分支名可能含 /(如 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 _build_refs(config): raise HTTPException(status_code=404, detail="分支不存在") track, kind = resolve_track(config, branch, data.get("track")) config.setdefault("branch_track", {})[branch] = track _ensure_track(config, track, kind) 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": "分支已删除"} # 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") 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": track.get("kind") if track.get("kind") in KINDS else _default_kind_for_track(name), "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 _build_refs(config) }, } @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": "服务器环境已删除"}