From 44405e589d07bb23260b314f507dbf4768e1b0c4 Mon Sep 17 00:00:00 2001 From: shenlei Date: Mon, 20 Jul 2026 16:55:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=89=93=E5=8C=85?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E4=B8=8E=E5=8E=86=E5=8F=B2=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/tasks.py | 25 ++++++++- backend/services/distribution.py | 62 +++++++++++++++++++++- frontend/src/__tests__/HistoryView.test.js | 44 ++++++++++----- frontend/src/views/HistoryView.vue | 60 ++++++++++++++------- tests/test_api_tasks.py | 45 ++++++++++++++++ tests/test_distribution.py | 30 +++++++++-- 6 files changed, 226 insertions(+), 40 deletions(-) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index e3f947c..4538e01 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -1,5 +1,6 @@ """任务 API""" import os +import json import shutil import tempfile import uuid @@ -164,6 +165,25 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)): if task.status in ("running", "pending"): raise HTTPException(status_code=400, detail="任务正在运行中,无法删除") + # 同一个 App / 版本 / 分支重复打包会覆盖并复用同一个远端文件; + # 只要仍有其他历史记录引用该下载地址,就不能删除远端产物。 + has_shared_artifact = bool( + task.oss_url and db.query(Task).filter( + Task.id != task.id, + Task.oss_url == task.oss_url, + ).first() + ) + if task.oss_url and not has_shared_artifact: + if not task.config_json: + raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端文件") + try: + build_config = json.loads(task.config_json) + from .config import load_config + from ..services.distribution import DistributionError, delete_published_artifacts + delete_published_artifacts(build_config, load_config().get("upload", {})) + except (json.JSONDecodeError, DistributionError) as exc: + raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc + # 删除日志文件 log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" if log_path.exists(): @@ -178,7 +198,10 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)): db.delete(task) db.commit() - return {"message": "记录已删除"} + message = "记录已删除" + if task.oss_url: + message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)" + return {"message": message} @router.get("/{task_id}/log") diff --git a/backend/services/distribution.py b/backend/services/distribution.py index da13376..3c93b43 100644 --- a/backend/services/distribution.py +++ b/backend/services/distribution.py @@ -2,6 +2,7 @@ import json import plistlib import posixpath +import re import shutil from pathlib import Path from urllib.parse import quote @@ -15,7 +16,12 @@ class DistributionError(Exception): def _artifact_stem(config: dict) -> str: version = config.get("VERSION", "0").replace(".", "_") - return f"{config.get('APPID', 'app')}_{version}" + # 历史任务快照没有 SOURCE_BRANCH,继续按旧文件名处理,确保删除旧记录时能清理到原产物。 + if "SOURCE_BRANCH" not in config: + return f"{config.get('APPID', 'app')}_{version}" + branch = re.sub(r"[^A-Za-z0-9._-]+", "_", config.get("SOURCE_BRANCH", "main")).strip("._-") + build_type = "appstore" if config.get("BUILD_TYPE") == "App_Store" else "adhoc" + return f"{config.get('APPID', 'app')}_{version}_{branch or 'main'}_{build_type}" def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]: @@ -70,6 +76,60 @@ def _remote_paths(config: dict) -> tuple[str, str, str, str]: ) +def _delete_webdav(config: dict, remote_paths: list[str]): + webdav = config.get("webdav", {}) + server_url = webdav.get("server_url", "").rstrip("/") + if not server_url or not webdav.get("username"): + raise DistributionError("WebDAV 配置不完整") + + base_path = webdav.get("base_path", "/ios-builds").strip("/") + auth = (webdav.get("username", ""), webdav.get("password", "")) + with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client: + for relative_path in remote_paths: + remote_path = posixpath.join(base_path, relative_path) + response = client.delete(f"{server_url}/{remote_path}") + # 404 代表文件已不存在,可视为清理完成。 + if response.status_code not in (200, 202, 204, 404): + raise DistributionError(f"WebDAV 删除失败: {relative_path} ({response.status_code})") + + +def _delete_oss(config: dict, remote_paths: list[str]): + oss = config.get("oss", {}) + required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"] + if any(not oss.get(key) for key in required): + raise DistributionError("OSS 配置不完整") + try: + import oss2 + except ImportError as exc: + raise DistributionError("未安装 oss2,请重新执行 ./deploy.sh build") from exc + + bucket = oss2.Bucket( + oss2.Auth(oss["access_key_id"], oss["access_key_secret"]), + oss["endpoint"], + oss["bucket_name"], + connect_timeout=30, + ) + for relative_path in remote_paths: + result = bucket.delete_object(relative_path) + if result.status // 100 != 2: + raise DistributionError(f"OSS 删除失败: {relative_path}") + + +def delete_published_artifacts(build_config: dict, upload_config: dict): + """删除任务对应的远端分发产物。""" + mode = upload_config.get("mode", "") + if mode not in {"oss", "webdav"}: + raise DistributionError("请选择 OSS 或 WebDAV 上传方式") + + ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config) + remote_paths = [ipa_remote] + if build_config.get("BUILD_TYPE") != "App_Store": + remote_paths.extend([manifest_remote, html_remote, qr_remote]) + + deleter = _delete_oss if mode == "oss" else _delete_webdav + deleter(upload_config, remote_paths) + + def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str): path = "" for segment in remote_path.strip("/").split("/")[:-1]: diff --git a/frontend/src/__tests__/HistoryView.test.js b/frontend/src/__tests__/HistoryView.test.js index 96c2da9..2cc5256 100644 --- a/frontend/src/__tests__/HistoryView.test.js +++ b/frontend/src/__tests__/HistoryView.test.js @@ -7,9 +7,9 @@ global.fetch = vi.fn() window.open = vi.fn() const mockTasks = [ - { id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' }, - { id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败', error_category: 'compilation' }, - { id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' }, + { id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', status: 'completed', created_at: '2024-01-01T10:00:00', config_json: '{"VERSION":"2.196.0"}', has_log: true, dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' }, + { id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', config_json: '{"VERSION":"2.195.0"}', error_message: '构建失败', error_category: 'compilation' }, + { id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00', config_json: '{"VERSION":"2.196.0"}' }, ] function createMockRouter() { @@ -86,7 +86,7 @@ describe('HistoryView.vue', () => { await flushPromises() const selects = wrapper.findAll('.filter-select') - await selects[0].setValue('Ad_Hoc') + await selects[2].setValue('Ad_Hoc') await wrapper.vm.$nextTick() const rows = wrapper.findAll('tbody tr') @@ -101,13 +101,31 @@ describe('HistoryView.vue', () => { await flushPromises() const selects = wrapper.findAll('.filter-select') - await selects[1].setValue('failed') + await selects[3].setValue('failed') await wrapper.vm.$nextTick() const rows = wrapper.findAll('tbody tr') expect(rows.length).toBe(1) // App2 }) + it('按 App 名称和版本号筛选', async () => { + const router = createMockRouter() + const wrapper = mount(HistoryView, { + global: { plugins: [router] }, + }) + await flushPromises() + + const selects = wrapper.findAll('.filter-select') + await selects[0].setValue('App1') + await wrapper.find('.version-filter').setValue('2.196') + await wrapper.vm.$nextTick() + + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(1) + expect(rows[0].text()).toContain('App1') + expect(rows[0].text()).toContain('2.196.0') + }) + it('已完成任务显示下载按钮', async () => { const router = createMockRouter() const wrapper = mount(HistoryView, { @@ -120,8 +138,7 @@ describe('HistoryView.vue', () => { const buttons = firstRow.findAll('.action-btn') const buttonTexts = buttons.map(b => b.text()) expect(buttonTexts).toContain('dSYM') - expect(buttonTexts).toContain('下载') - expect(buttonTexts).toContain('二维码') + expect(firstRow.find('.qr-thumb').exists()).toBe(true) }) it('空列表显示提示', async () => { @@ -148,7 +165,7 @@ describe('HistoryView.vue', () => { expect(wrapper.vm.statusText('failed')).toBe('失败') }) - it('时间仅显示月和日,Scheme 与状态单元格允许完整换行', async () => { + it('显示 App 版本号,Scheme 与状态单元格允许完整换行', async () => { const router = createMockRouter() const wrapper = mount(HistoryView, { global: { plugins: [router] }, @@ -156,13 +173,13 @@ describe('HistoryView.vue', () => { await flushPromises() expect(wrapper.vm.formatTime('2024-07-20T10:00:00')).toBe('7/20') + expect(wrapper.findAll('tbody tr')[0].text()).toContain('2.196.0') expect(wrapper.find('.scheme-cell').text()).toBe('readoor31OtherPayLongSchemeName') expect(wrapper.find('.status-cell').text()).toContain('已完成') }) - it('点击查看日志跳转', async () => { + it('点击查看日志打开日志弹窗', async () => { const router = createMockRouter() - router.push = vi.fn() const wrapper = mount(HistoryView, { global: { plugins: [router] }, }) @@ -171,7 +188,7 @@ describe('HistoryView.vue', () => { const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志') await logBtn.trigger('click') - expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } }) + expect(wrapper.find('.log-modal').exists()).toBe(true) }) it('点击二维码弹出弹窗', async () => { @@ -181,11 +198,10 @@ describe('HistoryView.vue', () => { }) await flushPromises() - const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码') - await qrBtn.trigger('click') + await wrapper.find('.qr-thumb').trigger('click') await wrapper.vm.$nextTick() - expect(wrapper.find('.qr-modal').exists()).toBe(true) + expect(wrapper.find('.qr-preview-modal').exists()).toBe(true) expect(wrapper.text()).toContain('下载二维码') }) }) diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue index 0243059..180f858 100644 --- a/frontend/src/views/HistoryView.vue +++ b/frontend/src/views/HistoryView.vue @@ -4,6 +4,11 @@

打包历史

+ +