diff --git a/CLAUDE.md b/CLAUDE.md index 35ec755..4c8a110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,11 +25,12 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu - `backend/deps.py` — JWT auth dependency used by all protected routes - `backend/config.py` — loads .env, defines all config constants - `frontend/src/views/` — BuildView, HistoryView, ConfigView -- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI +- `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI +- Versions are per-track: `release` (master/develop, build number auto-increments on App_Store) and `feature` (self-test, never increments); each branch's track lives in `config["branch_track"]` ## Conventions - All backend routes require JWT auth except `/api/auth/login` and `/api/health` -- Admin-only routes: user management (`/api/users/*`) +- Admin-only routes: user management (`/api/users/*`) and all of `/api/config/*` except `/api/config/apps*` - Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings) - Chinese UI and documentation throughout diff --git a/README.md b/README.md index 13cf4ce..20c78f0 100644 --- a/README.md +++ b/README.md @@ -187,10 +187,22 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token> | App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 | | Scheme 管理 | 打包 Scheme 配置 | | 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 | -| 分支管理 | 可打包的代码分支列表 | -| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码 | +| 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道 | +| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) | | 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 | +### 版本号与版本轨道 + +版本号按 **轨道(track)** 分开管理,两条轨道的 App_Ver 各自独立填写、互不影响: + +| 轨道 | 适用分支 | 构建号行为 | +|------|----------|-----------| +| `release`(上架) | master、develop(共用同一套版本号与构建号序列) | App_Store 打包时按当前 App_Ver 自动 +1 | +| `feature`(自测) | feature/* 等其他分支 | 永不自增,始终使用配置页填写的值(默认 `.0`) | + +- 分支归属默认按名字推断(master/main/develop/dev/release-*/hotfix-* → release,其余 → feature),可在「分支管理」页逐个改。 +- 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。 + ### 服务端自动化资源 项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。 @@ -277,7 +289,7 @@ logs/ | 角色 | 权限 | |------|------| | 管理员 | 所有功能 + 用户管理(增删改查、角色切换) | -| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) | +| 普通用户 | 打包、查看历史、管理 Apps 配置(不可修改版本号及其余配置、不可管理用户) | ### 管理用户 diff --git a/backend/routers/config.py b/backend/routers/config.py index 0df2d6f..2d49457 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -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), } diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 894a837..f0c12e4 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -601,29 +601,40 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict: """生成打包配置""" await log_streamer.emit_step(task_id, "生成配置") - from ..routers.config import load_config, _config_lock + from ..routers.config import ( + load_config, _config_lock, get_track, get_track_name, track_build_ver, + TRACK_RELEASE, _new_track, + ) config = load_config() apps = config.get("apps", {}) schemes = config.get("schemes", {}) - versions = config.get("versions", {}) app = apps.get(task.app_id, {}) scheme = schemes.get(task.scheme_id, {}) # 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。 - app_ver = versions.get("app_ver", "2.180.0") + # 按分支所属轨道取版本号:master/develop 走 release 轨(上架用),feature 走 feature 轨(自测用), + # 两条轨道的 App_Ver 各自独立。 + track_name = get_track_name(config, task.branch) + track = get_track(config, track_name) + app_ver = track.get("app_ver") or "2.180.0" # 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。 - build_map = versions.get("build_map", {}) or {} - build_ver = f"{app_ver}.{int(build_map.get(app_ver, 0))}" + build_ver = track_build_ver(track) + await log_streamer.emit( + task_id, + f"版本轨道: {track_name}({'上架,App_Store 打包自增' if track_name == TRACK_RELEASE else '自测,构建号不自增'})", + ) - # App_Store 打包时,在该版本号自己的构建号上 +1(回退到旧版本号也从它自己的最大值继续) - if task.build_type == "App_Store": + # release 轨的 App_Store 打包,在该版本号自己的构建号上 +1 + # (回退到旧版本号也从它自己的最大值继续);feature 轨始终使用手动设定的构建号。 + if track_name == TRACK_RELEASE and task.build_type == "App_Store": async with _config_lock: config = load_config() - versions = config.setdefault("versions", {"app_ver": app_ver, "build_map": {}}) - build_map = versions.setdefault("build_map", {}) + tracks = config.setdefault("versions", {}).setdefault("tracks", {}) + track = tracks.setdefault(track_name, _new_track(app_ver)) + build_map = track.setdefault("build_map", {}) n = int(build_map.get(app_ver, 0)) + 1 build_map[app_ver] = n build_ver = f"{app_ver}.{n}" diff --git a/config.example.json b/config.example.json index 5dd80cf..bfd9bb8 100644 --- a/config.example.json +++ b/config.example.json @@ -70,7 +70,10 @@ } }, "versions": { - "app_ver": "2.180.0", - "build_map": {} - } + "tracks": { + "release": { "app_ver": "2.180.0", "build_map": {} }, + "feature": { "app_ver": "2.180.0", "build_map": {} } + } + }, + "branch_track": { "master": "release", "develop": "release" } } diff --git a/frontend/src/__tests__/ConfigView.test.js b/frontend/src/__tests__/ConfigView.test.js index 9d04f49..bb7cd4c 100644 --- a/frontend/src/__tests__/ConfigView.test.js +++ b/frontend/src/__tests__/ConfigView.test.js @@ -24,7 +24,15 @@ function mockFetch(url) { '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, '/api/config/branches': ['main', 'dev'], '/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' }, - '/api/config/versions': { app_ver: '2.195.0', build_ver: '2.195.0.0' }, + '/api/config/versions': { + app_ver: '2.195.0', + build_ver: '2.195.0.0', + tracks: { + release: { app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} }, + feature: { app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} }, + }, + branch_track: { main: 'release', dev: 'release' }, + }, '/api/config': { apps: {}, schemes: {}, branches: ['main'] }, } return Promise.resolve({ @@ -168,20 +176,76 @@ describe('ConfigView.vue', () => { expect(window.alert).toHaveBeenCalledWith('设置已保存') }) - it('普通账号可修改版本号但看不到打包参数', async () => { + it('普通账号只能管理 Apps,看不到打包设置与版本号', async () => { const wrapper = mountConfigView({ admin: false }) await flushPromises() const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text()) - expect(menuTexts).toEqual(['Apps 配置', '打包设置']) + expect(menuTexts).toEqual(['Apps 配置']) + + expect(wrapper.text()).not.toContain('上架版本号') + expect(wrapper.text()).not.toContain('自测版本号') + expect(wrapper.text()).not.toContain('打包参数') + // 版本号接口为管理员专属,普通账号不再请求 + expect(fetch).not.toHaveBeenCalledWith('/api/config/versions', expect.anything()) + expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps']) + }) + + it('两条版本轨道各自独立展示与保存', async () => { + const calls = [] + fetch.mockImplementation((url, opts) => { + if (url === '/api/config/versions' && opts?.method === 'PUT') { + calls.push(JSON.parse(opts.body)) + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ app_ver: '2.101.0', build_ver: '2.101.0.0' }), + }) + } + return mockFetch(url) + }) + window.alert = vi.fn() + + const wrapper = mountConfigView() + await flushPromises() const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置') await buildMenu.trigger('click') await wrapper.vm.$nextTick() - expect(wrapper.text()).toContain('应用版本号') - expect(wrapper.text()).not.toContain('打包参数') - // 非管理员在打包设置页可见 App_Ver 与 Build_Ver 两个输入框 - expect(wrapper.findAll('input').length).toBe(2) + const inputs = wrapper.findAll('input[type="text"]') + expect(inputs[0].element.value).toBe('2.195.0') // release App_Ver + expect(inputs[2].element.value).toBe('2.100.0') // feature App_Ver + + await inputs[2].setValue('2.101.0') + const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存自测版本号') + await saveBtn.trigger('click') + await flushPromises() + + expect(calls).toEqual([{ track: 'feature', app_ver: '2.101.0', build_ver: '2.101.0.0' }]) + }) + + it('分支管理可切换版本轨道', async () => { + const calls = [] + fetch.mockImplementation((url, opts) => { + if (url === '/api/config/branches/track' && opts?.method === 'PUT') { + calls.push(JSON.parse(opts.body)) + return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) }) + } + return mockFetch(url) + }) + + const wrapper = mountConfigView() + await flushPromises() + + const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') + await branchesMenu.trigger('click') + await wrapper.vm.$nextTick() + + const select = wrapper.find('.config-table select') + expect(select.element.value).toBe('release') + await select.setValue('feature') + await flushPromises() + + expect(calls).toEqual([{ branch: 'main', track: 'feature' }]) }) }) diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index c84310c..4ecb2aa 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -15,7 +15,7 @@
管理可打包的代码分支,打包时从对应分支目录获取源码
++ 管理可打包的代码分支,打包时从对应分支目录获取源码。 + 版本轨道决定该分支使用哪一套版本号:上架(master/develop 共用,App_Store 打包构建号自增)、自测(feature 用,构建号不自增)。 +