diff --git a/pom.xml b/pom.xml index ba835b9..2f0e903 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,11 @@ org.springframework.boot spring-boot-starter-web + + io.minio + minio + 8.5.12 + org.springframework.boot spring-boot-starter-validation diff --git a/src/main/java/com/nbport/zgwl/config/MinioConfiguration.java b/src/main/java/com/nbport/zgwl/config/MinioConfiguration.java new file mode 100644 index 0000000..3c2ff97 --- /dev/null +++ b/src/main/java/com/nbport/zgwl/config/MinioConfiguration.java @@ -0,0 +1,21 @@ +package com.nbport.zgwl.config; + +import io.minio.MinioClient; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class MinioConfiguration { + + private final MinioProperties minioProperties; + + @Bean + public MinioClient minioClient() { + return MinioClient.builder() + .endpoint(minioProperties.getEndpoint()) + .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey()) + .build(); + } +} diff --git a/src/main/java/com/nbport/zgwl/config/MinioProperties.java b/src/main/java/com/nbport/zgwl/config/MinioProperties.java new file mode 100644 index 0000000..e239bea --- /dev/null +++ b/src/main/java/com/nbport/zgwl/config/MinioProperties.java @@ -0,0 +1,25 @@ +package com.nbport.zgwl.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "minio") +public class MinioProperties { + + private String endpoint; + + private String accessKey; + + private String secretKey; + + private String bucketName = "zgwl-contract"; + + private String basePath = ""; + + private boolean createBucketIfMissing = true; + + private boolean publicReadWrite = true; +} diff --git a/src/main/java/com/nbport/zgwl/controller/AdminDictController.java b/src/main/java/com/nbport/zgwl/controller/AdminDictController.java index ddddbed..7341a08 100644 --- a/src/main/java/com/nbport/zgwl/controller/AdminDictController.java +++ b/src/main/java/com/nbport/zgwl/controller/AdminDictController.java @@ -16,14 +16,23 @@ import java.util.Map; public class AdminDictController { private static final String TEMPLATE_FIELD_DICT_TYPE = "CONTRACT_TEMPLATE_FIELDS"; + private static final String TEMPLATE_DICT_TYPES = "TEMPLATE_DICT_TYPES"; @PostMapping("/getDictDataByType") public R> getDictDataByType(@RequestBody(required = false) Map body) { String dictType = resolveDictType(body); - if (TEMPLATE_FIELD_DICT_TYPE.equals(dictType)) { - return R.success(buildTemplateFieldItems()); + if (dictType == null || dictType.isBlank()) { + return R.success(List.of()); } - return R.success(List.of()); + return R.success(switch (dictType) { + case TEMPLATE_FIELD_DICT_TYPE -> buildTemplateFieldItems(); + case TEMPLATE_DICT_TYPES -> buildTemplateDictTypeItems(); + case "orgBusType" -> buildOrgBusTypeItems(); + case "contractPayMethod" -> buildContractPayMethodItems(); + case "sealScopeType" -> buildSealScopeTypeItems(); + case "partnerLevel" -> buildPartnerLevelItems(); + default -> List.of(); + }); } private String resolveDictType(Map body) { @@ -39,6 +48,15 @@ public class AdminDictController { return dictType == null ? null : String.valueOf(dictType).trim(); } + private List buildTemplateDictTypeItems() { + return List.of( + new DictDataItem("业务类型(orgBusType)", "orgBusType", "模板下拉框可选的字典类型"), + new DictDataItem("付款方式(contractPayMethod)", "contractPayMethod", "模板下拉框可选的字典类型"), + new DictDataItem("签章范围(sealScopeType)", "sealScopeType", "模板下拉框可选的字典类型"), + new DictDataItem("相对方级别(partnerLevel)", "partnerLevel", "模板下拉框可选的字典类型") + ); + } + private List buildTemplateFieldItems() { return List.of( new DictDataItem("合同名称", "contractName", "合同正文中的合同名称"), @@ -58,9 +76,43 @@ public class AdminDictController { new DictDataItem("附件情况", "attachmentSummary", "模板变量值,由用户自行维护"), new DictDataItem("模板名称", "templateLabel", "模板变量值,由用户自行维护"), new DictDataItem("签署日期", "signDate", "模板变量值,由用户自行维护"), - new DictDataItem("A的TAx", "partyATaxNo", "模板变量值,由用户自行维护"), - new DictDataItem("B的TAx", "partyBTaxNo", "模板变量值,由用户自行维护") + new DictDataItem("甲方税号", "partyATaxNo", "模板变量值,由用户自行维护"), + new DictDataItem("乙方税号", "partyBTaxNo", "模板变量值,由用户自行维护"), + new DictDataItem("附件一内容", "attachmentContent1", "建议配成富文本,且占位符单独成段"), + new DictDataItem("附件二内容", "attachmentContent2", "建议配成富文本,且占位符单独成段"), + new DictDataItem("业务类型", "orgBusType", "建议配成下拉,字典类型可填 orgBusType") + ); + } + private List buildOrgBusTypeItems() { + return List.of( + new DictDataItem("海铁联运", "SEA_RAIL", "测试字典:海铁联运"), + new DictDataItem("仓储服务", "WAREHOUSE", "测试字典:仓储服务"), + new DictDataItem("港内运输", "PORT_TRANSPORT", "测试字典:港内运输") + ); + } + + private List buildContractPayMethodItems() { + return List.of( + new DictDataItem("银行转账", "BANK_TRANSFER", "测试字典:付款方式"), + new DictDataItem("电汇", "WIRE_TRANSFER", "测试字典:付款方式"), + new DictDataItem("承兑汇票", "ACCEPTANCE_BILL", "测试字典:付款方式") + ); + } + + private List buildSealScopeTypeItems() { + return List.of( + new DictDataItem("仅我方盖章", "OUR_ONLY", "测试字典:签章范围"), + new DictDataItem("双方盖章", "BOTH_PARTIES", "测试字典:签章范围"), + new DictDataItem("多方盖章", "MULTI_PARTY", "测试字典:签章范围") + ); + } + + private List buildPartnerLevelItems() { + return List.of( + new DictDataItem("核心客户", "CORE", "测试字典:相对方级别"), + new DictDataItem("普通客户", "NORMAL", "测试字典:相对方级别"), + new DictDataItem("外部合作方", "OUTSIDE", "测试字典:相对方级别") ); } diff --git a/src/main/java/com/nbport/zgwl/manifest/dto/TemplateVariableMappingDto.java b/src/main/java/com/nbport/zgwl/manifest/dto/TemplateVariableMappingDto.java index e73a98c..0ca4c33 100644 --- a/src/main/java/com/nbport/zgwl/manifest/dto/TemplateVariableMappingDto.java +++ b/src/main/java/com/nbport/zgwl/manifest/dto/TemplateVariableMappingDto.java @@ -11,7 +11,7 @@ import java.io.Serializable; * 模板变量映射 * * variableKey:Word 模板里的变量名 - * bindField:业务字段名 + * bindField:业务字段编码 */ @Data @NoArgsConstructor @@ -26,4 +26,22 @@ public class TemplateVariableMappingDto implements Serializable { private String variableKey; private String bindField; + + /** 字段展示名,合同发起时作为表单标题展示 */ + private String fieldLabel; + + /** 变量录入类型:input / select / rich_text */ + private String fieldType; + + /** 是否必填 */ + private Boolean required; + + /** 表单提示语 */ + private String placeholder; + + /** 下拉框字典类型,如 orgBusType */ + private String dictType; + + /** 备注 */ + private String remark; } diff --git a/src/main/java/com/nbport/zgwl/manifest/handler/ContractTemplateDataHandler.java b/src/main/java/com/nbport/zgwl/manifest/handler/ContractTemplateDataHandler.java index 741651a..6034f5a 100644 --- a/src/main/java/com/nbport/zgwl/manifest/handler/ContractTemplateDataHandler.java +++ b/src/main/java/com/nbport/zgwl/manifest/handler/ContractTemplateDataHandler.java @@ -1,16 +1,20 @@ package com.nbport.zgwl.manifest.handler; import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson2.TypeReference; import com.nbport.zgwl.exception.ServiceException; import com.nbport.zgwl.manifest.dto.ContractTemplateDto; import com.nbport.zgwl.manifest.dto.TemplateOrgOptionDto; import com.nbport.zgwl.manifest.dto.TemplateVariableMappingDto; import com.nbport.zgwl.manifest.utils.ManifestJsonUtils; +import com.nbport.zgwl.manifest.utils.ManifestManageConstants; import org.springframework.stereotype.Component; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; /** @@ -55,11 +59,7 @@ public class ContractTemplateDataHandler { List mappings = template.getVariableMappings() == null ? new ArrayList<>() : template.getVariableMappings(); mappings = mappings.stream() .filter(Objects::nonNull) - .map(item -> new TemplateVariableMappingDto( - StrUtil.blankToDefault(item.getId(), "var_" + System.nanoTime()), - StrUtil.trim(item.getVariableKey()), - StrUtil.trim(item.getBindField()) - )) + .map(this::normalizeMapping) .filter(item -> StrUtil.isNotBlank(item.getVariableKey()) || StrUtil.isNotBlank(item.getBindField())) .collect(Collectors.toList()); template.setVariableMappings(mappings); @@ -97,13 +97,28 @@ public class ContractTemplateDataHandler { if (template.getVariableMappings() == null || template.getVariableMappings().isEmpty()) { throw new ServiceException("请至少维护一个变量映射"); } + + Set variableKeys = new HashSet<>(); for (TemplateVariableMappingDto mapping : template.getVariableMappings()) { if (StrUtil.isBlank(mapping.getVariableKey())) { throw new ServiceException("模板变量名称不能为空"); } + if (!variableKeys.add(mapping.getVariableKey())) { + throw new ServiceException("模板变量【" + mapping.getVariableKey() + "】重复,请检查后再保存"); + } if (StrUtil.isBlank(mapping.getBindField())) { throw new ServiceException("模板变量【" + mapping.getVariableKey() + "】尚未绑定业务字段"); } + if (StrUtil.isBlank(mapping.getFieldLabel())) { + throw new ServiceException("模板变量【" + mapping.getVariableKey() + "】展示名称不能为空"); + } + if (StrUtil.isBlank(mapping.getFieldType())) { + throw new ServiceException("模板变量【" + mapping.getVariableKey() + "】录入类型不能为空"); + } + if (ManifestManageConstants.FIELD_TYPE_SELECT.equals(mapping.getFieldType()) + && StrUtil.isBlank(mapping.getDictType())) { + throw new ServiceException("下拉变量【" + mapping.getVariableKey() + "】必须配置字典类型"); + } } } @@ -117,9 +132,9 @@ public class ContractTemplateDataHandler { template.setVariableMappings( ManifestJsonUtils.parseList( template.getVariableMappingsJson(), - new com.alibaba.fastjson2.TypeReference>() { + new TypeReference>() { } - ) + ).stream().map(this::normalizeMapping).collect(Collectors.toList()) ); template.setVariables( template.getVariableMappings().stream() @@ -159,4 +174,29 @@ public class ContractTemplateDataHandler { template.setPublishOrgNames(names); template.setPublishScopeText(String.join("、", names)); } + + private TemplateVariableMappingDto normalizeMapping(TemplateVariableMappingDto source) { + TemplateVariableMappingDto mapping = new TemplateVariableMappingDto(); + mapping.setId(StrUtil.blankToDefault(source.getId(), "var_" + System.nanoTime())); + mapping.setVariableKey(StrUtil.trim(source.getVariableKey())); + mapping.setBindField(StrUtil.blankToDefault(StrUtil.trim(source.getBindField()), mapping.getVariableKey())); + mapping.setFieldLabel(StrUtil.blankToDefault(StrUtil.trim(source.getFieldLabel()), mapping.getVariableKey())); + mapping.setFieldType(normalizeFieldType(source.getFieldType())); + mapping.setRequired(source.getRequired() == null ? Boolean.TRUE : source.getRequired()); + mapping.setPlaceholder(StrUtil.trim(source.getPlaceholder())); + mapping.setDictType(StrUtil.trim(source.getDictType())); + mapping.setRemark(StrUtil.trim(source.getRemark())); + return mapping; + } + + private String normalizeFieldType(String fieldType) { + String normalized = StrUtil.blankToDefault(StrUtil.trim(fieldType), ManifestManageConstants.FIELD_TYPE_INPUT); + if (ManifestManageConstants.FIELD_TYPE_SELECT.equals(normalized)) { + return ManifestManageConstants.FIELD_TYPE_SELECT; + } + if (ManifestManageConstants.FIELD_TYPE_RICH_TEXT.equals(normalized)) { + return ManifestManageConstants.FIELD_TYPE_RICH_TEXT; + } + return ManifestManageConstants.FIELD_TYPE_INPUT; + } } diff --git a/src/main/java/com/nbport/zgwl/manifest/utils/ManifestManageConstants.java b/src/main/java/com/nbport/zgwl/manifest/utils/ManifestManageConstants.java index fff8b6e..b862e0e 100644 --- a/src/main/java/com/nbport/zgwl/manifest/utils/ManifestManageConstants.java +++ b/src/main/java/com/nbport/zgwl/manifest/utils/ManifestManageConstants.java @@ -16,6 +16,15 @@ public final class ManifestManageConstants { /** 默认操作人(后续内网联调时替换为真实用户) */ public static final String DEFAULT_OPERATOR = "local_test"; + /** 普通输入框 */ + public static final String FIELD_TYPE_INPUT = "input"; + + /** 下拉框(字典数据源) */ + public static final String FIELD_TYPE_SELECT = "select"; + + /** 富文本 */ + public static final String FIELD_TYPE_RICH_TEXT = "rich_text"; + /** 未删除标记 */ public static final String DEL_FLAG_NORMAL = "0"; diff --git a/src/main/java/com/nbport/zgwl/service/LocalFileService.java b/src/main/java/com/nbport/zgwl/service/LocalFileService.java index 27a1318..1afa834 100644 --- a/src/main/java/com/nbport/zgwl/service/LocalFileService.java +++ b/src/main/java/com/nbport/zgwl/service/LocalFileService.java @@ -1,13 +1,11 @@ package com.nbport.zgwl.service; -import cn.hutool.core.date.DateUtil; -import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; import com.nbport.zgwl.exception.ServiceErrorCodeRange; import com.nbport.zgwl.exception.ServiceException; -import com.nbport.zgwl.utils.FileStorageUtils; +import com.nbport.zgwl.utils.MinioUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; @@ -16,78 +14,57 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Objects; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; @Service @Slf4j @RequiredArgsConstructor public class LocalFileService { - @Value("${file.local.path:${user.dir}/upload-files}") - private String localPath; + private final MinioUtil minioUtil; public String upload(MultipartFile file) { if (file == null || file.isEmpty()) { throw new ServiceException("上传文件不能为空"); } - try { - Path rootPath = Paths.get(localPath).toAbsolutePath().normalize(); - Files.createDirectories(rootPath); - - String dateFolder = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - String suffix = FileStorageUtils.getFileSuffix(Objects.requireNonNullElse(file.getOriginalFilename(), "")); - String fileName = IdUtil.simpleUUID() + suffix; - String relativePath = dateFolder + "/" + fileName; - Path targetPath = FileStorageUtils.resolveFilePath(rootPath, relativePath); - Files.createDirectories(targetPath.getParent()); - file.transferTo(targetPath); - log.info("文件上传成功,targetPath={}", targetPath); - return relativePath; - } catch (IOException e) { - log.error("文件上传失败", e); - throw new ServiceException("文件上传失败"); - } + return minioUtil.upload(file, null, true); } public ResponseEntity download(String fileName) { - try { - Path rootPath = Paths.get(localPath).toAbsolutePath().normalize(); - Path filePath = FileStorageUtils.resolveFilePath(rootPath, fileName); - if (!Files.exists(filePath) || Files.isDirectory(filePath)) { - throw new ServiceException(ServiceErrorCodeRange.FILE_NOT_FOUND, "文件不存在"); - } - byte[] bytes = Files.readAllBytes(filePath); - String contentType = Files.probeContentType(filePath); - MediaType mediaType = contentType == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(contentType); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(mediaType); - headers.setContentLength(bytes.length); - headers.setContentDisposition(ContentDisposition.inline().filename(filePath.getFileName().toString()).build()); - return ResponseEntity.ok() - .headers(headers) - .body(new ByteArrayResource(bytes)); - } catch (IOException e) { - log.error("文件下载失败", e); - throw new ServiceException("文件下载失败"); + if (StrUtil.isBlank(fileName)) { + throw new ServiceException(ServiceErrorCodeRange.FILE_NOT_FOUND, "文件不存在"); } + byte[] bytes = minioUtil.downloadAsBytes(fileName); + String contentType = minioUtil.getContentType(fileName); + MediaType mediaType = StrUtil.isBlank(contentType) + ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(contentType); + String downloadFileName = minioUtil.extractOriginalFileName(fileName); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(mediaType); + headers.setContentLength(bytes.length); + headers.setContentDisposition(ContentDisposition.inline().filename(downloadFileName, StandardCharsets.UTF_8).build()); + return ResponseEntity.ok() + .headers(headers) + .body(new ByteArrayResource(bytes)); } - /** - * 从外部URL下载文件并保存到本地,返回本地相对路径(如 20260603/uuid.pdf)。 - * 文件名优先从 Content-Disposition 或 URL 路径提取,否则使用兜底名。 - */ public String downloadFromUrl(String fileUrl) { return downloadFromUrl(fileUrl, null); } @@ -96,13 +73,11 @@ public class LocalFileService { if (fileUrl == null || fileUrl.isBlank()) { throw new ServiceException("下载URL不能为空"); } - // 先尝试不带 token(签章平台下载 URL 自带 JWT 认证) try { return doDownload(fileUrl, null); - } catch (ServiceException e) { - // 如果 401/403,再尝试带 token - if (accessToken != null && !accessToken.isBlank() && e.getMessage().contains("40")) { - log.info("无token下载失败,尝试带token重试: {}", fileUrl); + } catch (RuntimeException e) { + if (accessToken != null && !accessToken.isBlank() && e.getMessage() != null && e.getMessage().contains("40")) { + log.info("无token下载失败,尝试带token重试:{}", fileUrl); return doDownload(fileUrl, accessToken); } throw e; @@ -124,37 +99,43 @@ public class LocalFileService { int responseCode = conn.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { - // 读取错误响应体用于排查 String errorBody = ""; try (InputStream es = conn.getErrorStream()) { if (es != null) { errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8); } - } catch (Exception ignored) {} - log.error("下载签章文件失败, URL={}, code={}, body={}", fileUrl, responseCode, errorBody); - throw new ServiceException("下载签章文件失败,HTTP状态码: " + responseCode + ", 详情: " + errorBody); + } catch (Exception ignored) { + } + log.error("下载签章文件失败,URL={},code={},body={}", fileUrl, responseCode, errorBody); + throw new ServiceException("下载签章文件失败,HTTP状态码:" + responseCode + ",详情:" + errorBody); } - String fileName = extractFileName(fileUrl, conn); - String suffix = FileStorageUtils.getFileSuffix(fileName); - - Path rootPath = Paths.get(localPath).toAbsolutePath().normalize(); - Files.createDirectories(rootPath); - String dateFolder = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - String storedName = IdUtil.simpleUUID() + suffix; - String relativePath = dateFolder + "/" + storedName; - Path targetPath = FileStorageUtils.resolveFilePath(rootPath, relativePath); - Files.createDirectories(targetPath.getParent()); - - try (InputStream is = conn.getInputStream()) { - Files.copy(is, targetPath); + byte[] fileBytes; + try (InputStream is = conn.getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int len; + while ((len = is.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + fileBytes = baos.toByteArray(); } - log.info("从URL下载文件成功: {} -> {}", fileUrl, targetPath); - return relativePath; + String originalFileName = extractFileName(fileUrl, conn); + String contentType = conn.getContentType(); + if (contentType == null || contentType.isBlank()) { + contentType = resolveContentType(originalFileName); + } + + MultipartFile multipartFile = new ByteArrayMultipartFile(fileBytes, originalFileName, contentType); + String objectName = minioUtil.upload(multipartFile, null, true); + if (objectName == null) { + throw new ServiceException("上传文件到 MinIO 失败"); + } + log.info("签章文件已下载并上传至 MinIO:{}->{}", fileUrl, objectName); + return objectName; } catch (IOException e) { - log.error("从URL下载文件失败: {}", fileUrl, e); - throw new ServiceException("下载签章文件失败: " + e.getMessage()); + log.error("从URL下载文件失败:{}", fileUrl, e); + throw new ServiceException("下载签章文件失败:" + e.getMessage()); } finally { if (conn != null) { conn.disconnect(); @@ -165,22 +146,29 @@ public class LocalFileService { private HttpURLConnection openConnection(URL url) throws IOException { try { if ("https".equalsIgnoreCase(url.getProtocol())) { - javax.net.ssl.HttpsURLConnection httpsConn = (javax.net.ssl.HttpsURLConnection) url.openConnection(); - javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS"); - sslContext.init(null, new javax.net.ssl.TrustManager[]{ - new javax.net.ssl.X509TrustManager() { - public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {} - public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {} - public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; } + HttpsURLConnection httpsConn = (HttpsURLConnection) url.openConnection(); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { } - }, new java.security.SecureRandom()); + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }}, new SecureRandom()); httpsConn.setSSLSocketFactory(sslContext.getSocketFactory()); httpsConn.setHostnameVerifier((hostname, session) -> true); return httpsConn; } return (HttpURLConnection) url.openConnection(); } catch (Exception e) { - log.warn("SSL初始化失败,使用默认连接: {}", e.getMessage()); + log.warn("SSL初始化失败,使用默认连接:{}", e.getMessage()); return (HttpURLConnection) url.openConnection(); } } @@ -191,33 +179,108 @@ public class LocalFileService { for (String part : disposition.split(";")) { part = part.trim(); if (part.toLowerCase().startsWith("filename=")) { - String name = part.substring(part.indexOf('=') + 1).replaceAll("^[\"\']|[\"\']$", "").trim(); - if (!name.isBlank()) return name; + String name = part.substring(part.indexOf('=') + 1).replaceAll("^[\"']|[\"']$", "").trim(); + if (!name.isBlank()) { + return name; + } } } } String path = urlStr.contains("?") ? urlStr.substring(0, urlStr.indexOf('?')) : urlStr; String name = path.substring(path.lastIndexOf('/') + 1); - String suffix = FileStorageUtils.getFileSuffix(name); - // 后缀过长(>10)说明是JWT/乱码而非真实文件名, 默认pdf + String suffix = name.contains(".") ? name.substring(name.lastIndexOf('.')) : ""; if (!name.isBlank() && suffix.length() > 0 && suffix.length() <= 10) { return URLDecoder.decode(name, StandardCharsets.UTF_8); } return "signed_contract.pdf"; } - public byte[] readFileBytes(String fileName) { + private String resolveContentType(String fileName) { + String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase() : ""; + return switch (suffix) { + case "pdf" -> "application/pdf"; + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "doc" -> "application/msword"; + case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "xls" -> "application/vnd.ms-excel"; + case "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + default -> "application/octet-stream"; + }; + } + + public byte[] readFileBytes(String objectName) { + if (StrUtil.isBlank(objectName)) { + throw new ServiceException("文件路径不能为空"); + } try { - Path rootPath = Paths.get(localPath).toAbsolutePath().normalize(); - Path filePath = FileStorageUtils.resolveFilePath(rootPath, fileName); - if (!Files.exists(filePath) || Files.isDirectory(filePath)) { - throw new ServiceException(ServiceErrorCodeRange.FILE_NOT_FOUND, "文件不存在"); + byte[] fileBytes = minioUtil.downloadAsBytes(objectName); + if (fileBytes == null || fileBytes.length == 0) { + throw new ServiceException("从MinIO读取文件失败,文件为空"); } - return Files.readAllBytes(filePath); - } catch (IOException e) { - log.error("文件读取失败", e); - throw new ServiceException("文件读取失败"); + log.info("从MinIO读取文件成功:{},大小:{}bytes", objectName, fileBytes.length); + return fileBytes; + } catch (ServiceException e) { + log.error("从MinIO读取文件失败:{}", objectName, e); + throw e; + } catch (Exception e) { + log.error("从MinIO读取文件失败:{}", objectName, e); + throw new ServiceException("从MinIO读取文件失败:" + e.getMessage()); } } + private static class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] content; + private final String originalFilename; + private final String contentType; + + private ByteArrayMultipartFile(byte[] content, String originalFilename, String contentType) { + this.content = content; + this.originalFilename = originalFilename; + this.contentType = contentType; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException { + try (FileOutputStream fos = new FileOutputStream(dest)) { + fos.write(content); + } + } + } } diff --git a/src/main/java/com/nbport/zgwl/utils/MinioUtil.java b/src/main/java/com/nbport/zgwl/utils/MinioUtil.java new file mode 100644 index 0000000..fd913fa --- /dev/null +++ b/src/main/java/com/nbport/zgwl/utils/MinioUtil.java @@ -0,0 +1,225 @@ +package com.nbport.zgwl.utils; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.nbport.zgwl.config.MinioProperties; +import com.nbport.zgwl.exception.ServiceErrorCodeRange; +import com.nbport.zgwl.exception.ServiceException; +import io.minio.BucketExistsArgs; +import io.minio.GetObjectArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.SetBucketPolicyArgs; +import io.minio.StatObjectArgs; +import io.minio.StatObjectResponse; +import io.minio.errors.ErrorResponseException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Headers; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Slf4j +@RequiredArgsConstructor +public class MinioUtil { + + private static final DateTimeFormatter DATE_FOLDER_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final Pattern UUID_PREFIX_PATTERN = Pattern.compile("^[0-9a-fA-F]{32}_(.+)$"); + private static final Pattern INVALID_FILE_NAME_PATTERN = Pattern.compile("[\\\\/:*?\"<>|]+"); + + private final MinioClient minioClient; + private final MinioProperties minioProperties; + private final AtomicBoolean bucketReady = new AtomicBoolean(false); + + public String upload(MultipartFile file, String folder, boolean keepOriginalFilename) { + if (file == null || file.isEmpty()) { + throw new ServiceException("上传文件不能为空"); + } + ensureBucketReady(); + String originalFilename = sanitizeFileName(Objects.requireNonNullElse(file.getOriginalFilename(), "")); + String objectName = buildObjectName(originalFilename, folder, keepOriginalFilename); + String contentType = StrUtil.blankToDefault(file.getContentType(), MediaType.APPLICATION_OCTET_STREAM_VALUE); + try (InputStream inputStream = file.getInputStream()) { + minioClient.putObject(PutObjectArgs.builder() + .bucket(minioProperties.getBucketName()) + .object(objectName) + .stream(inputStream, file.getSize(), -1) + .contentType(contentType) + .build()); + log.info("文件上传至MinIO成功: {}", objectName); + return objectName; + } catch (Exception e) { + log.error("文件上传至MinIO失败: {}", objectName, e); + throw new ServiceException("上传文件到 MinIO 失败: " + e.getMessage()); + } + } + + public String upload(MultipartFile file) { + return upload(file, null, true); + } + + public byte[] downloadAsBytes(String objectName) { + ensureBucketReady(); + String normalizedObjectName = normalizeObjectName(objectName); + try (InputStream inputStream = minioClient.getObject(GetObjectArgs.builder() + .bucket(minioProperties.getBucketName()) + .object(normalizedObjectName) + .build())) { + return inputStream.readAllBytes(); + } catch (Exception e) { + throw handleMinioException("读取MinIO文件失败", normalizedObjectName, e); + } + } + + public String getContentType(String objectName) { + StatObjectResponse statObjectResponse = statObject(objectName); + Headers headers = statObjectResponse.headers(); + String contentType = headers == null ? null : headers.get("Content-Type"); + if (StrUtil.isNotBlank(contentType)) { + return contentType; + } + return MediaTypeFactory.getMediaType(extractOriginalFileName(objectName)) + .map(MediaType::toString) + .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE); + } + + public String extractOriginalFileName(String objectName) { + String normalizedObjectName = normalizeObjectName(objectName); + int lastSlashIndex = normalizedObjectName.lastIndexOf('/'); + String fileName = lastSlashIndex >= 0 ? normalizedObjectName.substring(lastSlashIndex + 1) : normalizedObjectName; + Matcher matcher = UUID_PREFIX_PATTERN.matcher(fileName); + if (matcher.matches()) { + return matcher.group(1); + } + return fileName; + } + + private StatObjectResponse statObject(String objectName) { + ensureBucketReady(); + String normalizedObjectName = normalizeObjectName(objectName); + try { + return minioClient.statObject(StatObjectArgs.builder() + .bucket(minioProperties.getBucketName()) + .object(normalizedObjectName) + .build()); + } catch (Exception e) { + throw handleMinioException("获取MinIO文件信息失败", normalizedObjectName, e); + } + } + + private void ensureBucketReady() { + if (bucketReady.get()) { + return; + } + synchronized (bucketReady) { + if (bucketReady.get()) { + return; + } + try { + boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder() + .bucket(minioProperties.getBucketName()) + .build()); + if (!bucketExists) { + if (!minioProperties.isCreateBucketIfMissing()) { + throw new ServiceException("MinIO桶不存在: " + minioProperties.getBucketName()); + } + minioClient.makeBucket(MakeBucketArgs.builder() + .bucket(minioProperties.getBucketName()) + .build()); + log.info("已创建MinIO桶: {}", minioProperties.getBucketName()); + } + if (minioProperties.isPublicReadWrite()) { + minioClient.setBucketPolicy(SetBucketPolicyArgs.builder() + .bucket(minioProperties.getBucketName()) + .config(buildPublicReadWritePolicy()) + .build()); + log.info("已设置MinIO桶匿名读写策略: {}", minioProperties.getBucketName()); + } + bucketReady.set(true); + } catch (Exception e) { + log.error("初始化MinIO桶失败", e); + throw new ServiceException("初始化MinIO桶失败: " + e.getMessage()); + } + } + } + + private String buildPublicReadWritePolicy() { + String bucketName = minioProperties.getBucketName(); + return "{" + + "\"Version\":\"2012-10-17\"," + + "\"Statement\":[{" + + "\"Effect\":\"Allow\"," + + "\"Principal\":{\"AWS\":[\"*\"]}," + + "\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucket\"]," + + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]" + + "},{" + + "\"Effect\":\"Allow\"," + + "\"Principal\":{\"AWS\":[\"*\"]}," + + "\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:DeleteObject\"]," + + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]" + + "}]}"; + } + + private String buildObjectName(String originalFilename, String folder, boolean keepOriginalFilename) { + String folderPath = StrUtil.blankToDefault(StrUtil.trim(folder), DATE_FOLDER_FORMATTER.format(LocalDate.now())); + folderPath = normalizeObjectName(folderPath); + + String fileName; + if (keepOriginalFilename && StrUtil.isNotBlank(originalFilename)) { + fileName = IdUtil.simpleUUID() + "_" + originalFilename; + } else { + String suffix = FileStorageUtils.getFileSuffix(originalFilename); + fileName = IdUtil.simpleUUID() + suffix; + } + + String basePath = normalizeObjectName(minioProperties.getBasePath()); + if (StrUtil.isBlank(basePath)) { + return folderPath + "/" + fileName; + } + return basePath + "/" + folderPath + "/" + fileName; + } + + private String sanitizeFileName(String originalFilename) { + String fileName = StrUtil.blankToDefault(originalFilename, "file").replace('\\', '/'); + int lastSlashIndex = fileName.lastIndexOf('/'); + if (lastSlashIndex >= 0) { + fileName = fileName.substring(lastSlashIndex + 1); + } + fileName = INVALID_FILE_NAME_PATTERN.matcher(fileName).replaceAll("_").trim(); + return StrUtil.blankToDefault(fileName, "file"); + } + + private String normalizeObjectName(String objectName) { + String normalizedObjectName = StrUtil.blankToDefault(objectName, "").trim().replace('\\', '/'); + while (normalizedObjectName.startsWith("/")) { + normalizedObjectName = normalizedObjectName.substring(1); + } + while (normalizedObjectName.contains("//")) { + normalizedObjectName = normalizedObjectName.replace("//", "/"); + } + return normalizedObjectName; + } + + private ServiceException handleMinioException(String action, String objectName, Exception e) { + if (e instanceof ErrorResponseException errorResponseException) { + String code = errorResponseException.errorResponse().code(); + if ("NoSuchKey".equals(code) || "NoSuchBucket".equals(code)) { + return new ServiceException(ServiceErrorCodeRange.FILE_NOT_FOUND, "文件不存在"); + } + } + log.error("{}: {}", action, objectName, e); + return new ServiceException(action + ": " + e.getMessage()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7d720c8..4282762 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,9 +27,14 @@ springdoc: api-docs: path: /v3/api-docs -file: - local: - path: ${user.dir}/upload-files +minio: + endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:19000} + access-key: ${MINIO_ACCESS_KEY:minioadmin} + secret-key: ${MINIO_SECRET_KEY:minioadmin123} + bucket-name: ${MINIO_BUCKET_NAME:zgwl-contract} + base-path: ${MINIO_BASE_PATH:} + create-bucket-if-missing: ${MINIO_CREATE_BUCKET_IF_MISSING:true} + public-read-write: ${MINIO_PUBLIC_READ_WRITE:true} seal: appKey: A7C4FD32BE454B5