1. 变量占位符:{中文变量}

2. 变量占位符预设类型,可以是:输入框、下拉、富文本(允许插入表格作为合同正本的附件内容)
This commit is contained in:
2026-07-09 14:08:26 +08:00
parent f18a173d25
commit 3dff283259
10 changed files with 580 additions and 117 deletions
+5
View File
@@ -29,6 +29,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
@@ -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();
}
}
@@ -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;
}
@@ -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<List<DictDataItem>> getDictDataByType(@RequestBody(required = false) Map<String, Object> 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.<DictDataItem>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.<DictDataItem>of();
});
}
private String resolveDictType(Map<String, Object> body) {
@@ -39,6 +48,15 @@ public class AdminDictController {
return dictType == null ? null : String.valueOf(dictType).trim();
}
private List<DictDataItem> buildTemplateDictTypeItems() {
return List.of(
new DictDataItem("业务类型(orgBusType", "orgBusType", "模板下拉框可选的字典类型"),
new DictDataItem("付款方式(contractPayMethod", "contractPayMethod", "模板下拉框可选的字典类型"),
new DictDataItem("签章范围(sealScopeType", "sealScopeType", "模板下拉框可选的字典类型"),
new DictDataItem("相对方级别(partnerLevel", "partnerLevel", "模板下拉框可选的字典类型")
);
}
private List<DictDataItem> 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<DictDataItem> buildOrgBusTypeItems() {
return List.of(
new DictDataItem("海铁联运", "SEA_RAIL", "测试字典:海铁联运"),
new DictDataItem("仓储服务", "WAREHOUSE", "测试字典:仓储服务"),
new DictDataItem("港内运输", "PORT_TRANSPORT", "测试字典:港内运输")
);
}
private List<DictDataItem> buildContractPayMethodItems() {
return List.of(
new DictDataItem("银行转账", "BANK_TRANSFER", "测试字典:付款方式"),
new DictDataItem("电汇", "WIRE_TRANSFER", "测试字典:付款方式"),
new DictDataItem("承兑汇票", "ACCEPTANCE_BILL", "测试字典:付款方式")
);
}
private List<DictDataItem> buildSealScopeTypeItems() {
return List.of(
new DictDataItem("仅我方盖章", "OUR_ONLY", "测试字典:签章范围"),
new DictDataItem("双方盖章", "BOTH_PARTIES", "测试字典:签章范围"),
new DictDataItem("多方盖章", "MULTI_PARTY", "测试字典:签章范围")
);
}
private List<DictDataItem> buildPartnerLevelItems() {
return List.of(
new DictDataItem("核心客户", "CORE", "测试字典:相对方级别"),
new DictDataItem("普通客户", "NORMAL", "测试字典:相对方级别"),
new DictDataItem("外部合作方", "OUTSIDE", "测试字典:相对方级别")
);
}
@@ -11,7 +11,7 @@ import java.io.Serializable;
* 模板变量映射
*
* variableKeyWord 模板里的变量名
* 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;
}
@@ -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<TemplateVariableMappingDto> 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<String> 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<List<TemplateVariableMappingDto>>() {
new TypeReference<List<TemplateVariableMappingDto>>() {
}
)
).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;
}
}
@@ -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";
@@ -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<ByteArrayResource> download(String fileName) {
try {
Path rootPath = Paths.get(localPath).toAbsolutePath().normalize();
Path filePath = FileStorageUtils.resolveFilePath(rootPath, fileName);
if (!Files.exists(filePath) || Files.isDirectory(filePath)) {
if (StrUtil.isBlank(fileName)) {
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);
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(filePath.getFileName().toString()).build());
headers.setContentDisposition(ContentDisposition.inline().filename(downloadFileName, StandardCharsets.UTF_8).build());
return ResponseEntity.ok()
.headers(headers)
.body(new ByteArrayResource(bytes));
} catch (IOException e) {
log.error("文件下载失败", e);
throw new ServiceException("文件下载失败");
}
}
/**
* 从外部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) {
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, "文件不存在");
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";
};
}
return Files.readAllBytes(filePath);
} catch (IOException e) {
log.error("文件读取失败", e);
throw new ServiceException("文件读取失败");
public byte[] readFileBytes(String objectName) {
if (StrUtil.isBlank(objectName)) {
throw new ServiceException("文件路径不能为空");
}
try {
byte[] fileBytes = minioUtil.downloadAsBytes(objectName);
if (fileBytes == null || fileBytes.length == 0) {
throw new ServiceException("从MinIO读取文件失败,文件为空");
}
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);
}
}
}
}
@@ -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());
}
}
+8 -3
View File
@@ -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