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 OldPagination from '@/components/OldPagination.vue'
|
||||
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 { 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)))
|
||||
})
|
||||
|
||||
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
|
||||
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 uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const newVersion = ref('')
|
||||
const pickedFile = ref<File | null>(null)
|
||||
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
||||
@@ -63,6 +108,7 @@ function openUpload() {
|
||||
async function submitUpload() {
|
||||
uploadMsg.value = ''
|
||||
uploadMsgOk.value = false
|
||||
uploadPercent.value = 0
|
||||
if (!isNonEmptyVersion(newVersion.value)) {
|
||||
uploadMsg.value = '请填写版本号'
|
||||
return
|
||||
@@ -75,16 +121,23 @@ async function submitUpload() {
|
||||
uploadMsg.value = '仅支持 .zip 格式'
|
||||
return
|
||||
}
|
||||
if (pickedFile.value.size > 512 * 1024 * 1024) {
|
||||
uploadMsg.value = '文件超过允许的大小限制'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
|
||||
const version = created?.version || newVersion.value.trim()
|
||||
const link = created?.fileUrl
|
||||
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
|
||||
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
|
||||
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||
await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name, (p) => {
|
||||
uploadPercent.value = p
|
||||
uploadMsg.value = p >= 100 ? '上传完成,正在登记版本...' : `正在上传:${p}%`
|
||||
})
|
||||
// 成功仅提示“发布成功”,弹窗留驻,不再展示版本号与链接。
|
||||
uploadMsg.value = '发布成功'
|
||||
uploadMsgOk.value = true
|
||||
newVersion.value = ''
|
||||
pickedFile.value = null
|
||||
uploadPercent.value = 0
|
||||
load()
|
||||
} catch (error) {
|
||||
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||
@@ -103,6 +156,7 @@ onMounted(load)
|
||||
<h3>版本列表</h3>
|
||||
<div class="version-head-tools">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,32 +165,39 @@ onMounted(load)
|
||||
<table>
|
||||
<thead>
|
||||
<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 style="width: 160px">创建时间</th>
|
||||
<th style="width: 120px">操作</th>
|
||||
<th style="width: 150px">创建时间</th>
|
||||
<th style="width: 150px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td>
|
||||
<a v-if="row.fileUrl" class="btn btn-sm btn-secondary dl-btn" :href="row.fileUrl" download>下载</a>
|
||||
<td class="ops-cell">
|
||||
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
|
||||
<span v-else class="dim">—</span>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="4" class="empty-tip">加载中...</td>
|
||||
<td colspan="5" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="4" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -157,6 +218,7 @@ onMounted(load)
|
||||
<p class="zip-hint">仅支持 .zip 格式</p>
|
||||
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
||||
</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-form>
|
||||
<template #footer>
|
||||
@@ -276,7 +338,7 @@ h3 {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.version-table-scroll > table {
|
||||
min-width: 760px;
|
||||
min-width: 840px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
@@ -338,6 +400,36 @@ h3 {
|
||||
color: #5b6f83;
|
||||
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 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,83 @@
|
||||
/** 软件版本列表加载适配(任务 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 type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
||||
|
||||
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
||||
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> {
|
||||
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
||||
return parseSoftwareVersionList(data)
|
||||
}
|
||||
|
||||
/** 上传客户端软件版本(multipart version + file);返回新版本行(含下载链接)。 */
|
||||
export async function uploadSoftwareVersion(version: string, file: Blob, filename = ''): Promise<SoftwareVersionItem | null> {
|
||||
const form = new FormData()
|
||||
form.append('version', version)
|
||||
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 SoftwareVersionUploadTarget {
|
||||
uploadUrl: string
|
||||
fileUrl: string
|
||||
version: string
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** 按 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)。
|
||||
assert.match(page, /download/, '下载链接带 download 强制下载')
|
||||
assert.match(page, /download\s*>\s*下载\s*<\/a>/, '操作列下载为强制下载链接')
|
||||
// 上传成功:弹窗保持打开,成功文案留驻可复制(对齐 admin.js:6484-6488)。
|
||||
assert.match(page, /发布成功。版本:/, '成功文案留驻弹窗')
|
||||
// 上传成功:弹窗保持打开,成功提示为“发布成功”(对齐新版精简提示)。
|
||||
assert.match(page, /发布成功/, '成功提示留驻弹窗')
|
||||
assert.match(page, /uploadMsg/, '成功/失败文案在弹窗内展示')
|
||||
// 上传弹窗文案(对齐 admin.html:6094-6110)。
|
||||
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', () => {
|
||||
// 依赖失败/提交走 adapter:上传表单纯逻辑;POST /api/admin/version multipart。
|
||||
// 依赖失败/提交走 adapter:上传表单纯逻辑;浏览器直传(presign 签发 → PUT 到 MinIO)由 version-api 编排。
|
||||
const mod = readSource('src/pages/records/version-upload-model.ts')
|
||||
assert.equal(/axios|http\.|vue/.test(mod), false, '上传表单保持纯逻辑')
|
||||
const api = readSource('src/pages/records/version-api.ts')
|
||||
assert.match(api, /uploadSoftwareVersion/)
|
||||
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', () => {
|
||||
// 依赖失败:成功文案含版本与链接;上传经 API 适配并解析新版本;空态兜底。
|
||||
// 依赖失败:成功提示精简为“发布成功”;上传经 API 适配并解析新版本;空态兜底。
|
||||
const page = readSource('src/pages/records/RecordsSoftwareVersionPage.vue')
|
||||
assert.match(page, /发布成功。版本:/, '成功文案需含版本')
|
||||
assert.match(page, /,链接:/, '成功文案需含链接')
|
||||
assert.match(page, /发布成功/, '成功提示留驻弹窗')
|
||||
assert.match(page, /正在上传/, '上传过程提示含进度')
|
||||
assert.match(page, /暂无版本/, '空态文案')
|
||||
const api = readSource('src/pages/records/version-api.ts')
|
||||
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 io.minio.BucketExistsArgs;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.GetPresignedObjectUrlArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.http.Method;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.StatObjectArgs;
|
||||
@@ -23,6 +25,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@@ -99,6 +102,70 @@ public class OssStorageService {
|
||||
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) {
|
||||
if (objectKey == null || objectKey.isBlank()) {
|
||||
throw new IllegalArgumentException("objectKey must not be blank");
|
||||
@@ -472,7 +539,8 @@ public class OssStorageService {
|
||||
}
|
||||
|
||||
private List<String> configuredBuckets() {
|
||||
return Stream.of(ossProperties.getBucket(), imageVideoBucket(), digitalHumanBucket(), templateBucket())
|
||||
return Stream.of(ossProperties.getBucket(), imageVideoBucket(), digitalHumanBucket(), templateBucket(),
|
||||
softwareVersionBucket())
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(bucket -> !bucket.isBlank())
|
||||
|
||||
+33
@@ -13,11 +13,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -53,4 +55,35 @@ public class SoftwareVersionAdminController {
|
||||
Map<String, Object> result = softwareVersionService.uploadSoftwareVersion(version, file);
|
||||
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.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -36,6 +38,9 @@ public class SoftwareVersionService {
|
||||
/** 与 Flask VERSION_UPLOAD_MAX_BYTES(默认 512MB)保持一致 */
|
||||
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");
|
||||
|
||||
/** 对齐 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) {
|
||||
return (System.nanoTime() - startedAt) / 1_000_000L;
|
||||
}
|
||||
|
||||
+35
@@ -1,6 +1,8 @@
|
||||
package com.nanri.aiimage.modules.file.service.oss;
|
||||
|
||||
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.Test;
|
||||
|
||||
@@ -8,6 +10,11 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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 {
|
||||
|
||||
@@ -124,6 +131,34 @@ class OssStorageServiceTest {
|
||||
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() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user