|
|
@@ -0,0 +1,285 @@
|
|
|
+package org.dromara.risk.controller;
|
|
|
+
|
|
|
+import cn.dev33.satoken.annotation.SaCheckPermission;
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
+import io.minio.BucketExistsArgs;
|
|
|
+import io.minio.MakeBucketArgs;
|
|
|
+import io.minio.MinioClient;
|
|
|
+import io.minio.PutObjectArgs;
|
|
|
+import io.minio.RemoveObjectArgs;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.dromara.common.core.domain.R;
|
|
|
+import org.dromara.common.core.exception.ServiceException;
|
|
|
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
|
|
|
+import org.dromara.common.log.annotation.Log;
|
|
|
+import org.dromara.common.log.enums.BusinessType;
|
|
|
+import org.dromara.common.web.core.BaseController;
|
|
|
+import org.dromara.risk.config.MinioProperties;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.validation.annotation.Validated;
|
|
|
+import org.springframework.web.bind.annotation.DeleteMapping;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PathVariable;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestPart;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.io.InputStream;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 风控平台 文件上传 控制器
|
|
|
+ * <p>
|
|
|
+ * 直接使用 MinIO Java SDK 上传文件到 MinIO 服务器,适用于风控业务模块的附件管理。
|
|
|
+ * </p>
|
|
|
+ *
|
|
|
+ * @author ruoyi
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Validated
|
|
|
+@RequiredArgsConstructor
|
|
|
+@RestController
|
|
|
+@RequestMapping("/risk/file")
|
|
|
+public class RiskFileController extends BaseController {
|
|
|
+
|
|
|
+ /** 允许上传的文件扩展名集合(小写) */
|
|
|
+ private static final Set<String> ALLOWED_EXTS = Set.of(
|
|
|
+ "jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx"
|
|
|
+ );
|
|
|
+
|
|
|
+ /** 单个文件最大大小 100MB */
|
|
|
+ private static final long MAX_FILE_SIZE = 100L * 1024 * 1024;
|
|
|
+
|
|
|
+ private final MinioClient minioClient;
|
|
|
+ private final MinioProperties minioProperties;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用文件上传
|
|
|
+ *
|
|
|
+ * @param file 上传的文件
|
|
|
+ * @return { url, fileName, ossId, originalName }
|
|
|
+ */
|
|
|
+ @SaCheckPermission("risk:file:upload")
|
|
|
+ @Log(title = "风控文件上传", businessType = BusinessType.INSERT)
|
|
|
+ @RepeatSubmit(interval = 2000, message = "请勿重复上传")
|
|
|
+ @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
|
|
+ public R<Map<String, String>> upload(@RequestPart("file") MultipartFile file) {
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
+ return R.fail("上传文件不能为空");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // 1. 校验文件大小
|
|
|
+ if (file.getSize() > MAX_FILE_SIZE) {
|
|
|
+ return R.fail("文件大小超过限制(最大100MB)");
|
|
|
+ }
|
|
|
+
|
|
|
+ String originalName = file.getOriginalFilename();
|
|
|
+ if (StrUtil.isBlank(originalName)) {
|
|
|
+ return R.fail("文件名不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 校验文件扩展名
|
|
|
+ String ext = getAndValidateExt(originalName);
|
|
|
+
|
|
|
+ // 3. 生成对象名: risk/{yyyy/MM/dd}/{uuid}.{ext}
|
|
|
+ String objectName = buildObjectName(ext);
|
|
|
+
|
|
|
+ // 4. 确保桶存在
|
|
|
+ ensureBucket();
|
|
|
+
|
|
|
+ // 5. 上传文件流
|
|
|
+ try (InputStream in = file.getInputStream()) {
|
|
|
+ minioClient.putObject(
|
|
|
+ PutObjectArgs.builder()
|
|
|
+ .bucket(minioProperties.getBucket())
|
|
|
+ .object(objectName)
|
|
|
+ .stream(in, file.getSize(), -1)
|
|
|
+ .contentType(file.getContentType())
|
|
|
+ .build()
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 6. 组装返回数据
|
|
|
+ String fileUrl = buildFileUrl(objectName);
|
|
|
+ String ossId = UUID.randomUUID().toString().replace("-", "");
|
|
|
+
|
|
|
+ Map<String, String> result = new HashMap<>(4);
|
|
|
+ result.put("url", fileUrl);
|
|
|
+ result.put("fileName", objectName);
|
|
|
+ result.put("ossId", ossId);
|
|
|
+ result.put("originalName", originalName);
|
|
|
+ return R.ok("文件上传成功", result);
|
|
|
+ } catch (ServiceException e) {
|
|
|
+ throw e;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[MinIO] 文件上传失败", e);
|
|
|
+ return R.fail("文件上传失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 多文件上传
|
|
|
+ *
|
|
|
+ * @param files 文件数组
|
|
|
+ * @return { urls: string[] }
|
|
|
+ */
|
|
|
+ @SaCheckPermission("risk:file:upload")
|
|
|
+ @Log(title = "风控文件批量上传", businessType = BusinessType.INSERT)
|
|
|
+ @RepeatSubmit(interval = 3000, message = "请勿重复上传")
|
|
|
+ @PostMapping(value = "/uploads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
|
|
+ public R<Map<String, Object>> uploads(@RequestPart("files") MultipartFile[] files) {
|
|
|
+ if (files == null || files.length == 0) {
|
|
|
+ return R.fail("上传文件不能为空");
|
|
|
+ }
|
|
|
+ List<String> urlList = new ArrayList<>(files.length);
|
|
|
+ List<String> failList = new ArrayList<>();
|
|
|
+ try {
|
|
|
+ ensureBucket();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[MinIO] 检查桶是否存在失败", e);
|
|
|
+ return R.fail("初始化存储失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ for (MultipartFile file : files) {
|
|
|
+ try {
|
|
|
+ if (file.isEmpty() || file.getSize() > MAX_FILE_SIZE) {
|
|
|
+ failList.add(file.getOriginalFilename());
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String originalName = file.getOriginalFilename();
|
|
|
+ if (StrUtil.isBlank(originalName)) {
|
|
|
+ failList.add("");
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String ext = getAndValidateExt(originalName);
|
|
|
+ String objectName = buildObjectName(ext);
|
|
|
+ try (InputStream in = file.getInputStream()) {
|
|
|
+ minioClient.putObject(
|
|
|
+ PutObjectArgs.builder()
|
|
|
+ .bucket(minioProperties.getBucket())
|
|
|
+ .object(objectName)
|
|
|
+ .stream(in, file.getSize(), -1)
|
|
|
+ .contentType(file.getContentType())
|
|
|
+ .build()
|
|
|
+ );
|
|
|
+ }
|
|
|
+ urlList.add(buildFileUrl(objectName));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[MinIO] 多文件上传时子文件失败: {}", file.getOriginalFilename(), e);
|
|
|
+ failList.add(file.getOriginalFilename());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Map<String, Object> data = new HashMap<>(3);
|
|
|
+ data.put("urls", urlList);
|
|
|
+ data.put("successCount", urlList.size());
|
|
|
+ data.put("failCount", failList.size());
|
|
|
+ if (!failList.isEmpty()) {
|
|
|
+ data.put("failedFiles", failList);
|
|
|
+ }
|
|
|
+ return R.ok("批量上传完成", data);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除文件
|
|
|
+ *
|
|
|
+ * @param fileName 对象名(对象存储中的相对路径)
|
|
|
+ */
|
|
|
+ @SaCheckPermission("risk:file:remove")
|
|
|
+ @Log(title = "风控文件删除", businessType = BusinessType.DELETE)
|
|
|
+ @DeleteMapping("/{fileName}")
|
|
|
+ public R<Map<String, Boolean>> remove(@PathVariable("fileName") String fileName) {
|
|
|
+ if (StrUtil.isBlank(fileName)) {
|
|
|
+ return R.fail("文件名不能为空");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ minioClient.removeObject(
|
|
|
+ RemoveObjectArgs.builder()
|
|
|
+ .bucket(minioProperties.getBucket())
|
|
|
+ .object(fileName)
|
|
|
+ .build()
|
|
|
+ );
|
|
|
+ Map<String, Boolean> result = new HashMap<>(1);
|
|
|
+ result.put("success", true);
|
|
|
+ return R.ok("文件删除成功", result);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[MinIO] 文件删除失败: {}", fileName, e);
|
|
|
+ Map<String, Boolean> result = new HashMap<>(1);
|
|
|
+ result.put("success", false);
|
|
|
+ return R.fail("文件删除失败: " + e.getMessage(), result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取 MinIO 客户端配置
|
|
|
+ */
|
|
|
+ @GetMapping("/config")
|
|
|
+ public R<Map<String, String>> config() {
|
|
|
+ Map<String, String> data = new HashMap<>(3);
|
|
|
+ data.put("endpoint", minioProperties.getEndpoint());
|
|
|
+ data.put("bucket", minioProperties.getBucket());
|
|
|
+ data.put("publicUrl", minioProperties.getPublicUrl());
|
|
|
+ return R.ok(data);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================ 私有方法 ============================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验并获取文件扩展名(小写)
|
|
|
+ */
|
|
|
+ private String getAndValidateExt(String originalName) {
|
|
|
+ int idx = originalName.lastIndexOf('.');
|
|
|
+ if (idx < 0 || idx == originalName.length() - 1) {
|
|
|
+ throw new ServiceException("文件类型不支持: " + originalName);
|
|
|
+ }
|
|
|
+ String ext = originalName.substring(idx + 1).toLowerCase();
|
|
|
+ if (!ALLOWED_EXTS.contains(ext)) {
|
|
|
+ throw new ServiceException("仅支持以下文件类型: " + ALLOWED_EXTS);
|
|
|
+ }
|
|
|
+ return ext;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成对象名: risk/yyyy/MM/dd/{uuid}.{ext}
|
|
|
+ */
|
|
|
+ private String buildObjectName(String ext) {
|
|
|
+ String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
|
|
+ String uuid = UUID.randomUUID().toString().replace("-", "");
|
|
|
+ return "risk/" + datePath + "/" + uuid + "." + ext;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拼装完整访问URL
|
|
|
+ */
|
|
|
+ private String buildFileUrl(String objectName) {
|
|
|
+ String publicUrl = minioProperties.getPublicUrl();
|
|
|
+ if (publicUrl.endsWith("/")) {
|
|
|
+ return publicUrl + objectName;
|
|
|
+ }
|
|
|
+ return publicUrl + "/" + objectName;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查并创建桶
|
|
|
+ */
|
|
|
+ private void ensureBucket() throws Exception {
|
|
|
+ String bucket = minioProperties.getBucket();
|
|
|
+ boolean exists = minioClient.bucketExists(
|
|
|
+ BucketExistsArgs.builder().bucket(bucket).build()
|
|
|
+ );
|
|
|
+ if (!exists) {
|
|
|
+ minioClient.makeBucket(
|
|
|
+ MakeBucketArgs.builder().bucket(bucket).build()
|
|
|
+ );
|
|
|
+ log.info("[MinIO] 创建桶: {}", bucket);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|