2 Commits
Author SHA1 Message Date
shenlei 624f534b0f feat: 限制历史操作为管理员可见 2026-07-20 17:18:00 +09:00
shenlei 13366ab3b4 fix: 修复 OSS 删除与历史筛选 2026-07-20 17:09:28 +09:00
6 changed files with 113 additions and 33 deletions
+5 -1
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
+44 -7
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)
+28 -13
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import HistoryView from '../views/HistoryView.vue' import HistoryView from '../views/HistoryView.vue'
@@ -22,6 +23,15 @@ function createMockRouter() {
}) })
} }
function mountHistory(admin = false) {
return mount(HistoryView, {
global: {
plugins: [createMockRouter()],
provide: { isAdmin: ref(admin) },
},
})
}
describe('HistoryView.vue', () => { describe('HistoryView.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -78,7 +88,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 +96,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 +113,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 +129,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')
@@ -127,10 +139,7 @@ describe('HistoryView.vue', () => {
}) })
it('已完成任务显示下载按钮', async () => { it('已完成任务显示下载按钮', async () => {
const router = createMockRouter() const wrapper = mountHistory(true)
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
})
await flushPromises() await flushPromises()
// 第一行(App1, completed)应该有 dSYM 和下载按钮 // 第一行(App1, completed)应该有 dSYM 和下载按钮
@@ -141,6 +150,15 @@ describe('HistoryView.vue', () => {
expect(firstRow.find('.qr-thumb').exists()).toBe(true) expect(firstRow.find('.qr-thumb').exists()).toBe(true)
}) })
it('普通用户不显示历史操作按钮', async () => {
const wrapper = mountHistory(false)
await flushPromises()
expect(wrapper.find('.action-btns').exists()).toBe(false)
expect(wrapper.text()).not.toContain('删除')
expect(wrapper.text()).not.toContain('日志')
})
it('空列表显示提示', async () => { it('空列表显示提示', async () => {
fetch.mockResolvedValue({ json: () => Promise.resolve([]) }) fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
const router = createMockRouter() const router = createMockRouter()
@@ -179,10 +197,7 @@ describe('HistoryView.vue', () => {
}) })
it('点击查看日志打开日志弹窗', async () => { it('点击查看日志打开日志弹窗', async () => {
const router = createMockRouter() const wrapper = mountHistory(true)
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
})
await flushPromises() await flushPromises()
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志') const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
+11 -11
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>
@@ -33,7 +31,7 @@
<th>Scheme</th> <th>Scheme</th>
<th>状态</th> <th>状态</th>
<th>下载地址</th> <th>下载地址</th>
<th>操作</th> <th v-if="isAdmin">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -61,7 +59,7 @@
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
<td class="action-btns"> <td v-if="isAdmin" class="action-btns">
<button v-if="task.has_log" class="action-btn" @click="viewLogs(task.id)">日志</button> <button v-if="task.has_log" class="action-btn" @click="viewLogs(task.id)">日志</button>
<button v-if="task.status === 'completed' && task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button> <button v-if="task.status === 'completed' && task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button>
<button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button> <button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
@@ -69,7 +67,7 @@
</td> </td>
</tr> </tr>
<tr v-if="!filteredTasks.length"> <tr v-if="!filteredTasks.length">
<td colspan="7" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td> <td :colspan="isAdmin ? 7 : 6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -163,6 +161,7 @@
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken', () => '') const getToken = inject('getToken', () => '')
const isAdmin = inject('isAdmin', ref(false))
const tasks = ref([]) const tasks = ref([])
const authFetch = (url, options = {}) => { const authFetch = (url, options = {}) => {
@@ -172,7 +171,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 +191,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 +204,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
}) })
+3 -1
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
+22
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",
]