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)
from .config import load_config
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:
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc

View File

@ -5,7 +5,7 @@ import posixpath
import re
import shutil
from pathlib import Path
from urllib.parse import quote
from urllib.parse import quote, unquote, urlparse
import httpx
@ -115,16 +115,53 @@ def _delete_oss(config: dict, remote_paths: list[str]):
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", "")
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])
remote_paths = None
if mode == "oss" and published_url:
remote_paths = _remote_paths_from_published_url(
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(upload_config, remote_paths)

View File

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

View File

@ -8,11 +8,9 @@
<option value="">全部 App</option>
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
</select>
<input v-model.trim="filterVersion" class="filter-select version-filter" type="search" placeholder="筛选 App 版本号">
<select v-model="filterBuildType" class="filter-select">
<option value="">全部类型</option>
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
<select v-model="filterVersion" class="filter-select version-filter">
<option value="">全部版本</option>
<option v-for="version in appVersions" :key="version" :value="version">{{ version }}</option>
</select>
<select v-model="filterStatus" class="filter-select">
<option value="">全部状态</option>
@ -172,7 +170,6 @@ const authFetch = (url, options = {}) => {
}
return fetch(url, options)
}
const filterBuildType = ref('')
const filterStatus = ref('')
const filterAppName = 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 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) => {
qrPreview.value = task
@ -203,8 +203,7 @@ const filteredTasks = computed(() => {
//
if (!filterStatus.value && task.status === 'cancelled') 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 (filterBuildType.value && task.build_type !== filterBuildType.value) return false
if (filterVersion.value && task.app_version !== filterVersion.value) return false
if (filterStatus.value && task.status !== filterStatus.value) return false
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.models import Task
oss_url = "https://files.example.com/test/iOS/1_2_0_0_0_main.html"
task = Task(
id="completed-task", app_id="1", app_name="测试App", build_type="Ad_Hoc",
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"}),
)
db = SessionLocal()
@ -189,6 +190,7 @@ def test_delete_completed_task_removes_remote_artifacts(client, tmp_config):
assert resp.status_code == 200
delete.assert_called_once()
assert delete.call_args.args[2] == oss_url
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.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",
]