feat(software-version): 安装包改浏览器直传 MinIO,支持批量删除
- 后端新增 /api/admin/version/presign、confirm、delete 端点;直传后服务端校验对象并写 web_config - OSS 层新增软件版本对象 presign PUT/大小校验/删除与 client 桶 URL 识别 - 后台前端上传改 presign→PUT→confirm 三段式直传,带进度条;成功提示精简为"发布成功" - 版本列表加勾选/表头全选、批量删除与单行删除;操作按钮样式对齐其他子菜单
This commit is contained in:
@@ -5,7 +5,7 @@ import { formatDateTime } from '@/utils/datetime'
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
|
import { fetchSoftwareVersions, uploadSoftwareVersion, deleteSoftwareVersions } from './version-api.ts'
|
||||||
import type { SoftwareVersionItem } from './version-dto.ts'
|
import type { SoftwareVersionItem } from './version-dto.ts'
|
||||||
import { isNonEmptyVersion } from './version-dto.ts'
|
import { isNonEmptyVersion } from './version-dto.ts'
|
||||||
|
|
||||||
@@ -29,8 +29,53 @@ watch(filteredItems, () => {
|
|||||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
|
||||||
|
const selectedIds = ref<Set<number>>(new Set())
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
function toggleSelectAllPage() {
|
||||||
|
const pageRows = pagedVersions.value
|
||||||
|
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (allOn) pageRows.forEach((row) => next.delete(row.id))
|
||||||
|
else pageRows.forEach((row) => next.add(row.id))
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
async function removeSelected() {
|
||||||
|
if (!selectedCount.value) return
|
||||||
|
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
|
||||||
|
const sample = rows.slice(0, 3).map((row) => row.version).join('、')
|
||||||
|
const summary = rows.length > 3 ? `${sample} 等 ${rows.length} 个` : sample
|
||||||
|
if (!window.confirm(`确认删除选中的 ${rows.length} 个版本(${summary})?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteSoftwareVersions(Array.from(selectedIds.value))
|
||||||
|
ElMessage.success(`已删除 ${rows.length} 个版本`)
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function removeOne(row: SoftwareVersionItem) {
|
||||||
|
if (!window.confirm(`确认删除版本 ${row.version}?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteSoftwareVersions([row.id])
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadVisible = ref(false)
|
const uploadVisible = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
const newVersion = ref('')
|
const newVersion = ref('')
|
||||||
const pickedFile = ref<File | null>(null)
|
const pickedFile = ref<File | null>(null)
|
||||||
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
||||||
@@ -63,6 +108,7 @@ function openUpload() {
|
|||||||
async function submitUpload() {
|
async function submitUpload() {
|
||||||
uploadMsg.value = ''
|
uploadMsg.value = ''
|
||||||
uploadMsgOk.value = false
|
uploadMsgOk.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
if (!isNonEmptyVersion(newVersion.value)) {
|
if (!isNonEmptyVersion(newVersion.value)) {
|
||||||
uploadMsg.value = '请填写版本号'
|
uploadMsg.value = '请填写版本号'
|
||||||
return
|
return
|
||||||
@@ -75,16 +121,23 @@ async function submitUpload() {
|
|||||||
uploadMsg.value = '仅支持 .zip 格式'
|
uploadMsg.value = '仅支持 .zip 格式'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (pickedFile.value.size > 512 * 1024 * 1024) {
|
||||||
|
uploadMsg.value = '文件超过允许的大小限制'
|
||||||
|
return
|
||||||
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
|
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||||
const version = created?.version || newVersion.value.trim()
|
await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name, (p) => {
|
||||||
const link = created?.fileUrl
|
uploadPercent.value = p
|
||||||
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
|
uploadMsg.value = p >= 100 ? '上传完成,正在登记版本...' : `正在上传:${p}%`
|
||||||
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
|
})
|
||||||
|
// 成功仅提示“发布成功”,弹窗留驻,不再展示版本号与链接。
|
||||||
|
uploadMsg.value = '发布成功'
|
||||||
uploadMsgOk.value = true
|
uploadMsgOk.value = true
|
||||||
newVersion.value = ''
|
newVersion.value = ''
|
||||||
pickedFile.value = null
|
pickedFile.value = null
|
||||||
|
uploadPercent.value = 0
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||||
@@ -103,6 +156,7 @@ onMounted(load)
|
|||||||
<h3>版本列表</h3>
|
<h3>版本列表</h3>
|
||||||
<div class="version-head-tools">
|
<div class="version-head-tools">
|
||||||
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
|
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
|
||||||
<button class="btn" type="button" @click="openUpload">上传新版本</button>
|
<button class="btn" type="button" @click="openUpload">上传新版本</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,32 +165,39 @@ onMounted(load)
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 120px">版本号</th>
|
<th style="width: 40px" class="th-select">
|
||||||
|
<input type="checkbox" :checked="pagedVersions.length > 0 && pagedVersions.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedVersions.length" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px">版本号</th>
|
||||||
<th>下载链接</th>
|
<th>下载链接</th>
|
||||||
<th style="width: 160px">创建时间</th>
|
<th style="width: 150px">创建时间</th>
|
||||||
<th style="width: 120px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="filteredItems.length">
|
<template v-if="filteredItems.length">
|
||||||
<tr v-for="row in pagedVersions" :key="row.version + row.id">
|
<tr v-for="row in pagedVersions" :key="row.version + row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
|
||||||
|
<td class="td-select">
|
||||||
|
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
|
||||||
|
</td>
|
||||||
<td>{{ row.version }}</td>
|
<td>{{ row.version }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
<td>
|
<td class="ops-cell">
|
||||||
<a v-if="row.fileUrl" class="btn btn-sm btn-secondary dl-btn" :href="row.fileUrl" download>下载</a>
|
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="4" class="empty-tip">加载中...</td>
|
<td colspan="5" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="4" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -157,6 +218,7 @@ onMounted(load)
|
|||||||
<p class="zip-hint">仅支持 .zip 格式</p>
|
<p class="zip-hint">仅支持 .zip 格式</p>
|
||||||
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
|
||||||
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -276,7 +338,7 @@ h3 {
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
.version-table-scroll > table {
|
.version-table-scroll > table {
|
||||||
min-width: 760px;
|
min-width: 840px;
|
||||||
}
|
}
|
||||||
.table-scroll th,
|
.table-scroll th,
|
||||||
.table-scroll td {
|
.table-scroll td {
|
||||||
@@ -338,6 +400,36 @@ h3 {
|
|||||||
color: #5b6f83;
|
color: #5b6f83;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-selected td {
|
||||||
|
background: #fdf0ee;
|
||||||
|
}
|
||||||
|
.th-select,
|
||||||
|
.td-select {
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 4px !important;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.th-select input,
|
||||||
|
.td-select input {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #b33a2e;
|
||||||
|
}
|
||||||
|
.upload-progress {
|
||||||
|
margin: 2px 0 10px;
|
||||||
|
}
|
||||||
.upload-msg {
|
.upload-msg {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,83 @@
|
|||||||
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
|
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
|
||||||
import { http } from '@/api/http'
|
import axios from 'axios'
|
||||||
|
import { http, unwrap } from '@/api/http'
|
||||||
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
|
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
|
||||||
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
||||||
|
|
||||||
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
||||||
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
|
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
|
||||||
|
export const SOFTWARE_VERSION_PRESIGN_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/presign`
|
||||||
|
export const SOFTWARE_VERSION_CONFIRM_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/confirm`
|
||||||
|
export const SOFTWARE_VERSION_DELETE_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/delete`
|
||||||
|
|
||||||
|
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
|
||||||
|
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
|
||||||
|
|
||||||
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
|
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
|
||||||
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
||||||
return parseSoftwareVersionList(data)
|
return parseSoftwareVersionList(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传客户端软件版本(multipart version + file);返回新版本行(含下载链接)。 */
|
export interface SoftwareVersionUploadTarget {
|
||||||
export async function uploadSoftwareVersion(version: string, file: Blob, filename = ''): Promise<SoftwareVersionItem | null> {
|
uploadUrl: string
|
||||||
const form = new FormData()
|
fileUrl: string
|
||||||
form.append('version', version)
|
version: string
|
||||||
form.append('file', file, filename || (file instanceof File ? file.name : 'version.zip'))
|
}
|
||||||
const { data } = await http.post<unknown>(SOFTWARE_VERSION_UPLOAD_ENDPOINT, form)
|
|
||||||
|
export interface SoftwareVersionUploadResult {
|
||||||
|
version: string
|
||||||
|
fileUrl: string
|
||||||
|
item: SoftwareVersionItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一步:向后端申请直传 PUT 预签名地址与公开下载 URL(登录会话签发,3 分钟有效)。 */
|
||||||
|
export async function requestSoftwareVersionPresign(version: string): Promise<SoftwareVersionUploadTarget> {
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_PRESIGN_ENDPOINT, null, { params: { version } })
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
|
||||||
|
if (!uploadUrl) {
|
||||||
|
throw new Error('后端未返回直传地址,请重试')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
|
||||||
|
version: typeof core.version === 'string' ? core.version : version.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第三步:直传完成后通知后端校验对象并写入版本记录(data.item 为新版本行)。 */
|
||||||
|
export async function confirmSoftwareVersion(version: string): Promise<SoftwareVersionItem | null> {
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_CONFIRM_ENDPOINT, null, { params: { version } })
|
||||||
return parseSoftwareVersionUpload(data)
|
return parseSoftwareVersionUpload(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按 id 批量删除版本(后端会顺带清理无引用对象的 MinIO 安装包);返回实际删除行数。 */
|
||||||
|
export async function deleteSoftwareVersions(ids: number[]): Promise<number> {
|
||||||
|
if (!ids.length) return 0
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_DELETE_ENDPOINT, ids)
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
return typeof core.deleted === 'number' ? core.deleted : ids.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传客户端软件版本(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
|
||||||
|
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
|
||||||
|
*/
|
||||||
|
export async function uploadSoftwareVersion(
|
||||||
|
version: string,
|
||||||
|
file: Blob,
|
||||||
|
_filename = '',
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<SoftwareVersionUploadResult> {
|
||||||
|
const target = await requestSoftwareVersionPresign(version)
|
||||||
|
await directPut.put(target.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress) return
|
||||||
|
const total = event.total || 0
|
||||||
|
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const item = await confirmSoftwareVersion(target.version)
|
||||||
|
return { version: target.version, fileUrl: item?.fileUrl || target.fileUrl, item }
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ test('align_software_version_page_wiring', () => {
|
|||||||
// 下载按钮:<a download> 强制下载(对齐 admin.js:6301)。
|
// 下载按钮:<a download> 强制下载(对齐 admin.js:6301)。
|
||||||
assert.match(page, /download/, '下载链接带 download 强制下载')
|
assert.match(page, /download/, '下载链接带 download 强制下载')
|
||||||
assert.match(page, /download\s*>\s*下载\s*<\/a>/, '操作列下载为强制下载链接')
|
assert.match(page, /download\s*>\s*下载\s*<\/a>/, '操作列下载为强制下载链接')
|
||||||
// 上传成功:弹窗保持打开,成功文案留驻可复制(对齐 admin.js:6484-6488)。
|
// 上传成功:弹窗保持打开,成功提示为“发布成功”(对齐新版精简提示)。
|
||||||
assert.match(page, /发布成功。版本:/, '成功文案留驻弹窗')
|
assert.match(page, /发布成功/, '成功提示留驻弹窗')
|
||||||
assert.match(page, /uploadMsg/, '成功/失败文案在弹窗内展示')
|
assert.match(page, /uploadMsg/, '成功/失败文案在弹窗内展示')
|
||||||
// 上传弹窗文案(对齐 admin.html:6094-6110)。
|
// 上传弹窗文案(对齐 admin.html:6094-6110)。
|
||||||
assert.match(page, /上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。/, '弹窗描述对齐')
|
assert.match(page, /上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。/, '弹窗描述对齐')
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ test('test_task_128_version_upload_invalid_input_rejected', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_128_version_upload_dependency_failure_returns_actionable_message', () => {
|
test('test_task_128_version_upload_dependency_failure_returns_actionable_message', () => {
|
||||||
// 依赖失败/提交走 adapter:上传表单纯逻辑;POST /api/admin/version multipart。
|
// 依赖失败/提交走 adapter:上传表单纯逻辑;浏览器直传(presign 签发 → PUT 到 MinIO)由 version-api 编排。
|
||||||
const mod = readSource('src/pages/records/version-upload-model.ts')
|
const mod = readSource('src/pages/records/version-upload-model.ts')
|
||||||
assert.equal(/axios|http\.|vue/.test(mod), false, '上传表单保持纯逻辑')
|
assert.equal(/axios|http\.|vue/.test(mod), false, '上传表单保持纯逻辑')
|
||||||
const api = readSource('src/pages/records/version-api.ts')
|
const api = readSource('src/pages/records/version-api.ts')
|
||||||
assert.match(api, /uploadSoftwareVersion/)
|
assert.match(api, /uploadSoftwareVersion/)
|
||||||
assert.match(api, /\/api\/admin\/version/)
|
assert.match(api, /\/api\/admin\/version/)
|
||||||
assert.match(api, /FormData/)
|
// 直传改造后不再走 multipart FormData,改 presign 预签名地址 + 独立 PUT 直传 MinIO。
|
||||||
|
assert.match(api, /presign/)
|
||||||
|
assert.match(api, /\.put\(/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ test('test_task_260_version_invalid_input_rejected', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_260_version_dependency_failure_returns_actionable_message', () => {
|
test('test_task_260_version_dependency_failure_returns_actionable_message', () => {
|
||||||
// 依赖失败:成功文案含版本与链接;上传经 API 适配并解析新版本;空态兜底。
|
// 依赖失败:成功提示精简为“发布成功”;上传经 API 适配并解析新版本;空态兜底。
|
||||||
const page = readSource('src/pages/records/RecordsSoftwareVersionPage.vue')
|
const page = readSource('src/pages/records/RecordsSoftwareVersionPage.vue')
|
||||||
assert.match(page, /发布成功。版本:/, '成功文案需含版本')
|
assert.match(page, /发布成功/, '成功提示留驻弹窗')
|
||||||
assert.match(page, /,链接:/, '成功文案需含链接')
|
assert.match(page, /正在上传/, '上传过程提示含进度')
|
||||||
assert.match(page, /暂无版本/, '空态文案')
|
assert.match(page, /暂无版本/, '空态文案')
|
||||||
const api = readSource('src/pages/records/version-api.ts')
|
const api = readSource('src/pages/records/version-api.ts')
|
||||||
assert.match(api, /parseSoftwareVersionUpload/, '上传适配解析新版本行')
|
assert.match(api, /parseSoftwareVersionUpload/, '上传适配解析新版本行')
|
||||||
|
|||||||
+69
-1
@@ -3,8 +3,10 @@ package com.nanri.aiimage.modules.file.service.oss;
|
|||||||
import com.nanri.aiimage.config.OssProperties;
|
import com.nanri.aiimage.config.OssProperties;
|
||||||
import io.minio.BucketExistsArgs;
|
import io.minio.BucketExistsArgs;
|
||||||
import io.minio.GetObjectArgs;
|
import io.minio.GetObjectArgs;
|
||||||
|
import io.minio.GetPresignedObjectUrlArgs;
|
||||||
import io.minio.MakeBucketArgs;
|
import io.minio.MakeBucketArgs;
|
||||||
import io.minio.MinioClient;
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.http.Method;
|
||||||
import io.minio.PutObjectArgs;
|
import io.minio.PutObjectArgs;
|
||||||
import io.minio.RemoveObjectArgs;
|
import io.minio.RemoveObjectArgs;
|
||||||
import io.minio.StatObjectArgs;
|
import io.minio.StatObjectArgs;
|
||||||
@@ -23,6 +25,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -99,6 +102,70 @@ public class OssStorageService {
|
|||||||
return (configured == null || configured.isBlank()) ? "client" : configured;
|
return (configured == null || configured.isBlank()) ? "client" : configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为软件版本安装包签发浏览器直传用的预签名 PUT URL(client 桶公开路径)。
|
||||||
|
* 签名 host 与反代入口一致(endpoint 即 public-endpoint oss.aishufu.top),浏览器对返回的 URL 直接 PUT 即可落对象。
|
||||||
|
*/
|
||||||
|
public String presignSoftwareVersionUpload(String objectKey, int expirySeconds) {
|
||||||
|
if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) {
|
||||||
|
throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return buildClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||||
|
.method(Method.PUT)
|
||||||
|
.bucket(softwareVersionBucket())
|
||||||
|
.object(objectKey)
|
||||||
|
.expiry(Math.max(60, expirySeconds), TimeUnit.SECONDS)
|
||||||
|
.build());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw storageFailure("presign", objectKey, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回软件版本对象的字节数;对象不存在返回 -1(直传完成后由服务端核对大小是否超限)。 */
|
||||||
|
public long softwareVersionObjectSize(String objectKey) {
|
||||||
|
if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) {
|
||||||
|
throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var stat = buildClient().statObject(StatObjectArgs.builder()
|
||||||
|
.bucket(softwareVersionBucket())
|
||||||
|
.object(objectKey)
|
||||||
|
.build());
|
||||||
|
return stat.size();
|
||||||
|
} catch (ErrorResponseException ex) {
|
||||||
|
if (isNotFound(ex)) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
throw storageFailure("stat", objectKey, ex);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw storageFailure("stat", objectKey, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成软件版本对象的公开下载 URL(与历史 file_url 同构:path-style client 桶)。 */
|
||||||
|
public String getSoftwareVersionDownloadUrl(String objectKey) {
|
||||||
|
if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) {
|
||||||
|
throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX);
|
||||||
|
}
|
||||||
|
return getPublicUrl(objectKey, softwareVersionBucket());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除软件版本对象(client 桶);确认环节发现超限等异常时清理残留,避免公共桶悬挂大对象。 */
|
||||||
|
public void removeSoftwareVersionObject(String objectKey) {
|
||||||
|
if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) {
|
||||||
|
throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
buildClient().removeObject(RemoveObjectArgs.builder()
|
||||||
|
.bucket(softwareVersionBucket())
|
||||||
|
.object(objectKey)
|
||||||
|
.build());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw storageFailure("delete", objectKey, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public String uploadText(String objectKey, String content) {
|
public String uploadText(String objectKey, String content) {
|
||||||
if (objectKey == null || objectKey.isBlank()) {
|
if (objectKey == null || objectKey.isBlank()) {
|
||||||
throw new IllegalArgumentException("objectKey must not be blank");
|
throw new IllegalArgumentException("objectKey must not be blank");
|
||||||
@@ -472,7 +539,8 @@ public class OssStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> configuredBuckets() {
|
private List<String> configuredBuckets() {
|
||||||
return Stream.of(ossProperties.getBucket(), imageVideoBucket(), digitalHumanBucket(), templateBucket())
|
return Stream.of(ossProperties.getBucket(), imageVideoBucket(), digitalHumanBucket(), templateBucket(),
|
||||||
|
softwareVersionBucket())
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.map(String::trim)
|
.map(String::trim)
|
||||||
.filter(bucket -> !bucket.isBlank())
|
.filter(bucket -> !bucket.isBlank())
|
||||||
|
|||||||
+33
@@ -13,11 +13,13 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,4 +55,35 @@ public class SoftwareVersionAdminController {
|
|||||||
Map<String, Object> result = softwareVersionService.uploadSoftwareVersion(version, file);
|
Map<String, Object> result = softwareVersionService.uploadSoftwareVersion(version, file);
|
||||||
return ApiResponse.success("上传成功", result);
|
return ApiResponse.success("上传成功", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/version/presign")
|
||||||
|
@Operation(summary = "签发软件版本直传预签名 URL", description = "返回浏览器直传 MinIO client 桶的 PUT 预签名地址;直传完成后调用 /version/confirm 校验落库")
|
||||||
|
public ApiResponse<Map<String, Object>> presignUploadVersion(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@Parameter(description = "版本号,如 3.0.67") @RequestParam(value = "version", required = false) String version) {
|
||||||
|
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||||
|
log.info("[software-version] 管理端签发版本直传预签名 operator={} version={}", operator.getUsername(), version);
|
||||||
|
return ApiResponse.success(softwareVersionService.presignSoftwareVersion(version));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/version/confirm")
|
||||||
|
@Operation(summary = "确认软件版本直传完成", description = "服务端核对 client 桶对象存在与大小并写 web_config,返回新版本行")
|
||||||
|
public ApiResponse<Map<String, Object>> confirmUploadVersion(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@Parameter(description = "版本号,如 3.0.67") @RequestParam(value = "version", required = false) String version) {
|
||||||
|
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||||
|
log.info("[software-version] 管理端确认版本直传完成 operator={} version={}", operator.getUsername(), version);
|
||||||
|
return ApiResponse.success("上传成功", Map.of("item", softwareVersionService.confirmSoftwareVersion(version)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/version/delete")
|
||||||
|
@Operation(summary = "批量删除客户端软件版本", description = "按 id 删除版本记录;关联 MinIO 安装包仅当无其它记录引用时清除")
|
||||||
|
public ApiResponse<Map<String, Object>> deleteVersions(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@Parameter(description = "要删除的版本记录 id 列表") @RequestBody List<Long> ids) {
|
||||||
|
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||||
|
log.info("[software-version] 管理端删除版本记录 operator={} ids={}", operator.getUsername(), ids);
|
||||||
|
int deleted = softwareVersionService.deleteSoftwareVersions(ids);
|
||||||
|
return ApiResponse.success("删除成功", Map.of("deleted", deleted));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+118
@@ -16,9 +16,11 @@ import java.nio.file.Files;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +38,9 @@ public class SoftwareVersionService {
|
|||||||
/** 与 Flask VERSION_UPLOAD_MAX_BYTES(默认 512MB)保持一致 */
|
/** 与 Flask VERSION_UPLOAD_MAX_BYTES(默认 512MB)保持一致 */
|
||||||
private static final long MAX_UPLOAD_BYTES = 512L * 1024 * 1024;
|
private static final long MAX_UPLOAD_BYTES = 512L * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 直传预签名 URL 有效期:3 分钟足够 106MB 级安装包上传,过期即失效(与后台上传超时口径一致)。 */
|
||||||
|
private static final int PRESIGN_EXPIRY_SECONDS = 180;
|
||||||
|
|
||||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||||
|
|
||||||
/** 对齐 Python re.sub(r'[^\w.\-]', '_')(\w 含中文等 Unicode 字符),仅版本号片段防注入 */
|
/** 对齐 Python re.sub(r'[^\w.\-]', '_')(\w 含中文等 Unicode 字符),仅版本号片段防注入 */
|
||||||
@@ -159,6 +164,119 @@ public class SoftwareVersionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为浏览器直传签发预签名 PUT URL(client 桶公开路径):客户端直接把 zip PUT 到返回的
|
||||||
|
* upload_url,完成后调用 {@link #confirmSoftwareVersion} 由服务端校验对象并写入 web_config。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> presignSoftwareVersion(String version) {
|
||||||
|
String normalizedVersion = requireVersion(version);
|
||||||
|
String objectKey = softwareVersionObjectKey(normalizedVersion);
|
||||||
|
log.info("[software-version] 签发版本直传预签名 version={} objectKey={} expirySeconds={}",
|
||||||
|
normalizedVersion, objectKey, PRESIGN_EXPIRY_SECONDS);
|
||||||
|
String uploadUrl = ossStorageService.presignSoftwareVersionUpload(objectKey, PRESIGN_EXPIRY_SECONDS);
|
||||||
|
String fileUrl = ossStorageService.getSoftwareVersionDownloadUrl(objectKey);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("version", normalizedVersion);
|
||||||
|
result.put("object_key", objectKey);
|
||||||
|
result.put("upload_url", uploadUrl);
|
||||||
|
result.put("file_url", fileUrl);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直传完成后确认落库:服务端重算 key、校验对象存在且不超限,写 web_config 并返回新行。 */
|
||||||
|
public Map<String, Object> confirmSoftwareVersion(String version) {
|
||||||
|
String normalizedVersion = requireVersion(version);
|
||||||
|
String objectKey = softwareVersionObjectKey(normalizedVersion);
|
||||||
|
long size = ossStorageService.softwareVersionObjectSize(objectKey);
|
||||||
|
if (size < 0) {
|
||||||
|
throw new BusinessException("对象存储中未找到版本包,请重新上传");
|
||||||
|
}
|
||||||
|
if (size > MAX_UPLOAD_BYTES) {
|
||||||
|
log.warn("[software-version] 直传对象超过大小上限,清理残留 version={} objectKey={} bytes={}",
|
||||||
|
normalizedVersion, objectKey, size);
|
||||||
|
ossStorageService.removeSoftwareVersionObject(objectKey);
|
||||||
|
throw new BusinessException("文件超过允许的大小限制");
|
||||||
|
}
|
||||||
|
log.info("[software-version] 直传对象校验通过,写入版本记录 version={} objectKey={} bytes={}",
|
||||||
|
normalizedVersion, objectKey, size);
|
||||||
|
SoftwareVersionEntity entity = new SoftwareVersionEntity();
|
||||||
|
entity.setVersion(normalizedVersion);
|
||||||
|
entity.setFileUrl(ossStorageService.getSoftwareVersionDownloadUrl(objectKey));
|
||||||
|
softwareVersionMapper.insert(entity);
|
||||||
|
SoftwareVersionEntity saved = softwareVersionMapper.selectById(entity.getId());
|
||||||
|
return toItemMap(saved == null ? entity : saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 批量删除版本记录;对应 MinIO 对象仅在删除后无任何剩余记录引用时才清除
|
||||||
|
* (历史上同一版本重复上传会共享同一个对象 key,不能因为删一行就误删仍在用的安装包)。
|
||||||
|
*/
|
||||||
|
public int deleteSoftwareVersions(List<Long> ids) {
|
||||||
|
List<Long> distinctIds = ids == null ? List.of() : ids.stream()
|
||||||
|
.filter(id -> id != null).distinct().toList();
|
||||||
|
if (distinctIds.isEmpty()) {
|
||||||
|
throw new BusinessException("请选择要删除的版本");
|
||||||
|
}
|
||||||
|
List<SoftwareVersionEntity> targets = softwareVersionMapper.selectBatchIds(distinctIds);
|
||||||
|
int deleted = 0;
|
||||||
|
for (Long id : distinctIds) {
|
||||||
|
deleted += softwareVersionMapper.deleteById(id);
|
||||||
|
}
|
||||||
|
Set<String> touchedUrls = new LinkedHashSet<>();
|
||||||
|
for (SoftwareVersionEntity target : targets) {
|
||||||
|
if (target.getFileUrl() != null && !target.getFileUrl().isBlank()) {
|
||||||
|
touchedUrls.add(target.getFileUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int removedObjects = 0;
|
||||||
|
for (String url : touchedUrls) {
|
||||||
|
Long remain = softwareVersionMapper.selectCount(new LambdaQueryWrapper<SoftwareVersionEntity>()
|
||||||
|
.eq(SoftwareVersionEntity::getFileUrl, url));
|
||||||
|
if (remain == null || remain <= 0) {
|
||||||
|
removedObjects += removeSoftwareVersionObjectQuietly(url) ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("[software-version] 批量删除版本记录 ids={} deleted={} 清理关联文件数={}",
|
||||||
|
distinctIds, deleted, removedObjects);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean removeSoftwareVersionObjectQuietly(String fileUrl) {
|
||||||
|
try {
|
||||||
|
String objectKey = ossStorageService.resolveObjectKey(fileUrl);
|
||||||
|
if (objectKey == null || !objectKey.startsWith(STORAGE_PATH_PREFIX)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ossStorageService.removeSoftwareVersionObject(objectKey);
|
||||||
|
return true;
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("[software-version] 清理版本关联文件失败,保留对象 fileUrl={} err={}", fileUrl, ex.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireVersion(String version) {
|
||||||
|
String normalizedVersion = version == null ? "" : version.trim();
|
||||||
|
if (normalizedVersion.isEmpty()) {
|
||||||
|
throw new BusinessException("请填写版本号");
|
||||||
|
}
|
||||||
|
return normalizedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String softwareVersionObjectKey(String version) {
|
||||||
|
return STORAGE_PATH_PREFIX + safeVersionKey(version) + ".zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toItemMap(SoftwareVersionEntity entity) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", entity.getId());
|
||||||
|
item.put("version", entity.getVersion() == null ? "" : entity.getVersion());
|
||||||
|
item.put("file_url", entity.getFileUrl() == null ? "" : entity.getFileUrl());
|
||||||
|
item.put("created_at", entity.getCreatedAt() == null
|
||||||
|
? "" : entity.getCreatedAt().format(CREATED_AT_FORMATTER));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
private long elapsedMs(long startedAt) {
|
private long elapsedMs(long startedAt) {
|
||||||
return (System.nanoTime() - startedAt) / 1_000_000L;
|
return (System.nanoTime() - startedAt) / 1_000_000L;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -1,6 +1,8 @@
|
|||||||
package com.nanri.aiimage.modules.file.service.oss;
|
package com.nanri.aiimage.modules.file.service.oss;
|
||||||
|
|
||||||
import com.nanri.aiimage.config.OssProperties;
|
import com.nanri.aiimage.config.OssProperties;
|
||||||
|
import io.minio.GetPresignedObjectUrlArgs;
|
||||||
|
import io.minio.MinioClient;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -8,6 +10,11 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class OssStorageServiceTest {
|
class OssStorageServiceTest {
|
||||||
|
|
||||||
@@ -124,6 +131,34 @@ class OssStorageServiceTest {
|
|||||||
storageService.generateFreshDownloadUrl("result/similar_asin/1/re.xlsx"));
|
storageService.generateFreshDownloadUrl("result/similar_asin/1/re.xlsx"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void presignSoftwareVersionUploadDelegatesToClientPut() throws Exception {
|
||||||
|
// 自定义 endpoint 下 getPresignedObjectUrl 会先联网探测 region,单测用 mock 客户端隔离
|
||||||
|
MinioClient client = mock(MinioClient.class);
|
||||||
|
when(client.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class)))
|
||||||
|
.thenReturn("https://oss.aishufu.top/client/nanri-image/versions/3.0.67.zip?X-Amz-Signature=test-sig");
|
||||||
|
OssStorageService oss = new OssStorageService(properties, client);
|
||||||
|
assertEquals("https://oss.aishufu.top/client/nanri-image/versions/3.0.67.zip?X-Amz-Signature=test-sig",
|
||||||
|
oss.presignSoftwareVersionUpload("nanri-image/versions/3.0.67.zip", 180));
|
||||||
|
verify(client).getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void presignSoftwareVersionUploadRejectsForeignKey() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> storageService.presignSoftwareVersionUpload("other/prefix.zip", 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientBucketSoftwareVersionUrlResolution() {
|
||||||
|
assertEquals("https://oss.aishufu.top/client/nanri-image/versions/3.0.66.zip",
|
||||||
|
storageService.getSoftwareVersionDownloadUrl("nanri-image/versions/3.0.66.zip"));
|
||||||
|
// client 桶已并入 configuredBuckets,其 URL 可被 normalize 正确识别保留而不误判私有桶
|
||||||
|
assertEquals("https://oss.aishufu.top/client/nanri-image/versions/3.0.66.zip",
|
||||||
|
storageService.normalizeManagedPublicUrl(
|
||||||
|
"http://47.110.241.161:9000/client/nanri-image/versions/3.0.66.zip"));
|
||||||
|
}
|
||||||
|
|
||||||
private OssProperties properties() {
|
private OssProperties properties() {
|
||||||
return properties;
|
return properties;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user