1. 变量占位符:{中文变量}
2. 变量占位符预设类型,可以是:输入框、下拉、富文本(允许插入表格作为合同正本的附件内容)
This commit is contained in:
@@ -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;
|
||||
* 模板变量映射
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user