1. 变量占位符:{中文变量}
2. 变量占位符预设类型,可以是:输入框、下拉、富文本(允许插入表格作为合同正本的附件内容)
This commit is contained in:
@@ -29,6 +29,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>8.5.12</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<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 {
|
public class AdminDictController {
|
||||||
|
|
||||||
private static final String TEMPLATE_FIELD_DICT_TYPE = "CONTRACT_TEMPLATE_FIELDS";
|
private static final String TEMPLATE_FIELD_DICT_TYPE = "CONTRACT_TEMPLATE_FIELDS";
|
||||||
|
private static final String TEMPLATE_DICT_TYPES = "TEMPLATE_DICT_TYPES";
|
||||||
|
|
||||||
@PostMapping("/getDictDataByType")
|
@PostMapping("/getDictDataByType")
|
||||||
public R<List<DictDataItem>> getDictDataByType(@RequestBody(required = false) Map<String, Object> body) {
|
public R<List<DictDataItem>> getDictDataByType(@RequestBody(required = false) Map<String, Object> body) {
|
||||||
String dictType = resolveDictType(body);
|
String dictType = resolveDictType(body);
|
||||||
if (TEMPLATE_FIELD_DICT_TYPE.equals(dictType)) {
|
if (dictType == null || dictType.isBlank()) {
|
||||||
return R.success(buildTemplateFieldItems());
|
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) {
|
private String resolveDictType(Map<String, Object> body) {
|
||||||
@@ -39,6 +48,15 @@ public class AdminDictController {
|
|||||||
return dictType == null ? null : String.valueOf(dictType).trim();
|
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() {
|
private List<DictDataItem> buildTemplateFieldItems() {
|
||||||
return List.of(
|
return List.of(
|
||||||
new DictDataItem("合同名称", "contractName", "合同正文中的合同名称"),
|
new DictDataItem("合同名称", "contractName", "合同正文中的合同名称"),
|
||||||
@@ -58,9 +76,43 @@ public class AdminDictController {
|
|||||||
new DictDataItem("附件情况", "attachmentSummary", "模板变量值,由用户自行维护"),
|
new DictDataItem("附件情况", "attachmentSummary", "模板变量值,由用户自行维护"),
|
||||||
new DictDataItem("模板名称", "templateLabel", "模板变量值,由用户自行维护"),
|
new DictDataItem("模板名称", "templateLabel", "模板变量值,由用户自行维护"),
|
||||||
new DictDataItem("签署日期", "signDate", "模板变量值,由用户自行维护"),
|
new DictDataItem("签署日期", "signDate", "模板变量值,由用户自行维护"),
|
||||||
new DictDataItem("A的TAx", "partyATaxNo", "模板变量值,由用户自行维护"),
|
new DictDataItem("甲方税号", "partyATaxNo", "模板变量值,由用户自行维护"),
|
||||||
new DictDataItem("B的TAx", "partyBTaxNo", "模板变量值,由用户自行维护")
|
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;
|
|||||||
* 模板变量映射
|
* 模板变量映射
|
||||||
*
|
*
|
||||||
* variableKey:Word 模板里的变量名
|
* variableKey:Word 模板里的变量名
|
||||||
* bindField:业务字段名
|
* bindField:业务字段编码
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -26,4 +26,22 @@ public class TemplateVariableMappingDto implements Serializable {
|
|||||||
private String variableKey;
|
private String variableKey;
|
||||||
|
|
||||||
private String bindField;
|
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;
|
package com.nbport.zgwl.manifest.handler;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.alibaba.fastjson2.TypeReference;
|
||||||
import com.nbport.zgwl.exception.ServiceException;
|
import com.nbport.zgwl.exception.ServiceException;
|
||||||
import com.nbport.zgwl.manifest.dto.ContractTemplateDto;
|
import com.nbport.zgwl.manifest.dto.ContractTemplateDto;
|
||||||
import com.nbport.zgwl.manifest.dto.TemplateOrgOptionDto;
|
import com.nbport.zgwl.manifest.dto.TemplateOrgOptionDto;
|
||||||
import com.nbport.zgwl.manifest.dto.TemplateVariableMappingDto;
|
import com.nbport.zgwl.manifest.dto.TemplateVariableMappingDto;
|
||||||
import com.nbport.zgwl.manifest.utils.ManifestJsonUtils;
|
import com.nbport.zgwl.manifest.utils.ManifestJsonUtils;
|
||||||
|
import com.nbport.zgwl.manifest.utils.ManifestManageConstants;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,11 +59,7 @@ public class ContractTemplateDataHandler {
|
|||||||
List<TemplateVariableMappingDto> mappings = template.getVariableMappings() == null ? new ArrayList<>() : template.getVariableMappings();
|
List<TemplateVariableMappingDto> mappings = template.getVariableMappings() == null ? new ArrayList<>() : template.getVariableMappings();
|
||||||
mappings = mappings.stream()
|
mappings = mappings.stream()
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.map(item -> new TemplateVariableMappingDto(
|
.map(this::normalizeMapping)
|
||||||
StrUtil.blankToDefault(item.getId(), "var_" + System.nanoTime()),
|
|
||||||
StrUtil.trim(item.getVariableKey()),
|
|
||||||
StrUtil.trim(item.getBindField())
|
|
||||||
))
|
|
||||||
.filter(item -> StrUtil.isNotBlank(item.getVariableKey()) || StrUtil.isNotBlank(item.getBindField()))
|
.filter(item -> StrUtil.isNotBlank(item.getVariableKey()) || StrUtil.isNotBlank(item.getBindField()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
template.setVariableMappings(mappings);
|
template.setVariableMappings(mappings);
|
||||||
@@ -97,13 +97,28 @@ public class ContractTemplateDataHandler {
|
|||||||
if (template.getVariableMappings() == null || template.getVariableMappings().isEmpty()) {
|
if (template.getVariableMappings() == null || template.getVariableMappings().isEmpty()) {
|
||||||
throw new ServiceException("请至少维护一个变量映射");
|
throw new ServiceException("请至少维护一个变量映射");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> variableKeys = new HashSet<>();
|
||||||
for (TemplateVariableMappingDto mapping : template.getVariableMappings()) {
|
for (TemplateVariableMappingDto mapping : template.getVariableMappings()) {
|
||||||
if (StrUtil.isBlank(mapping.getVariableKey())) {
|
if (StrUtil.isBlank(mapping.getVariableKey())) {
|
||||||
throw new ServiceException("模板变量名称不能为空");
|
throw new ServiceException("模板变量名称不能为空");
|
||||||
}
|
}
|
||||||
|
if (!variableKeys.add(mapping.getVariableKey())) {
|
||||||
|
throw new ServiceException("模板变量【" + mapping.getVariableKey() + "】重复,请检查后再保存");
|
||||||
|
}
|
||||||
if (StrUtil.isBlank(mapping.getBindField())) {
|
if (StrUtil.isBlank(mapping.getBindField())) {
|
||||||
throw new ServiceException("模板变量【" + mapping.getVariableKey() + "】尚未绑定业务字段");
|
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(
|
template.setVariableMappings(
|
||||||
ManifestJsonUtils.parseList(
|
ManifestJsonUtils.parseList(
|
||||||
template.getVariableMappingsJson(),
|
template.getVariableMappingsJson(),
|
||||||
new com.alibaba.fastjson2.TypeReference<List<TemplateVariableMappingDto>>() {
|
new TypeReference<List<TemplateVariableMappingDto>>() {
|
||||||
}
|
}
|
||||||
)
|
).stream().map(this::normalizeMapping).collect(Collectors.toList())
|
||||||
);
|
);
|
||||||
template.setVariables(
|
template.setVariables(
|
||||||
template.getVariableMappings().stream()
|
template.getVariableMappings().stream()
|
||||||
@@ -159,4 +174,29 @@ public class ContractTemplateDataHandler {
|
|||||||
template.setPublishOrgNames(names);
|
template.setPublishOrgNames(names);
|
||||||
template.setPublishScopeText(String.join("、", 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 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";
|
public static final String DEL_FLAG_NORMAL = "0";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package com.nbport.zgwl.service;
|
package com.nbport.zgwl.service;
|
||||||
|
|
||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.core.util.IdUtil;
|
|
||||||
import com.nbport.zgwl.exception.ServiceErrorCodeRange;
|
import com.nbport.zgwl.exception.ServiceErrorCodeRange;
|
||||||
import com.nbport.zgwl.exception.ServiceException;
|
import com.nbport.zgwl.exception.ServiceException;
|
||||||
import com.nbport.zgwl.utils.FileStorageUtils;
|
import com.nbport.zgwl.utils.MinioUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.http.ContentDisposition;
|
import org.springframework.http.ContentDisposition;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -16,78 +14,57 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.security.SecureRandom;
|
||||||
import java.nio.file.Path;
|
import java.security.cert.X509Certificate;
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class LocalFileService {
|
public class LocalFileService {
|
||||||
|
|
||||||
@Value("${file.local.path:${user.dir}/upload-files}")
|
private final MinioUtil minioUtil;
|
||||||
private String localPath;
|
|
||||||
|
|
||||||
public String upload(MultipartFile file) {
|
public String upload(MultipartFile file) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new ServiceException("上传文件不能为空");
|
throw new ServiceException("上传文件不能为空");
|
||||||
}
|
}
|
||||||
try {
|
return minioUtil.upload(file, null, true);
|
||||||
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("文件上传失败");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ResponseEntity<ByteArrayResource> download(String fileName) {
|
public ResponseEntity<ByteArrayResource> download(String fileName) {
|
||||||
try {
|
if (StrUtil.isBlank(fileName)) {
|
||||||
Path rootPath = Paths.get(localPath).toAbsolutePath().normalize();
|
throw new ServiceException(ServiceErrorCodeRange.FILE_NOT_FOUND, "文件不存在");
|
||||||
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("文件下载失败");
|
|
||||||
}
|
}
|
||||||
|
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) {
|
public String downloadFromUrl(String fileUrl) {
|
||||||
return downloadFromUrl(fileUrl, null);
|
return downloadFromUrl(fileUrl, null);
|
||||||
}
|
}
|
||||||
@@ -96,13 +73,11 @@ public class LocalFileService {
|
|||||||
if (fileUrl == null || fileUrl.isBlank()) {
|
if (fileUrl == null || fileUrl.isBlank()) {
|
||||||
throw new ServiceException("下载URL不能为空");
|
throw new ServiceException("下载URL不能为空");
|
||||||
}
|
}
|
||||||
// 先尝试不带 token(签章平台下载 URL 自带 JWT 认证)
|
|
||||||
try {
|
try {
|
||||||
return doDownload(fileUrl, null);
|
return doDownload(fileUrl, null);
|
||||||
} catch (ServiceException e) {
|
} catch (RuntimeException e) {
|
||||||
// 如果 401/403,再尝试带 token
|
if (accessToken != null && !accessToken.isBlank() && e.getMessage() != null && e.getMessage().contains("40")) {
|
||||||
if (accessToken != null && !accessToken.isBlank() && e.getMessage().contains("40")) {
|
log.info("无token下载失败,尝试带token重试:{}", fileUrl);
|
||||||
log.info("无token下载失败,尝试带token重试: {}", fileUrl);
|
|
||||||
return doDownload(fileUrl, accessToken);
|
return doDownload(fileUrl, accessToken);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
@@ -124,37 +99,43 @@ public class LocalFileService {
|
|||||||
|
|
||||||
int responseCode = conn.getResponseCode();
|
int responseCode = conn.getResponseCode();
|
||||||
if (responseCode < 200 || responseCode >= 300) {
|
if (responseCode < 200 || responseCode >= 300) {
|
||||||
// 读取错误响应体用于排查
|
|
||||||
String errorBody = "";
|
String errorBody = "";
|
||||||
try (InputStream es = conn.getErrorStream()) {
|
try (InputStream es = conn.getErrorStream()) {
|
||||||
if (es != null) {
|
if (es != null) {
|
||||||
errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8);
|
errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {
|
||||||
log.error("下载签章文件失败, URL={}, code={}, body={}", fileUrl, responseCode, errorBody);
|
}
|
||||||
throw new ServiceException("下载签章文件失败,HTTP状态码: " + responseCode + ", 详情: " + errorBody);
|
log.error("下载签章文件失败,URL={},code={},body={}", fileUrl, responseCode, errorBody);
|
||||||
|
throw new ServiceException("下载签章文件失败,HTTP状态码:" + responseCode + ",详情:" + errorBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
String fileName = extractFileName(fileUrl, conn);
|
byte[] fileBytes;
|
||||||
String suffix = FileStorageUtils.getFileSuffix(fileName);
|
try (InputStream is = conn.getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
Path rootPath = Paths.get(localPath).toAbsolutePath().normalize();
|
int len;
|
||||||
Files.createDirectories(rootPath);
|
while ((len = is.read(buffer)) != -1) {
|
||||||
String dateFolder = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
baos.write(buffer, 0, len);
|
||||||
String storedName = IdUtil.simpleUUID() + suffix;
|
}
|
||||||
String relativePath = dateFolder + "/" + storedName;
|
fileBytes = baos.toByteArray();
|
||||||
Path targetPath = FileStorageUtils.resolveFilePath(rootPath, relativePath);
|
|
||||||
Files.createDirectories(targetPath.getParent());
|
|
||||||
|
|
||||||
try (InputStream is = conn.getInputStream()) {
|
|
||||||
Files.copy(is, targetPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("从URL下载文件成功: {} -> {}", fileUrl, targetPath);
|
String originalFileName = extractFileName(fileUrl, conn);
|
||||||
return relativePath;
|
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) {
|
} catch (IOException e) {
|
||||||
log.error("从URL下载文件失败: {}", fileUrl, e);
|
log.error("从URL下载文件失败:{}", fileUrl, e);
|
||||||
throw new ServiceException("下载签章文件失败: " + e.getMessage());
|
throw new ServiceException("下载签章文件失败:" + e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
if (conn != null) {
|
if (conn != null) {
|
||||||
conn.disconnect();
|
conn.disconnect();
|
||||||
@@ -165,22 +146,29 @@ public class LocalFileService {
|
|||||||
private HttpURLConnection openConnection(URL url) throws IOException {
|
private HttpURLConnection openConnection(URL url) throws IOException {
|
||||||
try {
|
try {
|
||||||
if ("https".equalsIgnoreCase(url.getProtocol())) {
|
if ("https".equalsIgnoreCase(url.getProtocol())) {
|
||||||
javax.net.ssl.HttpsURLConnection httpsConn = (javax.net.ssl.HttpsURLConnection) url.openConnection();
|
HttpsURLConnection httpsConn = (HttpsURLConnection) url.openConnection();
|
||||||
javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS");
|
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||||
sslContext.init(null, new javax.net.ssl.TrustManager[]{
|
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
|
||||||
new javax.net.ssl.X509TrustManager() {
|
@Override
|
||||||
public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {}
|
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||||
public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {}
|
|
||||||
public java.security.cert.X509Certificate[] getAcceptedIssuers() { return new java.security.cert.X509Certificate[0]; }
|
|
||||||
}
|
}
|
||||||
}, 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.setSSLSocketFactory(sslContext.getSocketFactory());
|
||||||
httpsConn.setHostnameVerifier((hostname, session) -> true);
|
httpsConn.setHostnameVerifier((hostname, session) -> true);
|
||||||
return httpsConn;
|
return httpsConn;
|
||||||
}
|
}
|
||||||
return (HttpURLConnection) url.openConnection();
|
return (HttpURLConnection) url.openConnection();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("SSL初始化失败,使用默认连接: {}", e.getMessage());
|
log.warn("SSL初始化失败,使用默认连接:{}", e.getMessage());
|
||||||
return (HttpURLConnection) url.openConnection();
|
return (HttpURLConnection) url.openConnection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,33 +179,108 @@ public class LocalFileService {
|
|||||||
for (String part : disposition.split(";")) {
|
for (String part : disposition.split(";")) {
|
||||||
part = part.trim();
|
part = part.trim();
|
||||||
if (part.toLowerCase().startsWith("filename=")) {
|
if (part.toLowerCase().startsWith("filename=")) {
|
||||||
String name = part.substring(part.indexOf('=') + 1).replaceAll("^[\"\']|[\"\']$", "").trim();
|
String name = part.substring(part.indexOf('=') + 1).replaceAll("^[\"']|[\"']$", "").trim();
|
||||||
if (!name.isBlank()) return name;
|
if (!name.isBlank()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String path = urlStr.contains("?") ? urlStr.substring(0, urlStr.indexOf('?')) : urlStr;
|
String path = urlStr.contains("?") ? urlStr.substring(0, urlStr.indexOf('?')) : urlStr;
|
||||||
String name = path.substring(path.lastIndexOf('/') + 1);
|
String name = path.substring(path.lastIndexOf('/') + 1);
|
||||||
String suffix = FileStorageUtils.getFileSuffix(name);
|
String suffix = name.contains(".") ? name.substring(name.lastIndexOf('.')) : "";
|
||||||
// 后缀过长(>10)说明是JWT/乱码而非真实文件名, 默认pdf
|
|
||||||
if (!name.isBlank() && suffix.length() > 0 && suffix.length() <= 10) {
|
if (!name.isBlank() && suffix.length() > 0 && suffix.length() <= 10) {
|
||||||
return URLDecoder.decode(name, StandardCharsets.UTF_8);
|
return URLDecoder.decode(name, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
return "signed_contract.pdf";
|
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 {
|
try {
|
||||||
Path rootPath = Paths.get(localPath).toAbsolutePath().normalize();
|
byte[] fileBytes = minioUtil.downloadAsBytes(objectName);
|
||||||
Path filePath = FileStorageUtils.resolveFilePath(rootPath, fileName);
|
if (fileBytes == null || fileBytes.length == 0) {
|
||||||
if (!Files.exists(filePath) || Files.isDirectory(filePath)) {
|
throw new ServiceException("从MinIO读取文件失败,文件为空");
|
||||||
throw new ServiceException(ServiceErrorCodeRange.FILE_NOT_FOUND, "文件不存在");
|
|
||||||
}
|
}
|
||||||
return Files.readAllBytes(filePath);
|
log.info("从MinIO读取文件成功:{},大小:{}bytes", objectName, fileBytes.length);
|
||||||
} catch (IOException e) {
|
return fileBytes;
|
||||||
log.error("文件读取失败", e);
|
} catch (ServiceException e) {
|
||||||
throw new ServiceException("文件读取失败");
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,14 @@ springdoc:
|
|||||||
api-docs:
|
api-docs:
|
||||||
path: /v3/api-docs
|
path: /v3/api-docs
|
||||||
|
|
||||||
file:
|
minio:
|
||||||
local:
|
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:19000}
|
||||||
path: ${user.dir}/upload-files
|
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:
|
seal:
|
||||||
appKey: A7C4FD32BE454B5
|
appKey: A7C4FD32BE454B5
|
||||||
|
|||||||
Reference in New Issue
Block a user