Parcourir la source

上传统一采用minio,并封装公共接口

zengy il y a 1 semaine
Parent
commit
4da76d0848
33 fichiers modifiés avec 683 ajouts et 810 suppressions
  1. 0 5
      pipe-network-service/pom.xml
  2. 0 13
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/alarm/AlarmController.java
  3. 13 101
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/common/CommonController.java
  4. 71 70
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/common/MinioController.java
  5. 0 15
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileAlarmController.java
  6. 1 13
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEarlyWarningController.java
  7. 40 23
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysProfileController.java
  8. 5 6
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningController.java
  9. 2 23
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileAlarmControllerTest.java
  10. 4 7
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileControllerMappingTest.java
  11. 3 2
      pipe-network-service/zksy-common/pom.xml
  12. 34 0
      pipe-network-service/zksy-common/src/main/java/com/zksy/common/config/MinioConfig.java
  13. 75 0
      pipe-network-service/zksy-common/src/main/java/com/zksy/common/utils/file/MinioPathUtils.java
  14. 43 0
      pipe-network-service/zksy-common/src/main/java/com/zksy/common/utils/file/MinioUrlUtils.java
  15. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmReviewRequest.java
  16. 25 40
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewAttachmentService.java
  17. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java
  18. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningProcessRequest.java
  19. 7 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/EarlyWarningService.java
  20. 0 85
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningProcessAttachmentService.java
  21. 156 219
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java
  22. 8 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/service/impl/DisEventLogServiceImpl.java
  23. 10 9
      pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/service/impl/DisEventServiceImpl.java
  24. 0 89
      pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/support/DispatchAttachUrlSupport.java
  25. 0 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/InspectTask.java
  26. 0 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderDetailVO.java
  27. 0 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/InspectTaskServiceImpl.java
  28. 0 20
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java
  29. 3 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewServiceTest.java
  30. 153 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImplTest.java
  31. 25 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/common/utils/file/MinioUrlUtilsTest.java
  32. 0 31
      pipe-network-service/zksy-system/src/test/java/com/zksy/dispatch/support/DispatchAttachUrlSupportTest.java
  33. 1 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java

+ 0 - 5
pipe-network-service/pom.xml

@@ -237,11 +237,6 @@
                 <artifactId>hutool-all</artifactId>
                 <version>${hutool.version}</version>
             </dependency>
-            <dependency>
-                <groupId>com.zksy</groupId>
-                <artifactId>minioutil</artifactId>
-                <version>${minioutil.version}</version>
-            </dependency>
             <dependency>
                 <groupId>com.baomidou</groupId>
                 <artifactId>mybatis-plus-core</artifactId>

+ 0 - 13
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/alarm/AlarmController.java

@@ -7,7 +7,6 @@ import com.zksy.base.alarm.service.AlarmOperationsService;
 import com.zksy.base.alarm.dto.AlarmQuery;
 import com.zksy.base.alarm.enums.AlarmSeriesPeriod;
 import com.zksy.base.alarm.service.AlarmReviewService;
-import com.zksy.base.alarm.service.AlarmReviewAttachmentService;
 import com.zksy.base.alarm.dto.AlarmReviewQuery;
 import com.zksy.base.alarm.dto.AlarmReviewRequest;
 import com.zksy.base.alarm.enums.AlarmRecordStatusEnum;
@@ -22,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.multipart.MultipartFile;
 
 import javax.validation.Valid;
 
