fix: 修复 OSS 删除与历史筛选

This commit is contained in:
shenlei 2026-07-20 17:09:28 +09:00
parent 44405e589d
commit 13366ab3b4
6 changed files with 88 additions and 22 deletions

View File

@ -180,7 +180,11 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
build_config = json.loads(task.config_json) build_config = json.loads(task.config_json)
from .config import load_config from .config import load_config
from ..services.distribution import DistributionError, delete_published_artifacts from ..services.distribution import DistributionError, delete_published_artifacts
delete_published_artifacts(build_config, load_config().get("upload", {})) delete_published_artifacts(
build_config,
load_config().get("upload", {}),
task.oss_url,
)
except (json.JSONDecodeError, DistributionError) as exc: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc

View File

@ -5,7 +5,7 @@ import posixpath
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote, unquote, urlparse
import httpx import httpx
@ -115,16 +115,53 @@ def _delete_oss(config: dict, remote_paths: list[str]):
raise DistributionError(f"OSS 删除失败: {relative_path}") raise DistributionError(f"OSS 删除失败: {relative_path}")
def delete_published_artifacts(build_config: dict, upload_config: dict): def _remote_paths_from_published_url(
"""删除任务对应的远端分发产物。""" build_config: dict, upload_config: dict, published_url: str,
) -> list[str] | None:
"""从已保存的公开链接还原实际对象键,兼容历史命名规则。"""
parsed = urlparse(published_url)
if not parsed.path:
return None
remote_path = unquote(parsed.path).lstrip("/")
# base_url 允许带路径前缀;该前缀是公开地址的一部分,不属于 OSS 对象键。
base_url = upload_config.get("oss", {}).get("base_url", "")
base = urlparse(base_url)
if base_url and base.netloc == parsed.netloc:
base_path = unquote(base.path).strip("/")
if base_path and remote_path.startswith(f"{base_path}/"):
remote_path = remote_path[len(base_path) + 1:]
path = Path(remote_path)
if path.suffix not in {".ipa", ".plist", ".html", ".png"}:
return None
if build_config.get("BUILD_TYPE") == "App_Store":
return [str(path.with_suffix(".ipa"))]
return [str(path.with_suffix(suffix)) for suffix in (".ipa", ".plist", ".html", ".png")]
def delete_published_artifacts(
build_config: dict, upload_config: dict, published_url: str = "",
):
"""删除任务对应的远端分发产物。
优先按任务保存的下载链接还原对象键这样即使后续升级了文件命名
规则或历史快照含有新的分支字段仍会删除当时实际上传的文件
"""
mode = upload_config.get("mode", "") mode = upload_config.get("mode", "")
if mode not in {"oss", "webdav"}: if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式") raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config) remote_paths = None
remote_paths = [ipa_remote] if mode == "oss" and published_url:
if build_config.get("BUILD_TYPE") != "App_Store": remote_paths = _remote_paths_from_published_url(
remote_paths.extend([manifest_remote, html_remote, qr_remote]) build_config, upload_config, published_url,
)
if not remote_paths:
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 = _delete_oss if mode == "oss" else _delete_webdav
deleter(upload_config, remote_paths) deleter(upload_config, remote_paths)

View File

