From 3dff283259b0073f5153d88ee50f53cf240342f7 Mon Sep 17 00:00:00 2001
From: zhouxiaofeng <2946471396@qq.com>
Date: Thu, 9 Jul 2026 14:08:26 +0800
Subject: [PATCH] =?UTF-8?q?1.=20=E5=8F=98=E9=87=8F=E5=8D=A0=E4=BD=8D?=
=?UTF-8?q?=E7=AC=A6=EF=BC=9A{=E4=B8=AD=E6=96=87=E5=8F=98=E9=87=8F}=202.?=
=?UTF-8?q?=20=E5=8F=98=E9=87=8F=E5=8D=A0=E4=BD=8D=E7=AC=A6=E9=A2=84?=
=?UTF-8?q?=E8=AE=BE=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=8F=AF=E4=BB=A5=E6=98=AF?=
=?UTF-8?q?=EF=BC=9A=E8=BE=93=E5=85=A5=E6=A1=86=E3=80=81=E4=B8=8B=E6=8B=89?=
=?UTF-8?q?=E3=80=81=E5=AF=8C=E6=96=87=E6=9C=AC=EF=BC=88=E5=85=81=E8=AE=B8?=
=?UTF-8?q?=E6=8F=92=E5=85=A5=E8=A1=A8=E6=A0=BC=E4=BD=9C=E4=B8=BA=E5=90=88?=
=?UTF-8?q?=E5=90=8C=E6=AD=A3=E6=9C=AC=E7=9A=84=E9=99=84=E4=BB=B6=E5=86=85?=
=?UTF-8?q?=E5=AE=B9=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pom.xml | 5 +
.../zgwl/config/MinioConfiguration.java | 21 ++
.../nbport/zgwl/config/MinioProperties.java | 25 ++
.../zgwl/controller/AdminDictController.java | 62 +++-
.../dto/TemplateVariableMappingDto.java | 20 +-
.../handler/ContractTemplateDataHandler.java | 54 +++-
.../utils/ManifestManageConstants.java | 9 +
.../nbport/zgwl/service/LocalFileService.java | 265 +++++++++++-------
.../java/com/nbport/zgwl/utils/MinioUtil.java | 225 +++++++++++++++
src/main/resources/application.yml | 11 +-
10 files changed, 580 insertions(+), 117 deletions(-)
create mode 100644 src/main/java/com/nbport/zgwl/config/MinioConfiguration.java
create mode 100644 src/main/java/com/nbport/zgwl/config/MinioProperties.java
create mode 100644 src/main/java/com/nbport/zgwl/utils/MinioUtil.java
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