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:
+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