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
@@ -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;
}