Compare commits
2
Commits
44405e589d
..
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
624f534b0f | ||
|
|
13366ab3b4 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +115,49 @@ 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 上传方式")
|
||||||
|
|
||||||
|
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)
|
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config)
|
||||||
remote_paths = [ipa_remote]
|
remote_paths = [ipa_remote]
|
||||||
if build_config.get("BUILD_TYPE") != "App_Store":
|
if build_config.get("BUILD_TYPE") != "App_Store":
|
||||||
|
|||||||
@@ -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() === '日志')
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user