@@ -40,9 +38,6 @@ public class AlarmController {
     @Autowired
     private AlarmReviewService reviewService;
 
-    @Autowired
-    private AlarmReviewAttachmentService attachmentService;
-
     @Autowired
     private AlarmOperationsService operations;
 
@@ -152,14 +147,6 @@ public class AlarmController {
         return AjaxResult.success(reviewService.review(alarmId, query, request));
     }
 
-    @PostMapping("/{alarmId}/attachments")
-    @ApiOperation("上传报警审核图片,仅JPG/JPEG/PNG且单张不超过10MB")
-    @PreAuthorize("@ss.hasPermi('alarm:review:audit')")
-    public AjaxResult uploadAttachment(@PathVariable("alarmId") String alarmId, @Valid AlarmReviewQuery query, @RequestPart("file") MultipartFile file) throws Exception {
-        reviewService.detail(alarmId, query);
-        return AjaxResult.success(attachmentService.upload(alarmId, file));
-    }
-
     @GetMapping("/findByPage")
     @ApiOperation("报警分页查询")
     @Anonymous

+ 13 - 101
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/common/CommonController.java

@@ -3,57 +3,42 @@ package com.zksy.web.controller.common;
 import com.zksy.common.config.ZksyConfig;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.utils.StringUtils;
-import com.zksy.common.utils.file.FileUploadUtils;
 import com.zksy.common.utils.file.FileUtils;
-import com.zksy.framework.config.ServerConfig;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 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.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import java.util.ArrayList;
-import java.util.List;
 
 /**
  * 通用请求处理
- * 
+ *
  * @author zksy
  */
 @RestController
 @RequestMapping("/common")
 @Api(tags = "通用接口")
-public class CommonController
-{
+public class CommonController {
     private static final Logger log = LoggerFactory.getLogger(CommonController.class);
 
-    @Autowired
-    private ServerConfig serverConfig;
-
-    private static final String FILE_DELIMETER = ",";
-
     /**
      * 通用下载请求
-     * 
+     *
      * @param fileName 文件名称
-     * @param delete 是否删除
+     * @param delete   是否删除
      */
     @GetMapping("/download")
     @ApiOperation(value = "通用下载请求")
-    public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
-    {
-        try
-        {
-            if (!FileUtils.checkAllowDownload(fileName))
-            {
+    public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request) {
+        try {
+            if (!FileUtils.checkAllowDownload(fileName)) {
                 throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
             }
             String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
@@ -62,94 +47,23 @@ public class CommonController
             response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
             FileUtils.setAttachmentResponseHeader(response, realFileName);
             FileUtils.writeBytes(filePath, response.getOutputStream());
-            if (delete)
-            {
+            if (delete) {
                 FileUtils.deleteFile(filePath);
             }
-        }
-        catch (Exception e)
-        {
+        } catch (Exception e) {
             log.error("下载文件失败", e);
         }
     }
 
-    /**
-     * 通用上传请求(单个)
-     */
-    @PostMapping("/upload")
-    @ApiOperation(value = "通用上传请求(单个)")
-    public AjaxResult uploadFile(MultipartFile file) throws Exception
-    {
-        try
-        {
-            // 上传文件路径
-            String filePath = ZksyConfig.getUploadPath();
-            // 上传并返回新文件名称
-            String fileName = FileUploadUtils.upload(filePath, file);
-            String url = serverConfig.getUrl() + fileName;
-            AjaxResult ajax = AjaxResult.success();
-            ajax.put("url", url);
-            ajax.put("fileName", fileName);
-            ajax.put("newFileName", FileUtils.getName(fileName));
-            ajax.put("originalFilename", file.getOriginalFilename());
-            return ajax;
-        }
-        catch (Exception e)
-        {
-            return AjaxResult.error(e.getMessage());
-        }
-    }
-
-    /**
-     * 通用上传请求(多个)
-     */
-    @PostMapping("/uploads")
-    @ApiOperation(value = "通用上传请求(多个)")
-    public AjaxResult uploadFiles(List<MultipartFile> files) throws Exception
-    {
-        try
-        {
-            // 上传文件路径
-            String filePath = ZksyConfig.getUploadPath();
-            List<String> urls = new ArrayList<String>();
-            List<String> fileNames = new ArrayList<String>();
-            List<String> newFileNames = new ArrayList<String>();
-            List<String> originalFilenames = new ArrayList<String>();
-            for (MultipartFile file : files)
-            {
-                // 上传并返回新文件名称
-                String fileName = FileUploadUtils.upload(filePath, file);
-                String url = serverConfig.getUrl() + fileName;
-                urls.add(url);
-                fileNames.add(fileName);
-                newFileNames.add(FileUtils.getName(fileName));
-                originalFilenames.add(file.getOriginalFilename());
-            }
-            AjaxResult ajax = AjaxResult.success();
-            ajax.put("urls", StringUtils.join(urls, FILE_DELIMETER));
-            ajax.put("fileNames", StringUtils.join(fileNames, FILE_DELIMETER));
-            ajax.put("newFileNames", StringUtils.join(newFileNames, FILE_DELIMETER));
-            ajax.put("originalFilenames", StringUtils.join(originalFilenames, FILE_DELIMETER));
-            return ajax;
-        }
-        catch (Exception e)
-        {
-            return AjaxResult.error(e.getMessage());
-        }
-    }
-
     /**
      * 本地资源通用下载
      */
     @GetMapping("/download/resource")
     @ApiOperation(value = "本地资源通用下载")
     public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response)
-            throws Exception
-    {
-        try
-        {
-            if (!FileUtils.checkAllowDownload(resource))
-            {
+            throws Exception {
+        try {
+            if (!FileUtils.checkAllowDownload(resource)) {
                 throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", resource));
             }
             // 本地资源路径
@@ -161,9 +75,7 @@ public class CommonController
             response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
             FileUtils.setAttachmentResponseHeader(response, downloadName);
             FileUtils.writeBytes(downloadPath, response.getOutputStream());
-        }
-        catch (Exception e)
-        {
+        } catch (Exception e) {
             log.error("下载文件失败", e);
         }
     }

+ 71 - 70
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/common/MinioController.java

@@ -3,7 +3,10 @@ package com.zksy.web.controller.common;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.enums.BusinessType;
-import com.zksy.service.MinioFileStorageService;
+import com.zksy.common.utils.file.MinioPathUtils;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -14,10 +17,14 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.UUID;
 
 /**
  * MinIO 文件存储接口
@@ -28,18 +35,14 @@ import java.util.Map;
 @RestController
 @RequestMapping("/minio")
 @Api(tags = "MinIO 文件存储接口")
-public class MinioController
-{
+public class MinioController {
     private static final Logger log = LoggerFactory.getLogger(MinioController.class);
 
     @Autowired
-    private MinioFileStorageService minioFileStorageService;
-
-    @Value("${minio.endpoint}")
-    private String readPath;
+    private MinioClient minioClient;
 
     @Value("${minio.bucket}")
-    private String bucket;
+    private String minioBucket;
 
     /**
      * MinIO 单文件上传
@@ -51,35 +54,26 @@ public class MinioController
     @PostMapping("/upload")
     @ApiOperation(value = "MinIO 单文件上传", notes = "支持上传任意类型文件,如图片、文档、视频等")
     @Log(title = "MinIO 文件上传", businessType = BusinessType.INSERT)
-    public AjaxResult uploadFile(
-            @ApiParam(value = "文件", required = true) @RequestParam("file") MultipartFile file,
-            @ApiParam(value = "存储目录(可选,默认 upload)") @RequestParam(value = "dir", required = false, defaultValue = "upload") String dir)
-    {
-        try
-        {
-            if (file == null || file.isEmpty())
-            {
+    public AjaxResult uploadFile(@ApiParam(value = "文件", required = true) @RequestParam("file") MultipartFile file, @ApiParam(value = "存储目录(可选,默认 other;按业务表约定,如 work-order/early-warning/alarm 等,亦可自定义)") @RequestParam(value = "dir", required = false, defaultValue = "other") String dir) {
+        try {
+            if (file == null || file.isEmpty()) {
                 return AjaxResult.error("上传文件不能为空");
             }
 
-            // 上传文件到 MinIO
-            String relativePath = minioFileStorageService.uploadFile(file, dir);
+            // 上传文件到 MinIO(对象名格式:{module}/{yyyy/MM/dd}/{uuid}.{ext})
+            String relativePath = uploadToMinio(file, MinioPathUtils.buildDir(dir));
             // 提取存储后的文件名
             String fileName = relativePath.substring(relativePath.lastIndexOf("/") + 1);
-            // 构建完整 URL:endpoint/bucket/path/filename
-            String fullUrl = buildFullUrl(relativePath);
-
             AjaxResult ajax = AjaxResult.success();
-            ajax.put("url", fullUrl);
-            ajax.put("relativePath", "/" + relativePath.replaceFirst("^/+", ""));
+            String objectPath = "/" + relativePath.replaceFirst("^/+", "");
+            ajax.put("relativePath", objectPath);
+            ajax.put("url", objectPath);
             ajax.put("fileName", fileName);
             ajax.put("originalFilename", file.getOriginalFilename());
             ajax.put("contentType", file.getContentType());
             ajax.put("size", file.getSize());
             return ajax;
-        }
-        catch (Exception e)
-        {
+        } catch (Exception e) {
             log.error("MinIO 文件上传失败", e);
             return AjaxResult.error("文件上传失败:" + e.getMessage());
         }
@@ -95,34 +89,25 @@ public class MinioController
     @PostMapping("/uploads")
     @ApiOperation(value = "MinIO 多文件上传", notes = "支持批量上传任意类型文件,如图片、文档、视频等")
     @Log(title = "MinIO 多文件上传", businessType = BusinessType.INSERT)
-    public AjaxResult uploadFiles(
-            @ApiParam(value = "文件列表", required = true) @RequestParam("files") List<MultipartFile> files,
-            @ApiParam(value = "存储目录(可选,默认 upload)") @RequestParam(value = "dir", required = false, defaultValue = "upload") String dir)
-    {
-        try
-        {
-            if (files == null || files.isEmpty())
-            {
+    public AjaxResult uploadFiles(@ApiParam(value = "文件列表", required = true) @RequestParam("files") List<MultipartFile> files, @ApiParam(value = "存储目录(可选,默认 other;按业务表约定,如 work-order/early-warning/alarm 等,亦可自定义)") @RequestParam(value = "dir", required = false, defaultValue = "other") String dir) {
+        try {
+            if (files == null || files.isEmpty()) {
                 return AjaxResult.error("上传文件不能为空");
             }
 
             List<Map<String, Object>> fileList = new ArrayList<>();
-            for (MultipartFile file : files)
-            {
-                if (file.isEmpty())
-                {
+            for (MultipartFile file : files) {
+                if (file.isEmpty()) {
                     continue;
                 }
 
-                // 上传文件到 MinIO
-                String relativePath = minioFileStorageService.uploadFile(file, dir);
+                // 上传文件到 MinIO(对象名格式:{module}/{yyyy/MM/dd}/{uuid}.{ext})
+                String relativePath = uploadToMinio(file, MinioPathUtils.buildDir(dir));
                 String fileName = relativePath.substring(relativePath.lastIndexOf("/") + 1);
-                // 构建完整 URL
-                String fullUrl = buildFullUrl(relativePath);
-
                 Map<String, Object> fileInfo = new HashMap<>();
-                fileInfo.put("url", fullUrl);
-                fileInfo.put("relativePath", "/" + relativePath.replaceFirst("^/+", ""));
+                String objectPath = "/" + relativePath.replaceFirst("^/+", "");
+                fileInfo.put("relativePath", objectPath);
+                fileInfo.put("url", objectPath);
                 fileInfo.put("fileName", fileName);
                 fileInfo.put("originalFilename", file.getOriginalFilename());
                 fileInfo.put("contentType", file.getContentType());
@@ -134,9 +119,7 @@ public class MinioController
             ajax.put("files", fileList);
             ajax.put("count", fileList.size());
             return ajax;
-        }
-        catch (Exception e)
-        {
+        } catch (Exception e) {
             log.error("MinIO 多文件上传失败", e);
             return AjaxResult.error("文件上传失败:" + e.getMessage());
         }
@@ -151,39 +134,57 @@ public class MinioController
     @DeleteMapping("/delete")
     @ApiOperation(value = "MinIO 文件删除", notes = "根据文件路径删除 MinIO 中的文件")
     @Log(title = "MinIO 文件删除", businessType = BusinessType.DELETE)
-    public AjaxResult deleteFile(
-            @ApiParam(value = "文件路径", required = true) @RequestParam("filePath") String filePath)
-    {
-        try
-        {
-            if (filePath == null || filePath.isEmpty())
-            {
+    public AjaxResult deleteFile(@ApiParam(value = "文件路径", required = true) @RequestParam("filePath") String filePath) {
+        try {
+            if (filePath == null || filePath.isEmpty()) {
                 return AjaxResult.error("文件路径不能为空");
             }
 
-            minioFileStorageService.deleteFile(filePath);
+            minioClient.removeObject(RemoveObjectArgs.builder()
+                    .bucket(minioBucket)
+                    .object(normalizeObjectName(filePath))
+                    .build());
             return AjaxResult.success("删除成功");
-        }
-        catch (Exception e)
-        {
+        } catch (Exception e) {
             log.error("MinIO 文件删除失败", e);
             return AjaxResult.error("文件删除失败:" + e.getMessage());
         }
     }
 
     /**
-     * 构建完整的 MinIO 文件访问 URL
-     * 
-     * @param relativePath 相对路径(如 upload/2026/07/30/file.jpg)
-     * @return 完整访问 URL(如 http://192.168.110.30:9000/bucket/upload/2026/07/30/file.jpg)
+     * 上传单个文件到 MinIO,返回对象名(不含前导 /)。
+     * 对象名固定为 {module}/{yyyy/MM/dd}/{uuid}.{ext}:
+     * - module 放在日期之前,便于按业务归属归类;
+     * - 不再按文件扩展名单独建一层目录。
      */
-    private String buildFullUrl(String relativePath) {
-        // 确保 readPath 以 / 结尾,relativePath 不以 / 开头
-        String baseUrl = readPath.endsWith("/") ? readPath : readPath + "/";
-        // 格式:http://endpoint/bucket/path/filename
-        if (relativePath.startsWith("/")) {
-            relativePath = relativePath.substring(1);
+    private String uploadToMinio(MultipartFile file, String module) throws Exception {
+        String original = file.getOriginalFilename();
+        String ext = "";
+        if (original != null && original.lastIndexOf('.') > 0) {
+            ext = original.substring(original.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
         }
-        return baseUrl + bucket + "/" + relativePath;
+        String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        String uuid = UUID.randomUUID().toString().replace("-", "");
+        String objectName = module + "/" + datePath + "/" + uuid + (ext.isEmpty() ? "" : "." + ext);
+        minioClient.putObject(PutObjectArgs.builder()
+                .bucket(minioBucket)
+                .object(objectName)
+                .stream(file.getInputStream(), file.getSize(), -1)
+                .contentType(file.getContentType())
+                .build());
+        return objectName;
     }
+
+    /** 规范化对象名:去掉前导 /,无效返回 null */
+    private static String normalizeObjectName(String path) {
+        if (path == null) {
+            return null;
+        }
+        String p = path.trim();
+        while (p.startsWith("/")) {
+            p = p.substring(1);
+        }
+        return p.isEmpty() ? null : p;
+    }
+
 }

+ 0 - 15
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileAlarmController.java

@@ -5,14 +5,12 @@ import com.zksy.base.alarm.enums.AlarmAuditStatusEnum;
 import com.zksy.base.alarm.enums.AlarmRecordStatusEnum;
 import com.zksy.base.alarm.service.AlarmOperationsService;
 import com.zksy.base.alarm.service.AlarmReviewService;
-import com.zksy.base.alarm.service.AlarmReviewAttachmentService;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.enums.BusinessType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
 import javax.validation.Valid;
 
 @RestController
@@ -22,7 +20,6 @@ import javax.validation.Valid;
 public class MobileAlarmController {
     private final AlarmOperationsService operations;
     private final AlarmReviewService reviews;
-    private final AlarmReviewAttachmentService attachments;
 
     @GetMapping
     public AjaxResult list(@Valid MobileAlarmQuery query) { return AjaxResult.success(operations.mobilePage(query)); }
@@ -44,16 +41,4 @@ public class MobileAlarmController {
                              @Valid @RequestBody AlarmReviewRequest request) {
         return AjaxResult.success(reviews.mobileReview(id, query, request));
     }
-
-    @PostMapping("/{alarmId}/attachments")
-    @PreAuthorize("@ss.hasPermi('alarm:review:audit')")
-    public AjaxResult upload(@PathVariable("alarmId") String id, @Valid MobileAlarmQuery query,
-                             @RequestPart("file") MultipartFile file) throws Exception {
-        AlarmVO alarm = operations.mobileDetail(id, query);
-        if (!Integer.valueOf(AlarmAuditStatusEnum.UNREVIEWED.getCode()).equals(alarm.getAuditStatus())
-                || !Integer.valueOf(AlarmRecordStatusEnum.UNHANDLED.getCode()).equals(alarm.getAlarmStatus())) {
-            return AjaxResult.error("报警已审核或已处理,请刷新后重试");
-        }
-        return AjaxResult.success(attachments.upload(id, file));
-    }
 }

+ 1 - 13
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEarlyWarningController.java

@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.zksy.base.warning.domain.WarningAttachment;
 import com.zksy.base.warning.dto.*;
 import com.zksy.base.warning.service.EarlyWarningService;
-import com.zksy.base.warning.service.WarningProcessAttachmentService;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.exception.ServiceException;
 import io.minio.GetObjectArgs;
@@ -13,7 +12,6 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletResponse;
 import javax.validation.Valid;
@@ -28,7 +26,6 @@ import java.util.*;
 @PreAuthorize("isAuthenticated()")
 public class MobileEarlyWarningController {
     private final EarlyWarningService warnings;
-    private final WarningProcessAttachmentService processAttachments;
     private final MinioClient minioClient;
 
     @Value("${minio.bucket}")
@@ -80,22 +77,13 @@ public class MobileEarlyWarningController {
         return AjaxResult.success(warnings.startWarningProcess(warningId, request.getRemark()));
     }
 
-    @PostMapping("/{warningId}/process/attachments")
-    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
-    public AjaxResult uploadProcessAttachment(@PathVariable("warningId") String warningId,
-                                              @RequestPart("file") MultipartFile file) {
-        Map<String, Object> detail = warnings.getMobileWarningDetail(warningId);
-        if (!actions(detail).contains("HANDLE")) throw new ServiceException("当前预警不能上传处置图片");
-        return AjaxResult.success(processAttachments.upload(warningId, file));
-    }
-
     @PostMapping("/{warningId}/process")
     @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
     public AjaxResult process(@PathVariable("warningId") String warningId,
                               @Valid @RequestBody MobileWarningProcessRequest request) {
         warnings.requireMobileWarningVisible(warningId);
         return AjaxResult.success(warnings.submitMobileWarningProcess(warningId, request.getContent(),
-                request.getAttachmentTokens(), request.getReasonIds()));
+                request.getAttachments(), request.getReasonIds()));
     }
 
     @PostMapping("/{warningId}/verify")

+ 40 - 23
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysProfileController.java

@@ -9,9 +9,7 @@ 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 com.zksy.common.annotation.Log;
-import com.zksy.common.config.ZksyConfig;
 import com.zksy.common.core.controller.BaseController;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.core.domain.entity.SysUser;
@@ -20,9 +18,10 @@ import com.zksy.common.enums.BusinessType;
 import com.zksy.common.utils.DateUtils;
 import com.zksy.common.utils.SecurityUtils;
 import com.zksy.common.utils.StringUtils;
-import com.zksy.common.utils.file.FileUploadUtils;
-import com.zksy.common.utils.file.FileUtils;
-import com.zksy.common.utils.file.MimeTypeUtils;
+import com.zksy.common.utils.file.MinioPathUtils;
+import io.minio.MinioClient;
+import io.minio.RemoveObjectArgs;
+import org.springframework.beans.factory.annotation.Value;
 import com.zksy.framework.web.service.TokenService;
 import com.zksy.system.service.ISysUserService;
 
@@ -41,6 +40,12 @@ public class SysProfileController extends BaseController
     @Autowired
     private TokenService tokenService;
 
+    @Autowired
+    private MinioClient minioClient;
+
+    @Value("${minio.bucket}")
+    private String minioBucket;
+
     /**
      * 个人信息
      */
@@ -118,31 +123,43 @@ public class SysProfileController extends BaseController
     }
 
     /**
-     * 头像上传
+     * 头像设置(前端先调 /minio/upload?dir=user 取得相对路径后传入,本接口只负责存路径与清理旧图)
      */
     @Log(title = "用户头像", businessType = BusinessType.UPDATE)
     @PostMapping("/avatar")
-    public AjaxResult avatar(@RequestParam("avatarfile") MultipartFile file) throws Exception
+    public AjaxResult avatar(@RequestParam("avatar") String avatar)
     {
-        if (!file.isEmpty())
+        if (StringUtils.isEmpty(avatar) || avatar.contains("://") || !avatar.startsWith("/"))
+        {
+            return error("非法的头像路径");
+        }
+        LoginUser loginUser = getLoginUser();
+        if (userService.updateUserAvatar(loginUser.getUserId(), avatar))
         {
-            LoginUser loginUser = getLoginUser();
-            String avatar = FileUploadUtils.upload(ZksyConfig.getAvatarPath(), file, MimeTypeUtils.IMAGE_EXTENSION, true);
-            if (userService.updateUserAvatar(loginUser.getUserId(), avatar))
+            String oldAvatar = loginUser.getUser().getAvatar();
+            if (StringUtils.isNotEmpty(oldAvatar) && !oldAvatar.equals(avatar))
             {
-                String oldAvatar = loginUser.getUser().getAvatar();
-                if (StringUtils.isNotEmpty(oldAvatar))
-                {
-                    FileUtils.deleteFile(ZksyConfig.getProfile() + FileUtils.stripPrefix(oldAvatar));
-                }
-                AjaxResult ajax = AjaxResult.success();
-                ajax.put("imgUrl", avatar);
-                // 更新缓存用户头像
-                loginUser.getUser().setAvatar(avatar);
-                tokenService.setLoginUser(loginUser);
-                return ajax;
+                try { minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioBucket).object(normalizeObjectName(oldAvatar)).build()); } catch (Exception ignored) { }
             }
+            AjaxResult ajax = AjaxResult.success();
+            ajax.put("imgUrl", avatar);
+            // 更新缓存用户头像
+            loginUser.getUser().setAvatar(avatar);
+            tokenService.setLoginUser(loginUser);
+            return ajax;
+        }
+        return error("设置头像异常,请联系管理员");
+    }
+
+    /** 规范化对象名:去掉前导 /,无效返回 null */
+    private static String normalizeObjectName(String path) {
+        if (path == null) {
+            return null;
+        }
+        String p = path.trim();
+        while (p.startsWith("/")) {
+            p = p.substring(1);
         }
-        return error("上传图片异常,请联系管理员");
+        return p.isEmpty() ? null : p;
     }
 }

+ 5 - 6
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningController.java

@@ -27,7 +27,6 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
 
 import java.time.LocalDateTime;
 import java.io.IOException;
@@ -89,7 +88,7 @@ public class EarlyWarningController {
     public AjaxResult saveWarning(
             @RequestPart("warning") String warningJson,
             @RequestPart(value = "alarmIds", required = false) String alarmIdsJson,
-            @RequestPart(value = "files", required = false) List<MultipartFile> files) {
+            @RequestParam(value = "files", required = false) List<String> files) {
         try {
             EarlyWarning warning = JSON.parseObject(warningJson, EarlyWarning.class);
 List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
@@ -111,7 +110,7 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
     public AjaxResult updateWarning(
             @RequestPart("warning") String warningJson,
             @RequestPart(value = "alarmIds", required = false) String alarmIdsJson,
-            @RequestPart(value = "files", required = false) List<MultipartFile> files) {
+            @RequestParam(value = "files", required = false) List<String> files) {
         try {
             EarlyWarning warning = JSON.parseObject(warningJson, EarlyWarning.class);
             List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
@@ -170,7 +169,7 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
     @ApiOperation("上传预警报告,不修改流程状态")
     @PreAuthorize("@ss.hasPermi('warning:warning:edit')")
     public AjaxResult uploadReport(@PathVariable String warningId,
-                                   @RequestPart("files") List<MultipartFile> files) {
+                                   @RequestParam("files") List<String> files) {
         return earlyWarningService.uploadReport(warningId, files)
                 ? AjaxResult.success("报告上传成功") : AjaxResult.error("报告上传失败");
     }
@@ -209,7 +208,7 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
     @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
     public AjaxResult submitWarningProcess(
             @RequestPart("process") String processJson,
-            @RequestPart(value = "files", required = false) List<MultipartFile> files) {
+            @RequestParam(value = "files", required = false) List<String> files) {
         try {
             WarningProcessDTO process = JSON.parseObject(processJson, WarningProcessDTO.class);
             boolean result = earlyWarningService.submitWarningProcess(process.getWarningId(), process.getProcessContent(), files, process.getReasonIds());
@@ -272,7 +271,7 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
             @ApiParam("督办意见") @RequestParam String supervisionContent,
             @ApiParam("通知方式") @RequestParam String notificationType,
             @ApiParam("通知内容") @RequestParam(required = false) String notificationContent,
-            @ApiParam("督办附件") @RequestParam(required = false) MultipartFile attachment) {
+            @ApiParam("督办附件") @RequestParam(required = false) String attachment) {
         WarningSupervision result = earlyWarningService.supervisionWarning(warningId, supervisionUser, supervisionContent, notificationType, notificationContent, attachment);
         return AjaxResult.success("FAILED".equals(result.getNotificationStatus())
                 ? "督办已保存,但短信发送失败" : "督办通知已发送", result);

+ 2 - 23
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileAlarmControllerTest.java

@@ -2,9 +2,7 @@ package com.zksy.web.controller.mobile;
 
 import com.zksy.base.alarm.dto.*;
 import com.zksy.base.alarm.service.*;
-import com.zksy.common.exception.ServiceException;
 import org.junit.jupiter.api.*;
-import org.springframework.mock.web.MockMultipartFile;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -16,13 +14,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 class MobileAlarmControllerTest {
     private final AlarmOperationsService operations = mock(AlarmOperationsService.class);
     private final AlarmReviewService reviews = mock(AlarmReviewService.class);
-    private final AlarmReviewAttachmentService attachments = mock(AlarmReviewAttachmentService.class);
-    private final MobileAlarmController controller = new MobileAlarmController(operations, reviews, attachments);
+    private final MobileAlarmController controller = new MobileAlarmController(operations, reviews);
     private MockMvc mvc;
     @BeforeEach void setup() { mvc = MockMvcBuilders.standaloneSetup(controller).build(); }
     @Test void readsRequireLoginAndWritesRetainAuditPermission() throws Exception {
         assertEquals("isAuthenticated()", MobileAlarmController.class.getAnnotation(PreAuthorize.class).value());
-        for (String method : new String[] {"review", "upload"}) {
+        for (String method : new String[] {"review"}) {
             java.lang.reflect.Method found = java.util.Arrays.stream(MobileAlarmController.class.getDeclaredMethods())
                     .filter(m -> m.getName().equals(method)).findFirst().orElseThrow();
             assertEquals("@ss.hasPermi('alarm:review:audit')", found.getAnnotation(PreAuthorize.class).value());
@@ -50,22 +47,4 @@ class MobileAlarmControllerTest {
         }
         verify(reviews, times(2)).mobileReview(eq("a1"), any(MobileAlarmQuery.class), any(AlarmReviewRequest.class));
     }
-    @Test void uploadChecksCompanyBeforeInvokingStorage() throws Exception {
-        MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1});
-        when(operations.mobileDetail(eq("a1"), any())).thenThrow(new ServiceException("无权访问"));
-        assertThrows(ServiceException.class, () -> controller.upload("a1", new MobileAlarmQuery(), file));
-        verifyNoInteractions(attachments);
-    }
-    @Test void reviewedOrHandledAlarmCannotUpload() throws Exception {
-        MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1});
-        AlarmVO alarm = new AlarmVO(); when(operations.mobileDetail(eq("a1"), any())).thenReturn(alarm);
-        alarm.setAuditStatus(1); alarm.setAlarmStatus(0);
-        assertEquals(500, controller.upload("a1", new MobileAlarmQuery(), file).get("code"));
-        alarm.setAuditStatus(0); alarm.setAlarmStatus(1);
-        assertEquals(500, controller.upload("a1", new MobileAlarmQuery(), file).get("code"));
-        verifyNoInteractions(attachments);
-        alarm.setAlarmStatus(0);
-        controller.upload("a1", new MobileAlarmQuery(), file);
-        verify(attachments).upload("a1", file);
-    }
 }

+ 4 - 7
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileControllerMappingTest.java

@@ -2,7 +2,6 @@ package com.zksy.web.controller.mobile;
 
 import com.zksy.base.alarm.service.*;
 import com.zksy.base.warning.service.EarlyWarningService;
-import com.zksy.base.warning.service.WarningProcessAttachmentService;
 import io.minio.MinioClient;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.ops.dto.InspectTaskCompleteDTO;
@@ -40,12 +39,12 @@ class MobileControllerMappingTest {
     private final ISysLogininforService loginLogs = mock(ISysLogininforService.class);
     private final EarlyWarningService warnings = mock(EarlyWarningService.class);
     private final Object[] mobile = {
-            new MobileAlarmController(mock(AlarmOperationsService.class), mock(AlarmReviewService.class), mock(AlarmReviewAttachmentService.class)),
+            new MobileAlarmController(mock(AlarmOperationsService.class), mock(AlarmReviewService.class)),
             new MobileWorkOrderController(orders), new MobileInspectTaskController(inspections),
             new MobileEquipmentController(equipment), new MobileNoticeController(notices),
             new MobileLogininforController(loginLogs), new MobileContactsController(mock(ISysUserService.class)),
             new MobileOfficeController(equipment, mock(AlarmOperationsService.class), inspections),
-            new MobileEarlyWarningController(warnings, mock(WarningProcessAttachmentService.class), mock(MinioClient.class))
+            new MobileEarlyWarningController(warnings, mock(MinioClient.class))
     };
     private MockMvc mvc;
     private RequestMappingHandlerMapping mapping;
@@ -80,12 +79,10 @@ class MobileControllerMappingTest {
         route("GET", "/api/mobile/office/summary", MobileOfficeController.class);
         for (String path : new String[] {"", "/a1", "/a1/logs"})
             route("GET", "/api/mobile/alarm" + path, MobileAlarmController.class);
-        for (String path : new String[] {"/a1/reviews", "/a1/attachments"})
-            route("POST", "/api/mobile/alarm" + path, MobileAlarmController.class);
+        route("POST", "/api/mobile/alarm/a1/reviews", MobileAlarmController.class);
         for (String path : new String[] {"", "/pending", "/participants", "/w1", "/attachments/a1"})
             route("GET", "/api/mobile/warning" + path, MobileEarlyWarningController.class);
-        for (String path : new String[] {"/w1/publish", "/w1/receive", "/w1/start", "/w1/process",
-                "/w1/process/attachments", "/w1/verify"})
+        for (String path : new String[] {"/w1/publish", "/w1/receive", "/w1/start", "/w1/process", "/w1/verify"})
             route("POST", "/api/mobile/warning" + path, MobileEarlyWarningController.class);
     }
 

+ 3 - 2
pipe-network-service/zksy-common/pom.xml

@@ -125,8 +125,9 @@
             <version>4.1.0</version>
         </dependency>
         <dependency>
-            <groupId>com.zksy</groupId>
-            <artifactId>minioutil</artifactId>
+            <groupId>io.minio</groupId>
+            <artifactId>minio</artifactId>
+            <version>7.1.0</version>
         </dependency>
 
         <!-- 阿里云短信服务 -->

+ 34 - 0
pipe-network-service/zksy-common/src/main/java/com/zksy/common/config/MinioConfig.java

@@ -0,0 +1,34 @@
+package com.zksy.common.config;
+
+import io.minio.MinioClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * MinIO 客户端配置。
+ *
+ * 原先 MinioClient Bean 由 minioutil(com.zksy:minioutil) 内部提供;
+ * 移除该依赖后在此显式声明,配置项与 nacos 上的 minio.* 保持一致。
+ * bucket 仍由各使用方通过 @Value("${minio.bucket}") 注入。
+ */
+@Configuration
+public class MinioConfig {
+
+    @Value("${minio.endpoint}")
+    private String endpoint;
+
+    @Value("${minio.accessKey}")
+    private String accessKey;
+
+    @Value("${minio.secretKey}")
+    private String secretKey;
+
+    @Bean
+    public MinioClient minioClient() {
+        return MinioClient.builder()
+                .endpoint(endpoint)
+                .credentials(accessKey, secretKey)
+                .build();
+    }
+}

+ 75 - 0
pipe-network-service/zksy-common/src/main/java/com/zksy/common/utils/file/MinioPathUtils.java

@@ -0,0 +1,75 @@
+package com.zksy.common.utils.file;
+
+import java.util.Locale;
+
+/**
+ * MinIO 附件目录规划,按附件归属业务表划分子目录。
+ * 对象名统一为 {module}/{yyyy/MM/dd}/{uuid}.{ext}:
+ * - module(模块名)放在日期之前,便于按业务归属归类;
+ * - 不再按文件扩展名单独建一层目录。
+ * 该对象名由上传入口(MinioController 等)统一生成,调用方只需传入模块名 dir。
+ *
+ * @author zksy
+ */
+public final class MinioPathUtils {
+    private MinioPathUtils() {
+    }
+
+    /**
+     * 工单
+     */
+    public static final String MODULE_WORK_ORDER = "work-order";
+    /**
+     * 预警
+     */
+    public static final String MODULE_EARLY_WARNING = "early-warning";
+    /**
+     * 报警
+     */
+    public static final String MODULE_ALARM = "alarm";
+    /**
+     * 事件
+     */
+    public static final String MODULE_EVENT = "event";
+    /**
+     * 用户(头像等)
+     */
+    public static final String MODULE_USER = "user";
+    /**
+     * 巡检
+     */
+    public static final String MODULE_INSPECT = "inspect";
+    /**
+     * 隐患
+     */
+    public static final String MODULE_HAZARD = "hazard";
+    /**
+     * 其他
+     */
+    public static final String MODULE_OTHER = "other";
+
+    /**
+     * 校验并返回调用方传入的模块目录。
+     * 不做业务白名单限制:调用方(前端统一上传入口 / 后端)按「附件归属业务表」约定传入目录名即可,
+     * 例如 work-order、early-warning、alarm(见下方 MODULE_* 常量,仅为推荐值,非强制)。
+     * 仅做安全校验防止目录穿越:拒绝空值、含路径分隔符(/、\)、".." 或协议前缀的非法输入。
+     */
+    public static String resolveModule(String module) {
+        if (module == null) {
+            return MODULE_OTHER;
+        }
+        String key = module.trim().toLowerCase(Locale.ROOT);
+        if (key.isEmpty() || key.contains("/") || key.contains("\\") || key.contains("..") || key.contains("://")) {
+            throw new IllegalArgumentException("非法的存储模块目录:" + module);
+        }
+        return key;
+    }
+
+    /**
+     * 生成 MinIO 存储目录(dir):返回经安全校验的模块目录名。
+     * 日期目录由 minioutil 统一以 {yyyy/MM/dd} 前缀添加,无需在此拼接。
+     */
+    public static String buildDir(String module) {
+        return resolveModule(module);
+    }
+}

+ 43 - 0
pipe-network-service/zksy-common/src/main/java/com/zksy/common/utils/file/MinioUrlUtils.java

@@ -0,0 +1,43 @@
+package com.zksy.common.utils.file;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+/**
+ * MinIO 附件相对路径规整工具:库内统一存储 MinIO 对象相对路径,展示由前端拼接前缀。
+ * 仅做轻量规整(去空白、补前导 /、丢弃空项),不做历史绝对地址/外链兼容。
+ *
+ * @author zksy
+ */
+public final class MinioUrlUtils
+{
+    private MinioUrlUtils()
+    {
+    }
+
+    /**
+     * 将附件地址(支持逗号分隔多值)规整为相对路径:去空白、补前导 /、丢弃空项。
+     */
+    public static String toRelative(String input)
+    {
+        if (input == null || input.trim().isEmpty())
+        {
+            return input;
+        }
+        return Arrays.stream(input.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .map(MinioUrlUtils::ensureLeadingSlash)
+                .collect(Collectors.joining(","));
+    }
+
+    private static String ensureLeadingSlash(String value)
+    {
+        // 已是相对路径或带协议的地址(不兼容历史/外链,仅避免破坏)则原样返回
+        if (value.startsWith("/") || value.contains("://"))
+        {
+            return value;
+        }
+        return "/" + value;
+    }
+}

+ 2 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmReviewRequest.java

@@ -10,7 +10,7 @@ public class AlarmReviewRequest {
     /** 审核意见选填,最多500字。 */
     @Size(max = 500, message = "审核意见最多500字")
     private String opinion;
-    /** 服务端生成的附件票据,避免任意拼接外部附件 URL。 */
+    /** 附件相对路径列表(前端先经统一上传接口上传到 MinIO 后回传),业务接口只存路径。 */
     @Size(max = 5, message = "最多上传5个附件")
-    private java.util.List<String> attachmentTokens;
+    private java.util.List<String> attachments;
 }

+ 25 - 40
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewAttachmentService.java

@@ -1,52 +1,37 @@
 package com.zksy.base.alarm.service;
 
-import com.zksy.common.config.ZksyConfig;
-import com.zksy.common.core.redis.RedisCache;
 import com.zksy.common.exception.ServiceException;
-import com.zksy.common.utils.SecurityUtils;
-import com.zksy.common.utils.file.FileUploadUtils;
-import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
-import org.springframework.web.multipart.MultipartFile;
-import java.util.*;
-import java.util.concurrent.TimeUnit;
 
-/** 复用框架上传目录;票据绑定操作人和报警,审核接口不能伪造附件地址。 */
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * 报警审核附件:校验前端传入的相对路径并归并。
+ * 附件本身由统一上传接口先行上传到 MinIO(dir=alarm),本服务不再持有文件流或临时票据。
+ */
 @Service
-@RequiredArgsConstructor
 public class AlarmReviewAttachmentService {
-    private final RedisCache redis;
-    private static final long MAX_BYTES = 10L * 1024 * 1024;
-
-    public Map<String, String> upload(String alarmId, MultipartFile file) throws Exception {
-        if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES)
-            throw new ServiceException("附件不能为空且单个不得超过10MB");
-        String name = Objects.toString(file.getOriginalFilename(), "").toLowerCase(Locale.ROOT);
-        if (!(name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png")))
-            throw new ServiceException("仅支持JPG、JPEG、PNG后缀的附件");
-        String path = FileUploadUtils.upload(ZksyConfig.getUploadPath(), file, new String[]{"jpg", "jpeg", "png"}, true);
-        String token = UUID.randomUUID().toString();
-        redis.setCacheObject(key(alarmId, token), path, 2, TimeUnit.HOURS);
-        Map<String, String> result = new LinkedHashMap<>();
-        result.put("token", token); result.put("url", path); result.put("name", file.getOriginalFilename());
-        return result;
-    }
 
-    public String resolve(String alarmId, List<String> tokens) {
-        if (tokens == null || tokens.isEmpty()) return null;
-        if (tokens.size() > 5 || new HashSet<>(tokens).size() != tokens.size())
+    /**
+     * 校验前端回传的附件相对路径并归并为逗号分隔串。
+     * 仅做格式校验(相对路径、非绝对地址、去重、数量上限),防止拼接外部/伪造地址。
+     */
+    public String resolve(List<String> paths) {
+        if (paths == null || paths.isEmpty()) {
+            return null;
+        }
+        if (paths.size() > 5 || new HashSet<>(paths).size() != paths.size()) {
             throw new ServiceException("最多上传5个不重复的附件");
-        List<String> paths = new ArrayList<>();
-        for (String token : tokens) {
-            if (token == null || !token.matches("[a-fA-F0-9-]{36}")) throw new ServiceException("附件凭据无效");
-            String path = redis.getCacheObject(key(alarmId, token));
-            if (path == null) throw new ServiceException("附件已过期或不属于本次审核,请重新上传");
-            paths.add(path);
         }
-        return String.join(",", paths);
-    }
-
-    private String key(String alarmId, String token) {
-        return "alarm:review:attachment:" + SecurityUtils.getUserId() + ":" + alarmId + ":" + token;
+        List<String> normalized = new ArrayList<>();
+        for (String p : paths) {
+            if (p == null || p.trim().isEmpty() || p.contains("://") || !p.startsWith("/")) {
+                throw new ServiceException("附件路径无效");
+            }
+            normalized.add(p);
+        }
+        return String.join(",", normalized);
     }
 }

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java

@@ -94,7 +94,7 @@ public class AlarmReviewService {
         if (result == null || result == AlarmAuditStatusEnum.UNREVIEWED) throw new ServiceException("审核结果无效");
         String opinion = request.getOpinion() == null ? "" : request.getOpinion().trim();
         if (opinion.length() > 500) throw new ServiceException("审核意见最多500字");
-        String attachUrl = attachments.resolve(id, request.getAttachmentTokens());
+        String attachUrl = attachments.resolve(request.getAttachments());
         LocalDateTime now = LocalDateTime.now();
         // 原子条件更新锁定待审核报警;并发审核/监测恢复时,只有一方成功。
         int updated = alarmMapper.update(null, new LambdaUpdateWrapper<AlarmData>()

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningProcessRequest.java

@@ -13,7 +13,7 @@ public class MobileWarningProcessRequest {
     private String content;
 
     @Size(max = 9, message = "最多上传9个附件")
-    private List<String> attachmentTokens;
+    private List<String> attachments;
 
     @Size(max = 20, message = "一次最多选择20个处置原因")
     private List<String> reasonIds;

+ 7 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/EarlyWarningService.java

@@ -10,7 +10,6 @@ import com.zksy.base.warning.domain.WarningSupervision;
 import com.zksy.base.warning.domain.WarningAttachment;
 import com.zksy.base.warning.domain.vo.MobileWarningParticipantVO;
 import com.zksy.base.warning.dto.MobileWarningQuery;
-import org.springframework.web.multipart.MultipartFile;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -26,7 +25,7 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     IPage<Map<String, Object>> getTodoList(Page<?> page, Map<String, String> filters);
 
-    boolean uploadReport(String warningId, List<MultipartFile> files);
+    boolean uploadReport(String warningId, List<String> attachmentPaths);
 
     Map<String, Object> getWarningFullDetail(String warningId);
 
@@ -38,9 +37,9 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     void requireMobileWarningVisible(String warningId);
 
-    boolean saveWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files);
+    boolean saveWarning(EarlyWarning warning, List<String> alarmIds, List<String> attachmentPaths);
 
-    boolean updateWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files);
+    boolean updateWarning(EarlyWarning warning, List<String> alarmIds, List<String> attachmentPaths);
 
     boolean deleteWarning(String warningId);
 
@@ -64,11 +63,11 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     boolean misreportWarning(String warningId, String reason);
 
-    boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files);
+    boolean submitWarningProcess(String warningId, String processContent, List<String> attachmentPaths);
 
-    boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files, List<String> reasonIds);
+    boolean submitWarningProcess(String warningId, String processContent, List<String> attachmentPaths, List<String> reasonIds);
 
-    boolean submitMobileWarningProcess(String warningId, String processContent, List<String> attachmentTokens, List<String> reasonIds);
+    boolean submitMobileWarningProcess(String warningId, String processContent, List<String> attachments, List<String> reasonIds);
 
     boolean clearWarning(String warningId, String clearRemark);
 
@@ -78,7 +77,7 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     boolean returnWarning(String warningId, String disposalContent);
 
-    WarningSupervision supervisionWarning(String warningId, String supervisionUser, String supervisionContent, String notificationType, String notificationContent, MultipartFile attachment);
+    WarningSupervision supervisionWarning(String warningId, String supervisionUser, String supervisionContent, String notificationType, String notificationContent, String attachment);
 
     boolean readNotification(String todoId);
 

+ 0 - 85
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningProcessAttachmentService.java

@@ -1,85 +0,0 @@
-package com.zksy.base.warning.service;
-
-import com.zksy.common.core.redis.RedisCache;
-import com.zksy.common.exception.ServiceException;
-import com.zksy.common.utils.SecurityUtils;
-import com.zksy.service.MinioFileStorageService;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.stereotype.Service;
-import org.springframework.web.multipart.MultipartFile;
-
-import java.io.Serializable;
-import java.util.*;
-import java.util.concurrent.TimeUnit;
-
-/** Temporary, user-bound upload tokens for mobile warning disposal images. */
-@Service
-public class WarningProcessAttachmentService {
-    private static final long MAX_BYTES = 20L * 1024L * 1024L;
-    private final RedisCache redis;
-    private final MinioFileStorageService storage;
-
-    public WarningProcessAttachmentService(RedisCache redis,
-                                           @Qualifier("minioFileStorageService") MinioFileStorageService storage) {
-        this.redis = redis;
-        this.storage = storage;
-    }
-
-    public Map<String, String> upload(String warningId, MultipartFile file) {
-        if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) {
-            throw new ServiceException("图片不能为空且单个不得超过20MB");
-        }
-        String name = Objects.toString(file.getOriginalFilename(), "");
-        String lowerName = name.toLowerCase(Locale.ROOT);
-        if (!(lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || lowerName.endsWith(".png"))) {
-            throw new ServiceException("仅支持JPG、JPEG、PNG格式的图片");
-        }
-        final String path;
-        try {
-            path = storage.uploadFile(file, "warning");
-        } catch (Exception e) {
-            throw new ServiceException("图片上传失败,请重试");
-        }
-        String token = UUID.randomUUID().toString();
-        UploadInfo info = new UploadInfo(path, name, file.getContentType(), file.getSize());
-        redis.setCacheObject(key(warningId, token), info, 2, TimeUnit.HOURS);
-        Map<String, String> result = new LinkedHashMap<>();
-        result.put("token", token);
-        result.put("name", name);
-        return result;
-    }
-
-    public List<UploadInfo> resolve(String warningId, List<String> tokens) {
-        if (tokens == null || tokens.isEmpty()) return Collections.emptyList();
-        if (tokens.size() > 9 || new HashSet<>(tokens).size() != tokens.size()) {
-            throw new ServiceException("最多上传9个不重复的图片");
-        }
-        List<UploadInfo> result = new ArrayList<>();
-        for (String token : tokens) {
-            if (token == null || !token.matches("[a-fA-F0-9-]{36}")) throw new ServiceException("附件凭据无效");
-            UploadInfo info = redis.getCacheObject(key(warningId, token));
-            if (info == null) throw new ServiceException("附件已过期或不属于本次处置,请重新上传");
-            result.add(info);
-        }
-        return result;
-    }
-
-    public void removeTokens(String warningId, List<String> tokens) {
-        if (tokens == null) return;
-        for (String token : tokens) redis.deleteObject(key(warningId, token));
-    }
-
-    private String key(String warningId, String token) {
-        return "warning:process:attachment:" + SecurityUtils.getUserId() + ":" + warningId + ":" + token;
-    }
-
-    @lombok.Data
-    @lombok.AllArgsConstructor
-    public static class UploadInfo implements Serializable {
-        private static final long serialVersionUID = 1L;
-        private String path;
-        private String name;
-        private String contentType;
-        private Long size;
-    }
-}

+ 156 - 219
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java

@@ -10,28 +10,24 @@ import com.zksy.base.warning.domain.*;
 import com.zksy.base.warning.domain.vo.MobileWarningParticipantVO;
 import com.zksy.base.warning.dto.MobileWarningQuery;
 import com.zksy.base.warning.mapper.*;
-import com.zksy.base.warning.service.EarlyWarningService;
-import com.zksy.base.warning.service.WarningSupervisionNotifier;
-import com.zksy.base.warning.service.WarningProcessAttachmentService;
-import com.zksy.base.warning.service.WarningAccessService;
-import com.zksy.base.warning.service.WarningParticipantService;
-import com.zksy.common.core.domain.entity.SysDictData;
+import com.zksy.base.warning.service.*;
 import com.zksy.common.core.domain.entity.SysUser;
-import com.zksy.system.mapper.SysUserMapper;
-import com.zksy.common.utils.DictUtils;
-import com.zksy.common.utils.SecurityUtils;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.common.sms.SmsProperties;
 import com.zksy.common.sms.SmsTemplate;
-import com.zksy.service.MinioFileStorageService;
-import org.springframework.beans.factory.annotation.Autowired;
+import com.zksy.common.utils.SecurityUtils;
+import com.zksy.common.utils.file.MinioPathUtils;
+import io.minio.MinioClient;
+import io.minio.RemoveObjectArgs;
+import org.springframework.beans.factory.annotation.Value;
+import com.zksy.system.mapper.SysUserMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.web.multipart.MultipartFile;
 
 import java.time.LocalDateTime;
 import java.time.ZoneId;
@@ -41,15 +37,18 @@ import java.util.concurrent.ThreadLocalRandom;
 
 @Service
 @Slf4j
-public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, EarlyWarning>
-        implements EarlyWarningService {
+public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, EarlyWarning> implements EarlyWarningService {
 
     @Autowired
     private EarlyWarningMapper earlyWarningMapper;
-    @Autowired private EquipmentBaseMapper equipmentBaseMapper;
-    @Autowired private com.zksy.base.warning.service.WarningItemBindingService itemBinding;
-    @Autowired private WarningReasonMapper warningReasonMapper;
-    @Autowired private com.zksy.base.warning.service.WarningReferenceService reference;
+    @Autowired
+    private EquipmentBaseMapper equipmentBaseMapper;
+    @Autowired
+    private com.zksy.base.warning.service.WarningItemBindingService itemBinding;
+    @Autowired
+    private WarningReasonMapper warningReasonMapper;
+    @Autowired
+    private com.zksy.base.warning.service.WarningReferenceService reference;
     @Autowired
     private AlarmWarningRelMapper alarmWarningRelMapper;
     @Autowired
@@ -65,19 +64,22 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Autowired
     private WarningArchiveMapper warningArchiveMapper;
     @Autowired
-    private MinioFileStorageService minioFileStorageService;
+    private MinioClient minioClient;
+
+    @Value("${minio.bucket}")
+    private String minioBucket;
     @Autowired
     private SysUserMapper sysUserMapper;
-    @Autowired private WarningAccessService warningAccess;
-    @Autowired private WarningParticipantService participants;
+    @Autowired
+    private WarningAccessService warningAccess;
+    @Autowired
+    private WarningParticipantService participants;
     @Autowired
     private ObjectProvider<SmsTemplate> smsTemplateProvider;
     @Autowired
     private SmsProperties smsProperties;
     @Autowired
     private WarningSupervisionNotifier supervisionNotifier;
-    @Autowired
-    private WarningProcessAttachmentService processAttachmentService;
 
     @Override
     public IPage<Map<String, Object>> getWarningList(Page<?> page, String warningNo, String warningType, String warningLevel, String status) {
@@ -110,8 +112,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (!isBlank(type) && !Arrays.asList("TODO", "DONE", "NOTICE").contains(type)) {
             throw new ServiceException("待办类型只能为 TODO、DONE 或 NOTICE");
         }
-        IPage<Map<String, Object>> result = earlyWarningMapper.selectMyTasks(page, currentUser(),
-                String.valueOf(SecurityUtils.getUserId()), filters, warningAccess.current());
+        IPage<Map<String, Object>> result = earlyWarningMapper.selectMyTasks(page, currentUser(), String.valueOf(SecurityUtils.getUserId()), filters, warningAccess.current());
         result.getRecords().forEach(this::addWorkflowActions);
         return result;
     }
@@ -145,9 +146,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             throw new ServiceException("预警状态不正确");
         }
         Page<Map<String, Object>> page = new Page<>(safeQuery.getPageNum(), safeQuery.getPageSize());
-        IPage<Map<String, Object>> result = earlyWarningMapper.selectMobileWarnings(page, warningAccess.current(),
-                trimToNull(safeQuery.getKeyword()), trimToNull(safeQuery.getStatus()),
-                trimToNull(safeQuery.getWarningType()), trimToNull(safeQuery.getWarningLevel()), pendingOnly);
+        IPage<Map<String, Object>> result = earlyWarningMapper.selectMobileWarnings(page, warningAccess.current(), trimToNull(safeQuery.getKeyword()), trimToNull(safeQuery.getStatus()), trimToNull(safeQuery.getWarningType()), trimToNull(safeQuery.getWarningLevel()), pendingOnly);
         result.getRecords().forEach(this::addMobileWorkflowActions);
         return result;
     }
@@ -159,8 +158,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (detail == null) throw new ServiceException("预警不存在");
         Object basic = detail.get("basicInfo");
         if (basic instanceof Map) {
-            @SuppressWarnings("unchecked")
-            Map<String, Object> basicInfo = (Map<String, Object>) basic;
+            @SuppressWarnings("unchecked") Map<String, Object> basicInfo = (Map<String, Object>) basic;
             addMobileWorkflowActions(basicInfo);
         }
         return detail;
@@ -173,17 +171,12 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public List<MobileWarningParticipantVO> getMobileWarningParticipants(String warningId, String role) {
-        return participants.candidates(warningId, role, null, null).stream()
-                .map(user -> new MobileWarningParticipantVO(user.getUserId(), user.getUserName(),
-                        isBlank(user.getNickName()) ? user.getUserName() : user.getNickName(), user.getDeptId(),
-                        user.getDeptName()))
-                .collect(java.util.stream.Collectors.toList());
+        return participants.candidates(warningId, role, null, null).stream().map(user -> new MobileWarningParticipantVO(user.getUserId(), user.getUserName(), isBlank(user.getNickName()) ? user.getUserName() : user.getNickName(), user.getDeptId(), user.getDeptName())).collect(java.util.stream.Collectors.toList());
     }
 
     private void addMobileWorkflowActions(Map<String, Object> row) {
         addWorkflowActions(row);
-        @SuppressWarnings("unchecked")
-        List<String> actions = new ArrayList<>((List<String>) row.get("availableActions"));
+        @SuppressWarnings("unchecked") List<String> actions = new ArrayList<>((List<String>) row.get("availableActions"));
         actions.retainAll(Arrays.asList("PUBLISH", "RECEIVE", "START", "HANDLE", "CLEAR", "RETURN"));
         row.put("availableActions", actions);
     }
@@ -251,7 +244,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public boolean saveWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files) {
+    public boolean saveWarning(EarlyWarning warning, List<String> alarmIds, List<String> attachmentPaths) {
         reference.validateType(warning.getWarningType());
         bindRequiredDevice(warning);
         warning.setWarningId(null);
@@ -273,8 +266,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             if (alarmIds != null && !alarmIds.isEmpty()) {
                 linkAlarms(warning.getWarningId(), alarmIds);
             }
-            if (files != null && !files.isEmpty()) {
-              saveAttachments(warning.getWarningId(), null, "REPORT", files);
+            if (attachmentPaths != null && !attachmentPaths.isEmpty()) {
+                saveAttachments(warning.getWarningId(), null, "REPORT", attachmentPaths);
             }
         }
         return result;
@@ -285,63 +278,65 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
      * 与 zk-api-service 的 AlarmLifecycleSupport.nextWarningNo() 保持同一种子公式。
      */
     private static String generateWarningNo() {
-        return "YJ"
-                + LocalDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
-                + String.format(Locale.ROOT, "%04d", ThreadLocalRandom.current().nextInt(10000));
+        return "YJ" + LocalDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format(Locale.ROOT, "%04d", ThreadLocalRandom.current().nextInt(10000));
     }
 
     /**
      * 保存附件
      */
-    private void saveAttachments(String warningId, List<MultipartFile> files) {
-        saveAttachments(warningId, null, "REPORT", files);
-    }
-
-    private List<WarningAttachment> saveAttachments(String warningId, String disposalId, String stage, List<MultipartFile> files) {
+    private List<WarningAttachment> saveAttachments(String warningId, String disposalId, String stage, List<String> paths) {
         List<WarningAttachment> saved = new ArrayList<>();
-        try {
-            for (MultipartFile file : files) {
-                validateAttachment(file);
-                String path = minioFileStorageService.uploadFile(file, "warning");
-                if (TransactionSynchronizationManager.isSynchronizationActive()) {
-                    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                        @Override public void afterCompletion(int status) {
-                            if (status == STATUS_ROLLED_BACK) {
-                                try { minioFileStorageService.deleteFile(path); }
-                                catch (Exception ignored) { log.warn("预警附件事务回滚后的文件清理失败"); }
-                            }
-                        }
-                    });
-                }
-                // 上传文件
-                String fileName = file.getOriginalFilename();
-                if (fileName == null || fileName.trim().isEmpty()) {
-                    fileName = path.substring(path.lastIndexOf("/") + 1);
-                }
-                // 保存附件信息
-                WarningAttachment attachment = new WarningAttachment();
-                attachment.setWarningId(warningId);
-                attachment.setDisposalId(disposalId);
-                attachment.setAttachmentStage(stage);
-                attachment.setCreateBy(currentUser());
-                attachment.setAttachmentName(("PROCESS".equals(stage) ? "[处理反馈] " : "SUPERVISION".equals(stage) ? "[督办附件] " : "[预警报告] ") + fileName);
-                attachment.setAttachmentUrl(path);
-                // 部分 MIME(如 vnd.openxmlformats-officedocument.*)长度超过 64,写入前按 DB 列宽截断兜底
-                attachment.setAttachmentType(truncateAttachmentType(file.getContentType()));
-                attachment.setAttachmentSize(file.getSize());
-                attachment.setCreateTime(LocalDateTime.now());
-                warningAttachmentMapper.insert(attachment);
-                saved.add(attachment);
-            }
-            return saved;
-        } catch (Exception e) {
-            throw new RuntimeException("附件保存失败", e);
+        if (paths == null) return saved;
+        for (String raw : paths) {
+            if (isBlank(raw)) continue;
+            String path = validateAttachmentPath(raw);
+            String fileName = path.substring(path.lastIndexOf("/") + 1);
+            String extension = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(Locale.ROOT) : "";
+            WarningAttachment attachment = new WarningAttachment();
+            attachment.setWarningId(warningId);
+            attachment.setDisposalId(disposalId);
+            attachment.setAttachmentStage(stage);
+            attachment.setCreateBy(currentUser());
+            attachment.setAttachmentName(("PROCESS".equals(stage) ? "[处理反馈] " : "SUPERVISION".equals(stage) ? "[督办附件] " : "[预警报告] ") + fileName);
+            attachment.setAttachmentUrl(path);
+            attachment.setAttachmentType(contentTypeForExtension(extension));
+            attachment.setAttachmentSize(null);
+            attachment.setCreateTime(LocalDateTime.now());
+            warningAttachmentMapper.insert(attachment);
+            saved.add(attachment);
+        }
+        return saved;
+    }
+
+    private static String contentTypeForExtension(String extension) {
+        switch (extension) {
+            case "jpg":
+            case "jpeg":
+                return "image/jpeg";
+            case "png":
+                return "image/png";
+            case "gif":
+                return "image/gif";
+            case "webp":
+                return "image/webp";
+            case "pdf":
+                return "application/pdf";
+            case "xls":
+                return "application/vnd.ms-excel";
+            case "xlsx":
+                return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+            case "doc":
+                return "application/msword";
+            case "docx":
+                return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+            default:
+                return "application/octet-stream";
         }
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public boolean updateWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files) {
+    public boolean updateWarning(EarlyWarning warning, List<String> alarmIds, List<String> attachmentPaths) {
         warningAccess.requireVisible(warning.getWarningId());
         EarlyWarning existing = earlyWarningMapper.selectForUpdate(warning.getWarningId());
         WarningWorkflow.requireState(existing, "修改草稿", WarningWorkflow.DRAFT);
@@ -364,21 +359,22 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             if (alarmIds != null) {
                 linkAlarms(warning.getWarningId(), alarmIds);
             }
-            if (files != null && !files.isEmpty()) {
-                saveAttachments(warning.getWarningId(), null, "REPORT", files);
+            if (attachmentPaths != null && !attachmentPaths.isEmpty()) {
+                saveAttachments(warning.getWarningId(), null, "REPORT", attachmentPaths);
             }
         }
         return result;
     }
 
-    /** 人工新增和编辑必须关联台账设备,设备名称以台账为准。 */
+    /**
+     * 人工新增和编辑必须关联台账设备,设备名称以台账为准。
+     */
     private void bindRequiredDevice(EarlyWarning warning) {
         if (isBlank(warning.getDeviceCode())) {
             throw new ServiceException("请选择关联设备");
         }
         String code = warning.getDeviceCode().trim();
-        EquipmentBase equipment = equipmentBaseMapper.selectOne(new LambdaQueryWrapper<EquipmentBase>()
-                .eq(EquipmentBase::getEquipmentCode, code).last("LIMIT 1"));
+        EquipmentBase equipment = equipmentBaseMapper.selectOne(new LambdaQueryWrapper<EquipmentBase>().eq(EquipmentBase::getEquipmentCode, code).last("LIMIT 1"));
         if (equipment == null) {
             throw new ServiceException("关联设备不存在,请重新选择");
         }
@@ -396,15 +392,22 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         warningAccess.requireVisible(warningId);
         WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "删除", WarningWorkflow.DRAFT);
         // 先查询附件列表,删除物理文件
-        List<WarningAttachment> attachments = warningAttachmentMapper.selectList(
-                new LambdaQueryWrapper<WarningAttachment>()
-                        .eq(WarningAttachment::getWarningId, warningId)
-        );
+        List<WarningAttachment> attachments = warningAttachmentMapper.selectList(new LambdaQueryWrapper<WarningAttachment>().eq(WarningAttachment::getWarningId, warningId));
         for (WarningAttachment attachment : attachments) {
             String attachmentUrl = attachment.getAttachmentUrl();
             if (attachmentUrl != null && !attachmentUrl.isEmpty()) {
-                // 删除minio文件
-                minioFileStorageService.deleteFile(attachmentUrl);
+                String objectName = attachmentUrl.trim();
+                while (objectName.startsWith("/")) {
+                    objectName = objectName.substring(1);
+                }
+                try {
+                    minioClient.removeObject(RemoveObjectArgs.builder()
+                            .bucket(minioBucket)
+                            .object(objectName)
+                            .build());
+                } catch (Exception e) {
+                    log.warn("删除预警附件失败: {}", attachmentUrl, e);
+                }
             }
         }
 
@@ -468,9 +471,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         String assignee = isBlank(requestedHandler) ? warning.getHandler() : requestedHandler.trim();
         // 发布不能再自动指派当前登录用户,处置人必须由发布人明确选择或提前保存。
         participants.requireCandidate(warningId, "handler", assignee);
-        String reviewer = isBlank(requestedReviewer)
-                ? (isBlank(warning.getReviewer()) ? currentUser() : warning.getReviewer().trim())
-                : requestedReviewer.trim();
+        String reviewer = isBlank(requestedReviewer) ? (isBlank(warning.getReviewer()) ? currentUser() : warning.getReviewer().trim()) : requestedReviewer.trim();
         participants.requireCandidate(warningId, "reviewer", reviewer);
         warning.setReviewer(reviewer);
 
@@ -486,8 +487,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         boolean result = updateById(warning);
 
         if (result) {
-            addDisposalRecord(warningId, "RELEASE", null, null, currentUser(),
-                    "发布预警;处置人:" + assignee + ";核查人:" + reviewer);
+            addDisposalRecord(warningId, "RELEASE", null, null, currentUser(), "发布预警;处置人:" + assignee + ";核查人:" + reviewer);
             createTodo(warningId, assignee, "接收并处置预警", "TODO", "HANDLE");
         }
 
@@ -541,13 +541,13 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files) {
-        return submitWarningProcess(warningId, processContent, files, Collections.emptyList());
+    public boolean submitWarningProcess(String warningId, String processContent, List<String> attachmentPaths) {
+        return submitWarningProcess(warningId, processContent, attachmentPaths, Collections.emptyList());
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files, List<String> reasonIds) {
+    public boolean submitWarningProcess(String warningId, String processContent, List<String> attachmentPaths, List<String> reasonIds) {
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
         WarningWorkflow.requireState(warning, "提交处置结果", WarningWorkflow.PROCESSING);
         requireHandler(warning);
@@ -569,9 +569,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         List<Map<String, Object>> reasonSnapshot = new ArrayList<>();
         for (String reasonId : selectedIds) {
             WarningReason reason = warningReasonMapper.selectById(reasonId);
-            if (reason == null || !"0".equals(reason.getStatus())
-                    || !Objects.equals(reason.getWarningType(), warning.getWarningType())
-                    || !Objects.equals(reason.getWarningLevel(), warning.getWarningLevel())) {
+            if (reason == null || !"0".equals(reason.getStatus()) || !Objects.equals(reason.getWarningType(), warning.getWarningType()) || !Objects.equals(reason.getWarningLevel(), warning.getWarningLevel())) {
                 throw new ServiceException("所选原因已停用或不属于当前预警类型和级别,请重新选择");
             }
             Map<String, Object> entry = new LinkedHashMap<>();
@@ -587,8 +585,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         disposal.setReasonSnapshot(com.alibaba.fastjson2.JSON.toJSONString(reasonSnapshot));
         disposal.setCreateTime(now);
         warningDisposalMapper.insert(disposal);
-        if (files != null && !files.isEmpty()) {
-            saveAttachments(warningId, disposal.getDisposalId(), "PROCESS", files);
+        if (attachmentPaths != null && !attachmentPaths.isEmpty()) {
+            saveAttachments(warningId, disposal.getDisposalId(), "PROCESS", attachmentPaths);
         }
         completeTodo(warningId);
         createTodo(warningId, reviewerOf(warning), "核查处置结果", "TODO", "REVIEW");
@@ -597,40 +595,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public boolean submitMobileWarningProcess(String warningId, String processContent, List<String> attachmentTokens,
-                                              List<String> reasonIds) {
-        List<WarningProcessAttachmentService.UploadInfo> uploads =
-                processAttachmentService.resolve(warningId, attachmentTokens);
-        boolean success = submitWarningProcess(warningId, processContent, Collections.emptyList(), reasonIds);
-        if (!success) return false;
-
-        WarningDisposal disposal = warningDisposalMapper.selectOne(new LambdaQueryWrapper<WarningDisposal>()
-                .eq(WarningDisposal::getWarningId, warningId)
-                .eq(WarningDisposal::getDisposalType, "HANDLE")
-                .orderByDesc(WarningDisposal::getCreateTime)
-                .last("LIMIT 1"));
-        for (WarningProcessAttachmentService.UploadInfo upload : uploads) {
-            WarningAttachment attachment = new WarningAttachment();
-            attachment.setWarningId(warningId);
-            attachment.setDisposalId(disposal == null ? null : disposal.getDisposalId());
-            attachment.setAttachmentStage("PROCESS");
-            attachment.setAttachmentName("[处理反馈] " + (isBlank(upload.getName()) ? "现场图片" : upload.getName()));
-            attachment.setAttachmentUrl(upload.getPath());
-            attachment.setAttachmentType(truncateAttachmentType(upload.getContentType()));
-            attachment.setAttachmentSize(upload.getSize());
-            attachment.setCreateBy(currentUser());
-            attachment.setCreateTime(LocalDateTime.now());
-            warningAttachmentMapper.insert(attachment);
-        }
-        if (!uploads.isEmpty() && TransactionSynchronizationManager.isSynchronizationActive()) {
-            List<String> tokens = new ArrayList<>(attachmentTokens);
-            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                @Override public void afterCommit() {
-                    processAttachmentService.removeTokens(warningId, tokens);
-                }
-            });
-        }
-        return true;
+    public boolean submitMobileWarningProcess(String warningId, String processContent, List<String> attachments, List<String> reasonIds) {
+        return submitWarningProcess(warningId, processContent, attachments, reasonIds);
     }
 
     @Override
@@ -729,14 +695,13 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public boolean uploadReport(String warningId, List<MultipartFile> files) {
+    public boolean uploadReport(String warningId, List<String> attachmentPaths) {
         warningAccess.requireVisible(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
-        WarningWorkflow.requireState(warning, "上传报告", WarningWorkflow.DRAFT, WarningWorkflow.PENDING,
-                WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
-        if (files == null || files.isEmpty()) throw new ServiceException("请选择预警报告文件");
-        saveAttachments(warningId, null, "REPORT", files);
-        addDisposalRecord(warningId, "REPORT", null, null, currentUser(), "上传预警报告,共" + files.size() + "个附件");
+        WarningWorkflow.requireState(warning, "上传报告", WarningWorkflow.DRAFT, WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
+        if (attachmentPaths == null || attachmentPaths.isEmpty()) throw new ServiceException("请选择预警报告文件");
+        saveAttachments(warningId, null, "REPORT", attachmentPaths);
+        addDisposalRecord(warningId, "REPORT", null, null, currentUser(), "上传预警报告,共" + attachmentPaths.size() + "个附件");
         return true;
     }
 
@@ -745,12 +710,9 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     public boolean upgradeWarning(String warningId, String newLevel, String disposalContent) {
         warningAccess.requireVisible(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
-        WarningWorkflow.requireState(warning, "升级", WarningWorkflow.PENDING, WarningWorkflow.RECEIVED,
-                WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
+        WarningWorkflow.requireState(warning, "升级", WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
         if (isBlank(disposalContent)) throw new ServiceException("请填写会商研判意见和升级依据");
-        if (newLevel == null || !newLevel.matches("[1-4]")
-                || warning.getWarningLevel() == null || !warning.getWarningLevel().matches("[1-4]")
-                || Integer.parseInt(newLevel) >= Integer.parseInt(warning.getWarningLevel())) {
+        if (newLevel == null || !newLevel.matches("[1-4]") || warning.getWarningLevel() == null || !warning.getWarningLevel().matches("[1-4]") || Integer.parseInt(newLevel) >= Integer.parseInt(warning.getWarningLevel())) {
             throw new ServiceException("只能升级至更严重的级别,一级为最高级");
         }
 
@@ -795,19 +757,19 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public WarningSupervision supervisionWarning(String warningId, String supervisionUser, String supervisionContent, String notificationType, String notificationContent, MultipartFile attachment) {
+    public WarningSupervision supervisionWarning(String warningId, String supervisionUser, String supervisionContent, String notificationType, String notificationContent, String attachment) {
         warningAccess.requireVisible(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
-        WarningWorkflow.requireState(warning, "督办",
-                WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
-        if (isBlank(supervisionUser) || isBlank(supervisionContent)) throw new ServiceException("请填写督办人员和督办意见");
+        WarningWorkflow.requireState(warning, "督办", WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
+        if (isBlank(supervisionUser) || isBlank(supervisionContent))
+            throw new ServiceException("请填写督办人员和督办意见");
         if (!"短信".equals(notificationType) && !"系统通知".equals(notificationType)) {
             throw new ServiceException("督办通知方式仅支持短信或系统通知");
         }
         SysUser recipient = requireActiveUser(supervisionUser, "督办人员");
         String message = isBlank(notificationContent) ? supervisionContent.trim() : notificationContent.trim();
         if (message.length() > 180) throw new ServiceException("通知内容不能超过180字");
-        if (attachment != null) validateAttachment(attachment);
+        if (attachment != null) validateAttachmentPath(attachment);
         if ("短信".equals(notificationType)) {
             if (isBlank(recipient.getPhonenumber())) throw new ServiceException("督办人员未配置手机号");
             if (smsTemplateProvider.getIfAvailable() == null || isBlank(smsProperties.getSupervisionTemplateCode())) {
@@ -843,9 +805,11 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             createTodo(warningId, recipient.getUserName(), message, "NOTICE", "NOTICE");
         } else {
             TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                @Override public void afterCommit() {
-                    try { supervisionNotifier.send(supervision, recipient.getPhonenumber(), warning.getWarningNo()); }
-                    catch (Exception ignored) {
+                @Override
+                public void afterCommit() {
+                    try {
+                        supervisionNotifier.send(supervision, recipient.getPhonenumber(), warning.getWarningNo());
+                    } catch (Exception ignored) {
                         supervision.setNotificationStatus("FAILED");
                         supervision.setNotificationError("短信发送结果未能确认,请联系管理员核查,勿重复提交");
                         log.warn("督办短信发送结果保存失败,supervisionId={}", supervision.getSupervisionId());
@@ -859,8 +823,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     public boolean readNotification(String todoId) {
         WarningTodo todo = warningTodoMapper.selectById(todoId);
-        if (todo == null || !"NOTICE".equals(todo.getTodoType())
-                || !Objects.equals(String.valueOf(SecurityUtils.getUserId()), todo.getUserId())) return false;
+        if (todo == null || !"NOTICE".equals(todo.getTodoType()) || !Objects.equals(String.valueOf(SecurityUtils.getUserId()), todo.getUserId()))
+            return false;
         todo.setReadStatus("READ");
         return warningTodoMapper.updateById(todo) > 0;
     }
@@ -869,8 +833,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Transactional(rollbackFor = Exception.class)
     public boolean addLeaderInstruction(String warningId, String leaderName, String instructionContent, String smsReceivers) {
         warningAccess.requireVisible(warningId);
-        WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "添加领导批示",
-                WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
+        WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "添加领导批示", WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
         if (isBlank(leaderName) || isBlank(instructionContent)) throw new ServiceException("请填写批示领导和批示内容");
         WarningDisposal disposal = new WarningDisposal();
         disposal.setWarningId(warningId);
@@ -886,8 +849,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Transactional(rollbackFor = Exception.class)
     public boolean linkAlarms(String warningId, List<String> alarmIds) {
         warningAccess.requireVisible(warningId);
-        WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "关联报警",
-                WarningWorkflow.DRAFT, WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
+        WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "关联报警", WarningWorkflow.DRAFT, WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
         LambdaQueryWrapper<AlarmWarningRel> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AlarmWarningRel::getWarningId, warningId);
         alarmWarningRelMapper.delete(wrapper);
@@ -1030,11 +992,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     }
 
     private List<Map<String, Object>> getAttachmentList(String warningId) {
-        List<WarningAttachment> list = warningAttachmentMapper.selectList(
-                new LambdaQueryWrapper<WarningAttachment>()
-                        .eq(WarningAttachment::getWarningId, warningId)
-                        .orderByDesc(WarningAttachment::getCreateTime)
-        );
+        List<WarningAttachment> list = warningAttachmentMapper.selectList(new LambdaQueryWrapper<WarningAttachment>().eq(WarningAttachment::getWarningId, warningId).orderByDesc(WarningAttachment::getCreateTime));
 
         List<Map<String, Object>> result = new ArrayList<>();
         for (WarningAttachment attachment : list) {
@@ -1048,8 +1006,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             map.put("createBy", attachment.getCreateBy());
             map.put("disposalId", attachment.getDisposalId());
             String attachmentName = attachment.getAttachmentName();
-            map.put("attachmentStage", isBlank(attachment.getAttachmentStage())
-                    ? (attachmentName != null && attachmentName.startsWith("[处理反馈]") ? "PROCESS" : "REPORT") : attachment.getAttachmentStage());
+            map.put("attachmentStage", isBlank(attachment.getAttachmentStage()) ? (attachmentName != null && attachmentName.startsWith("[处理反馈]") ? "PROCESS" : "REPORT") : attachment.getAttachmentStage());
             result.add(map);
         }
         return result;
@@ -1088,8 +1045,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     private void completeTodo(String warningId) {
         LambdaQueryWrapper<WarningTodo> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(WarningTodo::getWarningId, warningId)
-                .eq(WarningTodo::getTodoType, "TODO");
+        wrapper.eq(WarningTodo::getWarningId, warningId).eq(WarningTodo::getTodoType, "TODO");
         List<WarningTodo> todos = warningTodoMapper.selectList(wrapper);
 
         for (WarningTodo todo : todos) {
@@ -1110,11 +1066,11 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public IPage<WarningArchive> getArchiveList(Page<WarningArchive> page, String warningNo, String warningType, String warningLevel, LocalDateTime startTime, LocalDateTime endTime) {
-        return earlyWarningMapper.selectVisibleArchives(page, warningNo, warningType, warningLevel,
-                startTime, endTime, warningAccess.current());
+        return earlyWarningMapper.selectVisibleArchives(page, warningNo, warningType, warningLevel, startTime, endTime, warningAccess.current());
     }
 
     private static final Map<String, String> DISPOSAL_TYPE_MAP = new HashMap<>();
+
     static {
         DISPOSAL_TYPE_MAP.put("RELEASE", "发布预警");
         DISPOSAL_TYPE_MAP.put("RECEIVE", "接收预警");
@@ -1179,8 +1135,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             taskNode.put("y", 150);
             taskNode.put("status", "completed");
             taskNode.put("disposalUser", disposal.getDisposalUser());
-            taskNode.put("disposalTime", disposal.getCreateTime() != null ?
-                    disposal.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
+            taskNode.put("disposalTime", disposal.getCreateTime() != null ? disposal.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
             taskNode.put("disposalId", disposal.getDisposalId());
             nodes.add(taskNode);
 
@@ -1253,10 +1208,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         result.put("supervisionUser", disposal.getSupervisionUser());
         result.put("supervisionContent", disposal.getSupervisionContent());
         result.put("notificationType", disposal.getNotificationType());
-        result.put("createTime", disposal.getCreateTime() != null ?
-                disposal.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
-        result.put("attachmentList", warningAttachmentMapper.selectList(
-                new LambdaQueryWrapper<WarningAttachment>().eq(WarningAttachment::getDisposalId, disposalId)));
+        result.put("createTime", disposal.getCreateTime() != null ? disposal.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
+        result.put("attachmentList", warningAttachmentMapper.selectList(new LambdaQueryWrapper<WarningAttachment>().eq(WarningAttachment::getDisposalId, disposalId)));
 
         return result;
     }
@@ -1289,10 +1242,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         result.put("handlerCount", handlers.size());
 
         if (!disposals.isEmpty()) {
-            result.put("firstDisposalTime", disposals.get(0).getCreateTime() != null ?
-                    disposals.get(0).getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
-            result.put("lastDisposalTime", disposals.get(disposals.size() - 1).getCreateTime() != null ?
-                    disposals.get(disposals.size() - 1).getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
+            result.put("firstDisposalTime", disposals.get(0).getCreateTime() != null ? disposals.get(0).getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
+            result.put("lastDisposalTime", disposals.get(disposals.size() - 1).getCreateTime() != null ? disposals.get(disposals.size() - 1).getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
         }
 
         return result;
@@ -1313,37 +1264,25 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         return contentType.substring(0, ATTACHMENT_TYPE_MAX_LEN);
     }
 
-    private static final Set<String> ALLOWED_ATTACHMENT_EXTENSIONS = new HashSet<>(Arrays.asList(
-            "jpg", "jpeg", "png", "gif", "webp", "pdf", "xls", "xlsx", "doc", "docx"
-    ));
+    private static final Set<String> ALLOWED_ATTACHMENT_EXTENSIONS = new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif", "webp", "pdf", "xls", "xlsx", "doc", "docx"));
 
-    private void validateAttachment(MultipartFile file) {
-        if (file == null || file.isEmpty()) {
-            throw new IllegalArgumentException("上传文件不能为空");
-        }
-        String originalName = file.getOriginalFilename();
-        String extension = "";
-        if (originalName != null && originalName.lastIndexOf('.') >= 0) {
-            extension = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
-        }
-        if (!ALLOWED_ATTACHMENT_EXTENSIONS.contains(extension)) {
-            throw new IllegalArgumentException("仅支持图片、PDF、Excel、Word文件");
-        }
-        String contentType = file.getContentType();
-        if (contentType != null && !contentType.trim().isEmpty()
-                && !"application/octet-stream".equalsIgnoreCase(contentType)
-                && !isCompatibleAttachmentType(extension, contentType)) {
-            throw new IllegalArgumentException("附件类型与文件扩展名不匹配");
+    private String validateAttachmentPath(String path) {
+        if (isBlank(path) || path.contains("://") || !path.startsWith("/")) {
+            throw new ServiceException("附件路径无效");
         }
-        if (file.getSize() > 20 * 1024 * 1024L) {
-            throw new IllegalArgumentException("单个附件不能超过20MB");
+        String normalized = path.trim().replaceFirst("^/+", "");
+        String lower = normalized.toLowerCase(Locale.ROOT);
+        boolean allowed = false;
+        for (String ext : ALLOWED_ATTACHMENT_EXTENSIONS) {
+            if (lower.endsWith("." + ext)) { allowed = true; break; }
         }
+        if (!allowed) throw new ServiceException("仅支持图片、PDF、Excel、Word文件");
+        return normalized;
     }
 
     private boolean isCompatibleAttachmentType(String extension, String contentType) {
         String type = contentType.toLowerCase(Locale.ROOT);
-        if ("jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension)
-                || "gif".equals(extension) || "webp".equals(extension)) {
+        if ("jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension) || "gif".equals(extension) || "webp".equals(extension)) {
             return type.startsWith("image/");
         }
         if ("pdf".equals(extension)) {
@@ -1353,13 +1292,11 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             return "application/vnd.ms-excel".equals(type) || "application/octet-stream".equals(type);
         }
         if ("xlsx".equals(extension)) {
-            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equals(type)
-                    || "application/zip".equals(type) || "application/octet-stream".equals(type);
+            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equals(type) || "application/zip".equals(type) || "application/octet-stream".equals(type);
         }
         if ("doc".equals(extension)) {
             return "application/msword".equals(type) || "application/octet-stream".equals(type);
         }
-        return "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equals(type)
-                || "application/zip".equals(type) || "application/octet-stream".equals(type);
+        return "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equals(type) || "application/zip".equals(type) || "application/octet-stream".equals(type);
     }
 }

+ 8 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/service/impl/DisEventLogServiceImpl.java

@@ -8,11 +8,10 @@ import com.zksy.dispatch.dto.EventLogPageDTO;
 import com.zksy.dispatch.enums.EventLogOperTypeEnum;
 import com.zksy.dispatch.mapper.DisEventLogMapper;
 import com.zksy.dispatch.service.DisEventLogService;
-import com.zksy.dispatch.support.DispatchAttachUrlSupport;
+import com.zksy.common.utils.file.MinioUrlUtils;
 import com.zksy.dispatch.support.DispatchUserSupport;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 
@@ -30,9 +29,10 @@ import java.util.List;
 public class DisEventLogServiceImpl extends ServiceImpl<DisEventLogMapper, DisEventLog>
         implements DisEventLogService {
 
-    /** 附件地址支撑:日志附件库内相对路径 → 出口拼 MinIO 配置地址 */
-    @Autowired
-    private DispatchAttachUrlSupport attachUrlSupport;
+    /** 附件地址规整为 MinIO 相对路径(库内统一存相对路径,前端拼前缀展示) */
+    private String toRelativeAttach(String input) {
+        return MinioUrlUtils.toRelative(input);
+    }
 
     @Override
     public void append(DisEventLog log) {
@@ -59,7 +59,7 @@ public class DisEventLogServiceImpl extends ServiceImpl<DisEventLogMapper, DisEv
         Page<DisEventLog> page = new Page<>(dto.getPageNum(), dto.getPageSize());
         List<DisEventLog> result = this.page(page, buildWrapper(dto)).getRecords();
         result.forEach(this::fillOperTypeName);
-        result.forEach(l -> l.setAttachUrl(attachUrlSupport.toAccessibleUrl(l.getAttachUrl())));
+        result.forEach(l -> l.setAttachUrl(toRelativeAttach(l.getAttachUrl())));
         return page;
     }
 
@@ -70,7 +70,7 @@ public class DisEventLogServiceImpl extends ServiceImpl<DisEventLogMapper, DisEv
         wrapper.orderByAsc(DisEventLog::getOperTime).orderByAsc(DisEventLog::getId);
         List<DisEventLog> list = this.list(wrapper);
         list.forEach(this::fillOperTypeName);
-        list.forEach(l -> l.setAttachUrl(attachUrlSupport.toAccessibleUrl(l.getAttachUrl())));
+        list.forEach(l -> l.setAttachUrl(toRelativeAttach(l.getAttachUrl())));
         return list;
     }
 
@@ -98,7 +98,7 @@ public class DisEventLogServiceImpl extends ServiceImpl<DisEventLogMapper, DisEv
         wrapper.orderByAsc(DisEventLog::getOperTime).orderByAsc(DisEventLog::getId);
         List<DisEventLog> list = this.list(wrapper);
         list.forEach(this::fillOperTypeName);
-        list.forEach(l -> l.setAttachUrl(attachUrlSupport.toAccessibleUrl(l.getAttachUrl())));
+        list.forEach(l -> l.setAttachUrl(toRelativeAttach(l.getAttachUrl())));
         return list;
     }
 

+ 10 - 9
pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/service/impl/DisEventServiceImpl.java

@@ -24,7 +24,7 @@ import com.zksy.dispatch.dto.EventUrgePageDTO;
 import com.zksy.dispatch.dto.EventUrgeRowVO;
 import com.zksy.dispatch.dto.EventVerifyDTO;
 import com.zksy.dispatch.dto.EventAuditDTO;
-import com.zksy.dispatch.support.DispatchAttachUrlSupport;
+import com.zksy.common.utils.file.MinioUrlUtils;
 import com.zksy.dispatch.dto.SseMessageDto;
 import com.zksy.dispatch.enums.EventLogOperTypeEnum;
 import com.zksy.dispatch.enums.EventStatusEnum;
@@ -87,9 +87,10 @@ public class DisEventServiceImpl extends ServiceImpl<DisEventMapper, DisEvent>
     @Autowired
     private DisEventLogService eventLogService;
 
-    /** 附件地址支撑:库内相对路径 ↔ 展示完整 URL(MinIO 配置拼接) */
-    @Autowired
-    private DispatchAttachUrlSupport attachUrlSupport;
+    /** 附件地址规整为 MinIO 相对路径(库内统一存相对路径,前端拼前缀展示) */
+    private String toRelativeAttach(String input) {
+        return MinioUrlUtils.toRelative(input);
+    }
 
     /** 人员引用查询(选人下拉 + 按ID批量反查昵称) */
     @Autowired
@@ -591,7 +592,7 @@ public class DisEventServiceImpl extends ServiceImpl<DisEventMapper, DisEvent>
         DisEventLog log = buildLog(db, operType, null, null);
         log.setTargets(String.join("、", depts));
         log.setOperDesc(dto.getOperDesc());
-        log.setAttachUrl(attachUrlSupport.toRelative(dto.getAttachUrl()));
+        log.setAttachUrl(toRelativeAttach(dto.getAttachUrl()));
         eventLogService.append(log);
     }
 
@@ -615,7 +616,7 @@ public class DisEventServiceImpl extends ServiceImpl<DisEventMapper, DisEvent>
 
         DisEventLog log = buildLog(db, EventLogOperTypeEnum.START_DISPOSE, statusFrom, db.getStatus());
         log.setOperDesc(StringUtils.defaultIfBlank(dto.getOperDesc(), "开始处置"));
-        log.setAttachUrl(attachUrlSupport.toRelative(dto.getAttachUrl()));
+        log.setAttachUrl(toRelativeAttach(dto.getAttachUrl()));
         eventLogService.append(log);
     }
 
@@ -644,7 +645,7 @@ public class DisEventServiceImpl extends ServiceImpl<DisEventMapper, DisEvent>
         log.setTrend(dto.getTrend());
         log.setNeedUpgrade(dto.getNeedUpgrade());
         log.setNextVisitTime(dto.getNextVisitTime());
-        log.setAttachUrl(attachUrlSupport.toRelative(dto.getAttachUrl()));
+        log.setAttachUrl(toRelativeAttach(dto.getAttachUrl()));
         eventLogService.append(log);
     }
 
@@ -665,7 +666,7 @@ public class DisEventServiceImpl extends ServiceImpl<DisEventMapper, DisEvent>
 
         DisEventLog log = buildLog(db, EventLogOperTypeEnum.SUBMIT_VERIFY, statusFrom, db.getStatus());
         log.setOperDesc(StringUtils.defaultIfBlank(dto.getOperDesc(), "处置人提交核验"));
-        log.setAttachUrl(attachUrlSupport.toRelative(dto.getAttachUrl()));
+        log.setAttachUrl(toRelativeAttach(dto.getAttachUrl()));
         eventLogService.append(log);
     }
 
@@ -701,7 +702,7 @@ public class DisEventServiceImpl extends ServiceImpl<DisEventMapper, DisEvent>
 
         DisEventLog log = buildLog(db, EventLogOperTypeEnum.AUDIT, statusFrom, db.getStatus());
         log.setOperDesc(StringUtils.defaultIfBlank(dto.getAuditOpinion(), Boolean.TRUE.equals(dto.getResolved()) ? "核验结果:已解除" : "核验结果:未解除,继续处置"));
-        log.setAttachUrl(attachUrlSupport.toRelative(dto.getAttachUrl()));
+        log.setAttachUrl(toRelativeAttach(dto.getAttachUrl()));
         eventLogService.append(log);
     }
 

+ 0 - 89
pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/support/DispatchAttachUrlSupport.java

@@ -1,89 +0,0 @@
-package com.zksy.dispatch.support;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-import org.springframework.util.StringUtils;
-
-import java.util.Arrays;
-import java.util.stream.Collectors;
-
-/**
- * 调度附件地址支撑:数据库统一存相对路径(如 /dispatch/2026/10/16/x.docx),
- * 读出口拼 minio.endpoint + bucket 返回完整 URL。
- * 两个方法都兼容 http 开头的历史存量值,不会二次处理。
- * @author zengy
- */
-@Component
-public class DispatchAttachUrlSupport {
-
-    /** MinIO 访问地址(minio.endpoint,与 MinioController 的 readPath 同源) */
-    @Value("${minio.endpoint}")
-    private String endpoint;
-
-    /** MinIO 存储桶(minio.bucket) */
-    @Value("${minio.bucket}")
-    private String bucket;
-
-    /**
-     * 库内存储值 → 可访问完整 URL(展示/下载出口统一走这里)
-     */
-    public String toAccessibleUrl(String stored) {
-        if (!StringUtils.hasText(stored)) {
-            return stored;
-        }
-        return Arrays.stream(stored.split(","))
-                .map(this::toAccessibleSingle)
-                .filter(StringUtils::hasText)
-                .collect(Collectors.joining(","));
-    }
-
-    private String toAccessibleSingle(String stored) {
-        if (!StringUtils.hasText(stored)) {
-            return stored;
-        }
-        String value = stored.trim();
-        // 历史存量已是完整 URL(或外部链接),原样返回,避免二次拼接
-        if (value.startsWith("http://") || value.startsWith("https://")) {
-            return value;
-        }
-        String base = endpoint == null ? "" : (endpoint.endsWith("/") ? endpoint : endpoint + "/");
-        String path = value.startsWith("/") ? value.substring(1) : value;
-        return base + (bucket == null ? "" : bucket + "/") + path;
-    }
-
-    /**
-     * 前端/入参值 → 库内相对路径(写入口统一走这里)
-     */
-    public String toRelative(String input) {
-        if (!StringUtils.hasText(input)) {
-            return input;
-        }
-        return Arrays.stream(input.split(","))
-                .map(this::toRelativeSingle)
-                .filter(StringUtils::hasText)
-                .collect(Collectors.joining(","));
-    }
-
-    private String toRelativeSingle(String input) {
-        if (!StringUtils.hasText(input)) {
-            return input;
-        }
-        String value = input.trim();
-        if (!value.startsWith("http://") && !value.startsWith("https://")) {
-            // 已是相对路径,规整为以 / 开头后返回
-            return value.startsWith("/") ? value : "/" + value;
-        }
-        String base = endpoint == null ? "" : (endpoint.endsWith("/") ? endpoint : endpoint + "/");
-        String prefix = base + (bucket == null ? "" : bucket + "/");
-        if (value.startsWith(prefix)) {
-            return "/" + value.substring(prefix.length());
-        }
-        // 域名与当前配置不一致的历史数据:按「/bucket/路径」截取,尽量还原相对路径
-        int idx = value.indexOf("/" + bucket + "/");
-        if (idx >= 0) {
-            return value.substring(idx + ("/" + bucket).length());
-        }
-        // 实在没有 bucket 段(外部地址),原样保留
-        return value;
-    }
-}

+ 0 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/InspectTask.java

@@ -70,7 +70,4 @@ public class InspectTask implements Serializable {
     @ApiModelProperty(value = "现场照片地址数组")
     @TableField(exist = false)
     private List<String> photoList;
-    @ApiModelProperty(value = "现场照片访问基础地址")
-    @TableField(exist = false)
-    private String attachmentBaseUrl;
 }

+ 0 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderDetailVO.java

@@ -27,6 +27,4 @@ public class WorkOrderDetailVO implements Serializable {
     @ApiModelProperty(value = "操作日志")
     private List<WorkOrderLogVO> logs = new ArrayList<>();
 
-    @ApiModelProperty(value = "附件访问基础地址,移动端用于拼接相对路径")
-    private String attachmentBaseUrl;
 }

+ 0 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/InspectTaskServiceImpl.java

@@ -19,7 +19,6 @@ import com.zksy.system.service.ISysDeptService;
 import com.zksy.system.service.ISysUserService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -83,12 +82,6 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
     @Autowired
     private ISysDeptService deptService;
 
-    @Value("${minio.endpoint}")
-    private String minioEndpoint;
-
-    @Value("${minio.bucket}")
-    private String minioBucket;
-
     /**
      * 分页查询巡检任务列表
      */
@@ -166,7 +159,6 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
     public InspectTask getMobileDetailById(Long inspectTaskId) {
         InspectTask task = getDetailById(inspectTaskId);
         validateMobileAssignee(task);
-        task.setAttachmentBaseUrl(minioEndpoint.replaceAll("/+$", "") + "/" + minioBucket + "/");
         return task;
     }
 

+ 0 - 20
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java

@@ -54,7 +54,6 @@ import com.zksy.system.service.ISysUserService;
 import com.zksy.system.service.ISysMenuService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
@@ -114,12 +113,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     private static final String PERM_PROCESS = "ops:workOrder:process";
     private static final String PERM_AUDIT = "ops:workOrder:audit";
 
-    @Value("${minio.endpoint}")
-    private String minioEndpoint;
-
-    @Value("${minio.bucket}")
-    private String minioBucket;
-
     @Autowired
     private WorkOrderLogMapper workOrderLogMapper;
 
@@ -277,17 +270,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         WorkOrderDetailVO detail = new WorkOrderDetailVO();
         detail.setOrder(vo);
         detail.setLogs(listLogs(orderId));
-        detail.setAttachmentBaseUrl(attachmentBaseUrl());
         return detail;
     }
 
-    private String attachmentBaseUrl() {
-        if (StringUtils.isBlank(minioEndpoint) || StringUtils.isBlank(minioBucket)) {
-            return null;
-        }
-        return minioEndpoint.replaceAll("/+$", "") + "/" + minioBucket + "/";
-    }
-
     @Override
     public WorkOrderDetailVO getMobileDetail(Long orderId) {
         WorkOrder order = getOrderById(orderId);
@@ -1061,11 +1046,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
 
     private String normalizeAttachmentPath(String value) {
         String path = value;
-        String base = StringUtils.isBlank(minioEndpoint) || StringUtils.isBlank(minioBucket)
-                ? "" : minioEndpoint.replaceAll("/+$", "") + "/" + minioBucket + "/";
-        if (!base.isEmpty() && path.startsWith(base)) {
-            path = path.substring(base.length());
-        }
         try {
             java.net.URI uri = java.net.URI.create(path);
             String decodedPath = uri.getPath();

+ 3 - 3
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewServiceTest.java

@@ -85,7 +85,7 @@ class AlarmReviewServiceTest {
 
     @Test void mobileConfirmationSharesOrderAndAuditLogTransaction() {
         when(queries.selectMobileDetail(eq("a1"), eq(query), anyList())).thenReturn(alarm);
-        when(attachments.resolve(eq("a1"), any())).thenReturn(null);
+        when(attachments.resolve(any())).thenReturn(null);
         when(orders.createWorkOrderFromAlarm("a1", null, null)).thenReturn(88L);
         AlarmReviewVO reviewed = service.reviewInScope("a1", query, Collections.singletonList(12),
                 request(AlarmAuditStatusEnum.CONFIRMED));
@@ -123,8 +123,8 @@ class AlarmReviewServiceTest {
     }
     @Test void falseAlarmRecordsOpinionAndAttachmentsWithoutCreatingOrder() {
         AlarmReviewRequest request = request(AlarmAuditStatusEnum.FALSE_ALARM);
-        request.setAttachmentTokens(Collections.singletonList("ticket"));
-        when(attachments.resolve("a1", request.getAttachmentTokens())).thenReturn("/profile/test.pdf");
+        request.setAttachments(Collections.singletonList("/2026/10/09/alarm/jpg/abc123.png"));
+        when(attachments.resolve(request.getAttachments())).thenReturn("/profile/test.pdf");
         AlarmReviewVO result = service.review("a1", query, request);
         assertEquals(0, result.getAlarmStatus()); assertEquals(2, result.getAuditStatus());
         verifyNoInteractions(orders);

+ 153 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImplTest.java

@@ -0,0 +1,153 @@
+package com.zksy.base.warning.service.impl;
+
+import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.domain.EarlyWarningStatusEnum;
+import com.zksy.base.warning.domain.WarningAttachment;
+import com.zksy.base.warning.domain.WarningDisposal;
+import com.zksy.base.warning.domain.WarningTodo;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.mapper.WarningArchiveMapper;
+import com.zksy.base.warning.mapper.WarningAttachmentMapper;
+import com.zksy.base.warning.mapper.WarningDisposalMapper;
+import com.zksy.base.warning.mapper.WarningReasonMapper;
+import com.zksy.base.warning.mapper.WarningSupervisionMapper;
+import com.zksy.base.warning.mapper.WarningTodoMapper;
+import com.zksy.base.warning.service.WarningAccessService;
+import com.zksy.base.warning.service.WarningItemBindingService;
+import com.zksy.base.warning.service.WarningParticipantService;
+import com.zksy.base.warning.service.WarningReferenceService;
+import com.zksy.base.warning.service.WarningSupervisionNotifier;
+import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.common.sms.SmsProperties;
+import com.zksy.common.sms.SmsTemplate;
+
+import com.zksy.system.mapper.SysUserMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 验证预警处置改造:业务接口接收前端传来的相对路径,直接落库为 WarningAttachment,
+ * 不再内联调用 MinIO 上传(先传后存)。
+ */
+class EarlyWarningServiceImplTest {
+
+    @Mock
+    private com.zksy.base.warning.mapper.EarlyWarningMapper earlyWarningMapper;
+    @Mock
+    private com.zksy.base.mapper.EquipmentBaseMapper equipmentBaseMapper;
+    @Mock
+    private WarningItemBindingService itemBinding;
+    @Mock
+    private WarningReasonMapper warningReasonMapper;
+    @Mock
+    private WarningReferenceService reference;
+    @Mock
+    private AlarmWarningRelMapper alarmWarningRelMapper;
+    @Mock
+    private WarningAttachmentMapper warningAttachmentMapper;
+    @Mock
+    private WarningDisposalMapper warningDisposalMapper;
+    @Mock
+    private WarningTodoMapper warningTodoMapper;
+    @Mock
+    private WarningSupervisionMapper warningSupervisionMapper;
+    @Mock
+    private WarningArchiveMapper warningArchiveMapper;
+    @Mock
+    private SysUserMapper sysUserMapper;
+    @Mock
+    private WarningAccessService warningAccess;
+    @Mock
+    private WarningParticipantService participants;
+    @Mock
+    private ObjectProvider<SmsTemplate> smsTemplateProvider;
+    @Mock
+    private SmsProperties smsProperties;
+    @Mock
+    private WarningSupervisionNotifier supervisionNotifier;
+
+    @InjectMocks
+    private EarlyWarningServiceImpl service;
+
+    private AutoCloseable mocks;
+
+    @BeforeEach
+    void init() throws Exception {
+        mocks = MockitoAnnotations.openMocks(this);
+        // 注入 MyBatis-Plus 基类的 baseMapper(@InjectMocks 不覆盖继承的 protected 字段)
+        java.lang.reflect.Field baseMapperField = EarlyWarningServiceImpl.class.getSuperclass().getDeclaredField("baseMapper");
+        baseMapperField.setAccessible(true);
+        baseMapperField.set(service, earlyWarningMapper);
+        SysUser loginSysUser = new SysUser();
+        loginSysUser.setUserName("handler");
+        LoginUser loginUser = new LoginUser();
+        loginUser.setUser(loginSysUser);
+        loginUser.setPermissions(Collections.singleton("*:*:*"));
+        Authentication auth = new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList());
+        SecurityContextHolder.getContext().setAuthentication(auth);
+    }
+
+    @AfterEach
+    void close() throws Exception {
+        mocks.close();
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void submitWarningProcessStoresRelativePathsWithoutUploadingToMinio() throws Exception {
+        EarlyWarning warning = new EarlyWarning();
+        warning.setWarningId("w1");
+        warning.setStatus(EarlyWarningStatusEnum.PROCESSING.getCode());
+        warning.setHandler("handler");
+        warning.setReviewer("handler");
+        warning.setWarningType("T");
+        warning.setWarningLevel("L");
+        when(earlyWarningMapper.selectForUpdate("w1")).thenReturn(warning);
+        when(earlyWarningMapper.updateById(any(EarlyWarning.class))).thenReturn(1);
+        when(warningDisposalMapper.insert(any(WarningDisposal.class))).thenReturn(1);
+        when(warningAttachmentMapper.insert(any(WarningAttachment.class))).thenReturn(1);
+        when(warningTodoMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(warningTodoMapper.insert(any(WarningTodo.class))).thenReturn(1);
+        SysUser user = new SysUser();
+        user.setStatus("0");
+        user.setDelFlag("0");
+        user.setUserId(1L);
+        user.setUserName("handler");
+        when(sysUserMapper.selectUserByUserName("handler")).thenReturn(user);
+
+        List<String> paths = Arrays.asList(
+                "/2026/10/26/early-warning/png/a.png",
+                "/2026/10/26/early-warning/jpg/b.jpg");
+        boolean result = service.submitWarningProcess("w1", "已现场处置", paths, Collections.emptyList());
+
+        assertTrue(result);
+        ArgumentCaptor<WarningAttachment> captor = ArgumentCaptor.forClass(WarningAttachment.class);
+        verify(warningAttachmentMapper, times(2)).insert(captor.capture());
+        List<WarningAttachment> attachments = captor.getAllValues();
+        assertEquals("2026/10/26/early-warning/png/a.png", attachments.get(0).getAttachmentUrl());
+        assertEquals("PROCESS", attachments.get(0).getAttachmentStage());
+        assertEquals("2026/10/26/early-warning/jpg/b.jpg", attachments.get(1).getAttachmentUrl());
+    }
+}

+ 25 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/common/utils/file/MinioUrlUtilsTest.java

@@ -0,0 +1,25 @@
+package com.zksy.common.utils.file;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class MinioUrlUtilsTest {
+
+    @Test
+    void ensuresLeadingSlashAndTrimsMultiValues() {
+        assertEquals("/2026/a.jpg,/2026/b.png", MinioUrlUtils.toRelative("2026/a.jpg, /2026/b.png "));
+    }
+
+    @Test
+    void keepsAlreadyRelativePaths() {
+        assertEquals("/2026/a.jpg", MinioUrlUtils.toRelative("/2026/a.jpg"));
+    }
+
+    @Test
+    void returnsInputWhenBlank() {
+        assertNull(MinioUrlUtils.toRelative(null));
+        assertEquals("", MinioUrlUtils.toRelative(""));
+    }
+}

+ 0 - 31
pipe-network-service/zksy-system/src/test/java/com/zksy/dispatch/support/DispatchAttachUrlSupportTest.java

@@ -1,31 +0,0 @@
-package com.zksy.dispatch.support;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-class DispatchAttachUrlSupportTest {
-    private final DispatchAttachUrlSupport support = new DispatchAttachUrlSupport();
-
-    @BeforeEach
-    void setUp() {
-        ReflectionTestUtils.setField(support, "endpoint", "https://minio.example.com/");
-        ReflectionTestUtils.setField(support, "bucket", "dispatch");
-    }
-
-    @Test
-    void convertsEveryCommaSeparatedAttachmentToAccessibleUrl() {
-        assertEquals(
-                "https://minio.example.com/dispatch/2026/a.jpg,https://minio.example.com/dispatch/2026/b.png",
-                support.toAccessibleUrl("/2026/a.jpg, /2026/b.png"));
-    }
-
-    @Test
-    void preservesExternalUrlsWhileConvertingRelativeAttachments() {
-        assertEquals(
-                "/2026/a.jpg,https://cdn.example.com/b.png",
-                support.toRelative("https://minio.example.com/dispatch/2026/a.jpg,https://cdn.example.com/b.png"));
-    }
-}

+ 1 - 3
pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java

@@ -381,11 +381,9 @@ class WorkOrderServiceImplTest {
 
     @Test
     void attachmentPathsAreRelativeAndIdempotent() {
-        ReflectionTestUtils.setField(service, "minioEndpoint", "https://files.example.test/");
-        ReflectionTestUtils.setField(service, "minioBucket", "attachments");
         String path = "/2026/10/09/workOrder/jpg/4649512327058833952.jpg";
         String expected = path + ",/2026/10/09/upload/jpg/old.jpg";
-        assertEquals(expected, service.normalizeAttachmentPaths(" https://files.example.test/attachments" + path
+        assertEquals(expected, service.normalizeAttachmentPaths(" " + path
                 + ",,2026/10/09/upload/jpg/old.jpg, "));
         assertEquals(expected, service.normalizeAttachmentPaths(expected));
         assertNull(service.normalizeAttachmentPaths(null));