@ -78,7 +78,7 @@ describe('HistoryView.vue', () => {
expect(wrapper.text()).toContain('等待中') expect(wrapper.text()).toContain('等待中')
}) })
it('按打包类型过滤', async () => { it('版本号筛选为下拉选项', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
@ -86,7 +86,9 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[2].setValue('Ad_Hoc') expect(selects).toHaveLength(3)
expect(wrapper.text()).not.toContain('全部类型')
await selects[1].setValue('2.196.0')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
@ -101,14 +103,14 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[3].setValue('failed') await selects[2].setValue('failed')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(1) // App2 expect(rows.length).toBe(1) // App2
}) })
it('按 App 名称和版本号筛选', async () => { it('按 App 名称和版本号下拉选项筛选', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
@ -117,7 +119,7 @@ describe('HistoryView.vue', () => {
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[0].setValue('App1') await selects[0].setValue('App1')
await wrapper.find('.version-filter').setValue('2.196') await wrapper.find('.version-filter').setValue('2.196.0')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')

View File

@ -8,11 +8,9 @@
<option value="">全部 App</option> <option value="">全部 App</option>
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option> <option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
</select> </select>
<input v-model.trim="filterVersion" class="filter-select version-filter" type="search" placeholder="筛选 App 版本号"> <select v-model="filterVersion" class="filter-select version-filter">
<select v-model="filterBuildType" class="filter-select"> <option value="">全部版本</option>
<option value="">全部类型</option> <option v-for="version in appVersions" :key="version" :value="version">{{ version }}</option>
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
</select> </select>
<select v-model="filterStatus" class="filter-select"> <select v-model="filterStatus" class="filter-select">
<option value="">全部状态</option> <option value="">全部状态</option>
@ -172,7 +170,6 @@ const authFetch = (url, options = {}) => {
} }
return fetch(url, options) return fetch(url, options)
} }
const filterBuildType = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const filterAppName = ref('') const filterAppName = ref('')
const filterVersion = ref('') const filterVersion = ref('')
@ -193,6 +190,9 @@ const getAppVersion = (task) => {
} }
const appNames = computed(() => [...new Set(tasks.value.map(task => task.app_name).filter(Boolean))].sort()) const appNames = computed(() => [...new Set(tasks.value.map(task => task.app_name).filter(Boolean))].sort())
const appVersions = computed(() => [...new Set(
tasks.value.map(task => task.app_version).filter(version => version && version !== '-')
)].sort((a, b) => b.localeCompare(a, undefined, { numeric: true })))
const showQrPreview = (task) => { const showQrPreview = (task) => {
qrPreview.value = task qrPreview.value = task
@ -203,8 +203,7 @@ const filteredTasks = computed(() => {
// //
if (!filterStatus.value && task.status === 'cancelled') return false if (!filterStatus.value && task.status === 'cancelled') return false
if (filterAppName.value && task.app_name !== filterAppName.value) return false if (filterAppName.value && task.app_name !== filterAppName.value) return false
if (filterVersion.value && !task.app_version.toLowerCase().includes(filterVersion.value.toLowerCase())) return false if (filterVersion.value && task.app_version !== filterVersion.value) return false
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
if (filterStatus.value && task.status !== filterStatus.value) return false if (filterStatus.value && task.status !== filterStatus.value) return false
return true return true
}) })

View File

@ -173,10 +173,11 @@ def test_delete_completed_task_removes_remote_artifacts(client, tmp_config):
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import Task from backend.models import Task
oss_url = "https://files.example.com/test/iOS/1_2_0_0_0_main.html"
task = Task( task = Task(
id="completed-task", app_id="1", app_name="测试App", build_type="Ad_Hoc", id="completed-task", app_id="1", app_name="测试App", build_type="Ad_Hoc",
scheme_id="1", scheme_name="readoor31", branch="main", status="completed", scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
oss_url="https://files.example.com/test/iOS/1_2_0_0_0_main.html", oss_url=oss_url,
config_json=json.dumps({"APPID": "1", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "test"}), config_json=json.dumps({"APPID": "1", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "test"}),
) )
db = SessionLocal() db = SessionLocal()
@ -189,6 +190,7 @@ def test_delete_completed_task_removes_remote_artifacts(client, tmp_config):
assert resp.status_code == 200 assert resp.status_code == 200
delete.assert_called_once() delete.assert_called_once()
assert delete.call_args.args[2] == oss_url
assert client.get("/api/tasks/completed-task").status_code == 404 assert client.get("/api/tasks/completed-task").status_code == 404

View File

@ -97,3 +97,25 @@ def test_delete_adhoc_artifacts_deletes_all_remote_files():
"readoor/iOS/100_2_0_0_0_main_adhoc.html", "readoor/iOS/100_2_0_0_0_main_adhoc.html",
"readoor/iOS/100_2_0_0_0_main_adhoc.png", "readoor/iOS/100_2_0_0_0_main_adhoc.png",
] ]
def test_delete_uses_saved_url_for_legacy_artifact_name():
# 旧任务已有 SOURCE_BRANCH 快照,但上传时仍采用未带分支的旧命名。
# 不能再根据当前命名规则推算,否则 OSS 会对不存在的键返回成功。
config = {
"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main",
"BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "readoor",
}
with patch("backend.services.distribution._delete_oss") as delete:
delete_published_artifacts(
config,
{"mode": "oss", "oss": {"base_url": "https://files.example.com"}},
"https://files.example.com/readoor/iOS/100_2_0_0_0.html",
)
assert delete.call_args.args[1] == [
"readoor/iOS/100_2_0_0_0.ipa",
"readoor/iOS/100_2_0_0_0.plist",
"readoor/iOS/100_2_0_0_0.html",
"readoor/iOS/100_2_0_0_0.png",
]