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:
2026-09-08 23:51:18 +08:00
parent 5fad0ae027
commit 45fdd8a294
9 changed files with 440 additions and 30 deletions
@@ -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}`
// 浏览器直传 MinIOpresign → 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。/, '弹窗描述对齐')
+4 -2
View File
@@ -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\(/)
})
+3 -3
View File
@@ -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/, '上传适配解析新版本行')
@@ -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())
@@ -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));
}
}
@@ -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;
}
@@ -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;
}