diff --git a/sql/dm/字典导入菜单权限_DM8.sql b/sql/dm/字典导入菜单权限_DM8.sql new file mode 100644 index 00000000..bea0a1d2 --- /dev/null +++ b/sql/dm/字典导入菜单权限_DM8.sql @@ -0,0 +1,15 @@ +-- DM8 字典导入按钮权限脚本 +-- 幂等处理:清理旧的导入权限按钮,再重新写入 + +DELETE FROM system_role_menu WHERE menu_id = 103001; +DELETE FROM system_menu WHERE id = 103001; + +INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, component_name, + status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted +) VALUES ( + 103001, '字典导入', 'system:dict:import', 3, 6, 105, '#', '#', '', NULL, + 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0' +); + +-- 如需同步给指定角色,请手工向 system_role_menu 插入对应关系记录 diff --git a/zt-dependencies/pom.xml b/zt-dependencies/pom.xml index 55186edb..328dd966 100644 --- a/zt-dependencies/pom.xml +++ b/zt-dependencies/pom.xml @@ -86,6 +86,8 @@ 4.1.116.Final 1.2.5 0.9.0 + + 2.15.1 4.5.13 2.17.0 @@ -661,6 +663,13 @@ + + + com.yomahub + liteflow-spring-boot-starter + ${liteflow.version} + + org.pf4j diff --git a/zt-framework/zt-spring-boot-starter-excel/src/main/java/com/zt/plat/framework/excel/core/util/ExcelUtils.java b/zt-framework/zt-spring-boot-starter-excel/src/main/java/com/zt/plat/framework/excel/core/util/ExcelUtils.java index 46ffd576..9d23b05c 100644 --- a/zt-framework/zt-spring-boot-starter-excel/src/main/java/com/zt/plat/framework/excel/core/util/ExcelUtils.java +++ b/zt-framework/zt-spring-boot-starter-excel/src/main/java/com/zt/plat/framework/excel/core/util/ExcelUtils.java @@ -49,4 +49,11 @@ public class ExcelUtils { .doReadAllSync(); } + public static List read(MultipartFile file, Class head, int sheetNo) throws IOException { + return EasyExcel.read(file.getInputStream(), head, null) + .autoCloseStream(false) + .sheet(sheetNo) + .doReadSync(); + } + } diff --git a/zt-gateway/src/main/resources/application.yaml b/zt-gateway/src/main/resources/application.yaml index ca176285..15f70592 100644 --- a/zt-gateway/src/main/resources/application.yaml +++ b/zt-gateway/src/main/resources/application.yaml @@ -272,6 +272,13 @@ spring: - Path=/admin-api/databus/** filters: - RewritePath=/admin-api/databus/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs + ## rule-server 服务 + - id: rule-admin-api # 路由的编号 + uri: grayLb://rule-server + predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 + - Path=/admin-api/rule/** + filters: + - RewritePath=/admin-api/rule/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/enums/gateway/ApiStatusEnum.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/enums/gateway/ApiStatusEnum.java index 76ffda64..28b2d5a9 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/enums/gateway/ApiStatusEnum.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/enums/gateway/ApiStatusEnum.java @@ -10,12 +10,13 @@ import lombok.Getter; @Getter public enum ApiStatusEnum { - DRAFT(0), - ONLINE(1), - OFFLINE(2), - DEPRECATED(3); + DRAFT(0, "草稿"), + ONLINE(1, "已上线"), + OFFLINE(2, "已下线"), + DEPRECATED(3, "已废弃"); private final int status; + private final String label; public static boolean isOnline(Integer status) { return status != null && status == ONLINE.status; @@ -25,4 +26,21 @@ public enum ApiStatusEnum { return status != null && status == DEPRECATED.status; } + public static ApiStatusEnum fromStatus(Integer status) { + if (status == null) { + return null; + } + for (ApiStatusEnum value : values()) { + if (value.status == status) { + return value; + } + } + return null; + } + + public static String labelOf(Integer status) { + ApiStatusEnum value = fromStatus(status); + return value != null ? value.label : "未知"; + } + } diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java index 2945f5ed..8113e1a3 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java @@ -4,21 +4,46 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; + +import java.time.Duration; @Configuration public class GatewayWebClientConfiguration { private final int maxInMemorySize; + private final long maxIdleTimeMillis; + private final long evictInBackgroundMillis; + private final ReactorClientHttpConnector httpConnector; public GatewayWebClientConfiguration( - @Value("${databus.gateway.web-client.max-in-memory-size:20971520}") int maxInMemorySize) { + @Value("${databus.gateway.web-client.max-in-memory-size:20971520}") int maxInMemorySize, + @Value("${databus.gateway.web-client.max-idle-time:45000}") long maxIdleTimeMillis, + @Value("${databus.gateway.web-client.evict-in-background-interval:20000}") long evictInBackgroundMillis) { this.maxInMemorySize = maxInMemorySize; + this.maxIdleTimeMillis = maxIdleTimeMillis > 0 ? maxIdleTimeMillis : 45000L; + this.evictInBackgroundMillis = Math.max(evictInBackgroundMillis, 0L); + this.httpConnector = buildConnector(); } @Bean public WebClientCustomizer gatewayWebClientCustomizer() { - return builder -> builder.codecs(configurer -> - configurer.defaultCodecs().maxInMemorySize(maxInMemorySize)); + return builder -> builder + .clientConnector(httpConnector) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(maxInMemorySize)); + } + + private ReactorClientHttpConnector buildConnector() { + ConnectionProvider.Builder providerBuilder = ConnectionProvider.builder("databus-gateway") + .maxIdleTime(Duration.ofMillis(maxIdleTimeMillis)); + if (evictInBackgroundMillis > 0) { + providerBuilder.evictInBackground(Duration.ofMillis(evictInBackgroundMillis)); + } + ConnectionProvider provider = providerBuilder.build(); + HttpClient httpClient = HttpClient.create(provider).compress(true); + return new ReactorClientHttpConnector(httpClient); } } diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiFlowDispatcher.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiFlowDispatcher.java index ce07a981..3c07d9c4 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiFlowDispatcher.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiFlowDispatcher.java @@ -1,6 +1,7 @@ package com.zt.plat.module.databus.framework.integration.gateway.core; import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil; +import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate; import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext; import lombok.RequiredArgsConstructor; import org.springframework.integration.core.MessagingTemplate; @@ -36,6 +37,24 @@ public class ApiFlowDispatcher { return (ApiInvocationContext) reply.getPayload(); } + public ApiInvocationContext dispatchWithAggregate(ApiDefinitionAggregate aggregate, ApiInvocationContext context) { + IntegrationFlowManager.DebugFlowHandle handle = integrationFlowManager.obtainDebugHandle(aggregate); + MessageChannel channel = handle.channel(); + Message message = MessageBuilder.withPayload(context) + .setHeader("apiCode", aggregate.getDefinition().getApiCode()) + .setHeader("version", aggregate.getDefinition().getVersion()) + .build(); + try { + Message reply = messagingTemplate.sendAndReceive(channel, message); + if (reply == null) { + throw ServiceExceptionUtil.exception(API_FLOW_NO_REPLY, aggregate.getDefinition().getApiCode(), aggregate.getDefinition().getVersion()); + } + return (ApiInvocationContext) reply.getPayload(); + } finally { + integrationFlowManager.releaseDebugHandle(handle); + } + } + private MessageChannel requireInputChannel(String apiCode, String version) { // 未命中时,进行一次兜底补偿查询 return integrationFlowManager.locateInputChannel(apiCode, version) diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiGatewayExecutionService.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiGatewayExecutionService.java index 1c593897..f84e5fdc 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiGatewayExecutionService.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/ApiGatewayExecutionService.java @@ -3,12 +3,15 @@ package com.zt.plat.module.databus.framework.integration.gateway.core; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.zt.plat.framework.common.exception.ServiceException; +import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil; import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO; import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties; +import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate; import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse; import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext; import com.zt.plat.module.databus.framework.integration.gateway.security.GatewayJwtResolver; import com.zt.plat.module.databus.framework.integration.gateway.security.GatewaySecurityFilter; +import com.zt.plat.module.databus.service.gateway.ApiDefinitionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.*; @@ -23,6 +26,9 @@ import java.lang.reflect.Array; import java.net.URI; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Optional; + +import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_DEFINITION_NOT_FOUND; /** * Orchestrates API portal request mapping, dispatch and response building so that @@ -40,6 +46,7 @@ public class ApiGatewayExecutionService { private static final String HEADER_QUERY_STRING = org.springframework.integration.http.HttpHeaders.PREFIX + "queryString"; private static final String HEADER_REMOTE_ADDRESS = org.springframework.integration.http.HttpHeaders.PREFIX + "remoteAddress"; private static final String LOCAL_DEBUG_REMOTE_ADDRESS = "127.0.0.1"; + private static final String ATTR_DEBUG_INVOKE = "gatewayDebugInvoke"; private final ApiGatewayRequestMapper requestMapper; private final ApiFlowDispatcher apiFlowDispatcher; @@ -47,6 +54,7 @@ public class ApiGatewayExecutionService { private final ApiGatewayProperties properties; private final ObjectMapper objectMapper; private final ApiGatewayAccessLogger accessLogger; + private final ApiDefinitionService apiDefinitionService; /** * Maps a raw HTTP message (as provided by Spring Integration) into a context message. @@ -67,8 +75,16 @@ public class ApiGatewayExecutionService { ApiInvocationContext context = message.getPayload(); accessLogger.onRequest(context); ApiInvocationContext responseContext; + ApiDefinitionAggregate debugAggregate = null; try { - responseContext = apiFlowDispatcher.dispatch(context.getApiCode(), context.getApiVersion(), context); + if (Boolean.TRUE.equals(context.getAttributes().get(ATTR_DEBUG_INVOKE))) { + debugAggregate = resolveDebugAggregate(context); + } + if (debugAggregate != null) { + responseContext = apiFlowDispatcher.dispatchWithAggregate(debugAggregate, context); + } else { + responseContext = apiFlowDispatcher.dispatch(context.getApiCode(), context.getApiVersion(), context); + } } catch (ServiceException ex) { errorProcessor.applyServiceException(context, ex); accessLogger.onException(context, ex); @@ -113,10 +129,20 @@ public class ApiGatewayExecutionService { ApiInvocationContext context = mappedMessage.getPayload(); // Ensure query parameters & headers from debug payload are reflected after mapping. mergeDebugMetadata(context, reqVO); + context.getAttributes().put(ATTR_DEBUG_INVOKE, Boolean.TRUE); ApiInvocationContext responseContext = dispatch(mappedMessage); return buildResponseEntity(responseContext); } + private ApiDefinitionAggregate resolveDebugAggregate(ApiInvocationContext context) { + Optional activeDefinition = apiDefinitionService.findByCodeAndVersion(context.getApiCode(), context.getApiVersion()); + if (activeDefinition.isPresent()) { + return activeDefinition.get(); + } + return apiDefinitionService.findByCodeAndVersionIncludingInactive(context.getApiCode(), context.getApiVersion()) + .orElseThrow(() -> ServiceExceptionUtil.exception(API_DEFINITION_NOT_FOUND)); + } + private Message buildDebugMessage(ApiGatewayInvokeReqVO reqVO) { Object payload = preparePayload(reqVO.getPayload()); MessageBuilder builder = MessageBuilder.withPayload(payload); diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/IntegrationFlowManager.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/IntegrationFlowManager.java index bcd985ae..998c6782 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/IntegrationFlowManager.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/core/IntegrationFlowManager.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** @@ -63,6 +64,33 @@ public class IntegrationFlowManager { return Optional.ofNullable(registration.getInputChannel()); } + public DebugFlowHandle obtainDebugHandle(ApiDefinitionAggregate aggregate) { + String key = key(aggregate.getDefinition().getApiCode(), aggregate.getDefinition().getVersion()); + IntegrationFlowContext.IntegrationFlowRegistration existing = activeRegistrations.get(key); + if (existing != null) { + return new DebugFlowHandle(existing.getInputChannel(), existing.getId(), false); + } + ApiFlowRegistration registration = apiFlowAssembler.assemble(aggregate); + String debugId = registration.getFlowId() + "#debug#" + UUID.randomUUID(); + IntegrationFlowContext.IntegrationFlowRegistration debugRegistration = integrationFlowContext.registration(registration.getFlow()) + .id(debugId) + .register(); + log.debug("[API-PORTAL] 临时注册调试流程 {} 对应 apiCode={} version={}", debugId, aggregate.getDefinition().getApiCode(), aggregate.getDefinition().getVersion()); + return new DebugFlowHandle(debugRegistration.getInputChannel(), debugRegistration.getId(), true); + } + + public void releaseDebugHandle(DebugFlowHandle handle) { + if (handle == null || !handle.temporary) { + return; + } + try { + integrationFlowContext.remove(handle.registrationId); + log.debug("[API-PORTAL] 已移除调试流程 {}", handle.registrationId); + } catch (Exception ex) { + log.warn("移除调试流程 {} 失败", handle.registrationId, ex); + } + } + private void registerFlow(ApiDefinitionAggregate aggregate) { String key = key(aggregate.getDefinition().getApiCode(), aggregate.getDefinition().getVersion()); deregisterByKey(key); @@ -93,4 +121,24 @@ public class IntegrationFlowManager { private String key(String apiCode, String version) { return (apiCode + ":" + version).toLowerCase(); } + + public static final class DebugFlowHandle { + private final MessageChannel channel; + private final String registrationId; + private final boolean temporary; + + private DebugFlowHandle(MessageChannel channel, String registrationId, boolean temporary) { + this.channel = channel; + this.registrationId = registrationId; + this.temporary = temporary; + } + + public MessageChannel channel() { + return channel; + } + + public boolean temporary() { + return temporary; + } + } } diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/step/impl/HttpStepHandler.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/step/impl/HttpStepHandler.java index bf75d09a..07dfacc8 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/step/impl/HttpStepHandler.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/step/impl/HttpStepHandler.java @@ -23,7 +23,10 @@ import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; +import reactor.netty.http.client.PrematureCloseException; +import reactor.util.retry.Retry; +import java.io.IOException; import java.net.URI; import java.time.Duration; import java.time.Instant; @@ -43,6 +46,8 @@ public class HttpStepHandler implements ApiStepHandler { private final WebClient.Builder webClientBuilder; private final ExpressionExecutor expressionExecutor; + private static final Duration RETRY_DELAY = Duration.ofMillis(200); + private static final Set DEFAULT_FORWARDED_HEADERS = Set.of( "authorization", "zt-auth-token", @@ -108,6 +113,7 @@ public class HttpStepHandler implements ApiStepHandler { WebClient client = webClientBuilder.build(); WebClient.RequestHeadersSpec requestSpec = buildRequest(client, callSpec, requestPayload, headerMap, supportsBody); Mono responseMono = requestSpec.retrieve().bodyToMono(Object.class); + responseMono = applyResilientRetry(responseMono, stepDefinition); Object response = timeout == null ? responseMono.block() : responseMono.block(timeout); payload.addStepResult(ApiStepResult.builder() .stepId(stepDefinition.getStep().getId()) @@ -380,4 +386,37 @@ public class HttpStepHandler implements ApiStepHandler { // 所有请求都要传递请求体 return true; } + + private Mono applyResilientRetry(Mono responseMono, ApiStepDefinition stepDefinition) { + return responseMono.retryWhen(Retry.fixedDelay(1, RETRY_DELAY) + .filter(this::isRetryableException) + .doBeforeRetry(signal -> { + if (log.isWarnEnabled()) { + log.warn("HTTP 步骤 stepId={} 第{}次重试,原因:{}", + stepDefinition.getStep().getId(), + signal.totalRetriesInARow(), + signal.failure() == null ? "未知" : signal.failure().getMessage()); + } + })); + } + + private boolean isRetryableException(Throwable throwable) { + if (throwable == null) { + return false; + } + Throwable cursor = throwable; + while (cursor != null) { + if (cursor instanceof ServiceException) { + return false; + } + if (cursor instanceof PrematureCloseException) { + return true; + } + if (cursor instanceof IOException) { + return true; + } + cursor = cursor.getCause(); + } + return false; + } } diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/ApiDefinitionService.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/ApiDefinitionService.java index e745c6bf..3d19736a 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/ApiDefinitionService.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/ApiDefinitionService.java @@ -24,6 +24,11 @@ public interface ApiDefinitionService { */ Optional findByCodeAndVersion(String apiCode, String version); + /** + * Lookup API definition regardless of publish status. + */ + Optional findByCodeAndVersionIncludingInactive(String apiCode, String version); + /** * Refresh a specific definition by evicting cache and reloading from DB. */ diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/impl/ApiDefinitionServiceImpl.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/impl/ApiDefinitionServiceImpl.java index c7ca8d51..4c147b19 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/impl/ApiDefinitionServiceImpl.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/service/gateway/impl/ApiDefinitionServiceImpl.java @@ -92,6 +92,12 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService { } } + @Override + public Optional findByCodeAndVersionIncludingInactive(String apiCode, String version) { + return apiDefinitionMapper.selectByCodeAndVersion(apiCode, version) + .map(this::buildAggregate); + } + @Override public Optional refresh(String apiCode, String version) { String cacheKey = buildCacheKey(apiCode, version); diff --git a/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/enums/ErrorCodeConstants.java b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/enums/ErrorCodeConstants.java index b6f0dd4b..e6a49d71 100644 --- a/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/enums/ErrorCodeConstants.java +++ b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/enums/ErrorCodeConstants.java @@ -86,6 +86,7 @@ public interface ErrorCodeConstants { ErrorCode DICT_TYPE_NAME_DUPLICATE = new ErrorCode(1_002_006_003, "已经存在该名字的字典类型"); ErrorCode DICT_TYPE_TYPE_DUPLICATE = new ErrorCode(1_002_006_004, "已经存在该类型的字典类型"); ErrorCode DICT_TYPE_HAS_CHILDREN = new ErrorCode(1_002_006_005, "无法删除,该字典类型还有字典数据"); + ErrorCode DICT_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_002_006_100, "导入字典数据不能为空!"); // ========== 字典数据 1-002-007-000 ========== ErrorCode DICT_DATA_NOT_EXISTS = new ErrorCode(1_002_007_001, "当前字典数据不存在"); diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/DictTypeController.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/DictTypeController.java index a74a99e7..16827086 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/DictTypeController.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/DictTypeController.java @@ -1,11 +1,20 @@ package com.zt.plat.module.system.controller.admin.dict; +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; import com.zt.plat.framework.apilog.core.annotation.ApiAccessLog; import com.zt.plat.framework.common.pojo.CommonResult; import com.zt.plat.framework.common.pojo.PageParam; import com.zt.plat.framework.common.pojo.PageResult; +import com.zt.plat.framework.common.util.http.HttpUtils; import com.zt.plat.framework.common.util.object.BeanUtils; +import com.zt.plat.framework.excel.core.handler.SelectSheetWriteHandler; import com.zt.plat.framework.excel.core.util.ExcelUtils; +import com.alibaba.excel.converters.longconverter.LongStringConverter; +import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportExcelVO; +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportRespVO; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypePageReqVO; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypeRespVO; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypeSaveReqVO; @@ -14,6 +23,7 @@ import com.zt.plat.module.system.dal.dataobject.dict.DictTypeDO; import com.zt.plat.module.system.service.dict.DictTypeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletResponse; @@ -21,8 +31,10 @@ import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.Arrays; import java.util.List; import static com.zt.plat.framework.apilog.core.enums.OperateTypeEnum.EXPORT; @@ -99,4 +111,45 @@ public class DictTypeController { BeanUtils.toBean(list, DictTypeRespVO.class)); } + @GetMapping("/get-import-template") + @Operation(summary = "获得字典导入模板") + public void importTemplate(HttpServletResponse response) throws IOException { + List samples = Arrays.asList( + DictImportExcelVO.builder() + .dictTypeName("性别").dictType("system_user_sex").dictTypeRemark("系统内置示例") + .label("男").value("1").sort(1).colorType("primary").dataRemark("示例数据").build(), + DictImportExcelVO.builder() + .dictTypeName("证件类型").dictType("system_id_card_type").dictTypeRemark("自定义示例") + .label("身份证").value("ID").sort(1).dataRemark("示例数据").build() + ); + + try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()) + .autoCloseStream(false) + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) + .registerConverter(new LongStringConverter()) + .build()) { + WriteSheet sheet = EasyExcel.writerSheet(0, "字典导入") + .head(DictImportExcelVO.class) + .registerWriteHandler(new SelectSheetWriteHandler(DictImportExcelVO.class)) + .build(); + writer.write(samples, sheet); + } + + response.addHeader("Content-Disposition", + "attachment;filename=" + HttpUtils.encodeUtf8("字典导入模板.xls")); + response.setContentType("application/vnd.ms-excel;charset=UTF-8"); + } + + @PostMapping("/import") + @Operation(summary = "导入字典") + @Parameters({ + @Parameter(name = "file", description = "Excel 文件", required = true) + }) + @PreAuthorize("@ss.hasPermission('system:dict:import')") + public CommonResult importDict(@RequestParam("file") MultipartFile file) throws IOException { + List importList = ExcelUtils.read(file, DictImportExcelVO.class, 0); + DictImportRespVO respVO = dictTypeService.importDictList(importList); + return success(respVO); + } + } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/data/DictDataImportExcelVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/data/DictDataImportExcelVO.java new file mode 100644 index 00000000..607c4919 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/data/DictDataImportExcelVO.java @@ -0,0 +1,13 @@ +package com.zt.plat.module.system.controller.admin.dict.vo.data; + +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportExcelVO; + +/** + * @deprecated 迁移到单工作表导入模型 {@link DictImportExcelVO} + */ +@Deprecated +public final class DictDataImportExcelVO { + + private DictDataImportExcelVO() { + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictImportExcelVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictImportExcelVO.java new file mode 100644 index 00000000..3af749f1 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictImportExcelVO.java @@ -0,0 +1,73 @@ +package com.zt.plat.module.system.controller.admin.dict.vo.type; + +import com.alibaba.excel.annotation.ExcelProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +/** + * 字典导入 Excel VO(单行同时包含字典类型与字典数据) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Accessors(chain = false) +public class DictImportExcelVO { + + /** + * 字典名称 + */ + @ExcelProperty("字典名称") + private String dictTypeName; + + /** + * 字典类型 + */ + @ExcelProperty("字典类型") + private String dictType; + + /** + * 字典类型备注 + */ + @ExcelProperty("类型备注") + private String dictTypeRemark; + + /** + * 字典标签 + */ + @ExcelProperty("字典标签") + private String label; + + /** + * 字典键值 + */ + @ExcelProperty("字典键值") + private String value; + + /** + * 排序 + */ + @ExcelProperty("排序") + private Integer sort; + + /** + * 颜色类型 + */ + @ExcelProperty("颜色类型") + private String colorType; + + /** + * CSS 样式 + */ + @ExcelProperty("CSS 样式") + private String cssClass; + + /** + * 字典数据备注 + */ + @ExcelProperty("数据备注") + private String dataRemark; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictImportRespVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictImportRespVO.java new file mode 100644 index 00000000..eb1d0062 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictImportRespVO.java @@ -0,0 +1,36 @@ +package com.zt.plat.module.system.controller.admin.dict.vo.type; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 字典导入响应 VO + */ +@Data +@Builder +@Schema(description = "管理后台 - 字典导入 Response VO") +public class DictImportRespVO { + + @Schema(description = "创建成功的字典类型名称列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List createDictTypeNames; + + @Schema(description = "更新成功的字典类型名称列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List updateDictTypeNames; + + @Schema(description = "导入失败的字典类型集合,key 为字典名称,value 为失败原因", requiredMode = Schema.RequiredMode.REQUIRED) + private Map failureDictTypeNames; + + @Schema(description = "创建成功的字典数据标识列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List createDictDataKeys; + + @Schema(description = "更新成功的字典数据标识列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List updateDictDataKeys; + + @Schema(description = "导入失败的字典数据集合,key 为字典数据标识,value 为失败原因", requiredMode = Schema.RequiredMode.REQUIRED) + private Map failureDictDataKeys; + +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictTypeImportExcelVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictTypeImportExcelVO.java new file mode 100644 index 00000000..e4f78a3d --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/dict/vo/type/DictTypeImportExcelVO.java @@ -0,0 +1,11 @@ +package com.zt.plat.module.system.controller.admin.dict.vo.type; + +/** + * @deprecated 保留空壳文件以兼容历史引用,新的导入请使用 {@link DictImportExcelVO} + */ +@Deprecated +public final class DictTypeImportExcelVO { + + private DictTypeImportExcelVO() { + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeService.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeService.java index 5da93bf1..c40da4ed 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeService.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeService.java @@ -3,6 +3,8 @@ package com.zt.plat.module.system.service.dict; import com.zt.plat.framework.common.pojo.PageResult; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypePageReqVO; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypeSaveReqVO; +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportExcelVO; +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportRespVO; import com.zt.plat.module.system.dal.dataobject.dict.DictTypeDO; import java.util.List; @@ -67,4 +69,12 @@ public interface DictTypeService { */ List getDictTypeList(); + /** + * 导入字典类型与字典数据(单工作表) + * + * @param importList 导入行列表 + * @return 导入结果 + */ + DictImportRespVO importDictList(List importList); + } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeServiceImpl.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeServiceImpl.java index 5e8a8dbf..0b9099f4 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeServiceImpl.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/dict/DictTypeServiceImpl.java @@ -1,19 +1,36 @@ package com.zt.plat.module.system.service.dict; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import com.google.common.annotations.VisibleForTesting; +import com.zt.plat.framework.common.enums.CommonStatusEnum; import com.zt.plat.framework.common.pojo.PageResult; +import com.zt.plat.framework.common.util.collection.CollectionUtils; import com.zt.plat.framework.common.util.date.LocalDateTimeUtils; import com.zt.plat.framework.common.util.object.BeanUtils; +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportExcelVO; +import com.zt.plat.module.system.controller.admin.dict.vo.type.DictImportRespVO; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypePageReqVO; import com.zt.plat.module.system.controller.admin.dict.vo.type.DictTypeSaveReqVO; +import com.zt.plat.module.system.controller.admin.dict.vo.data.DictDataSaveReqVO; +import com.zt.plat.module.system.dal.dataobject.dict.DictDataDO; import com.zt.plat.module.system.dal.dataobject.dict.DictTypeDO; import com.zt.plat.module.system.dal.mysql.dict.DictTypeMapper; -import com.google.common.annotations.VisibleForTesting; -import org.springframework.stereotype.Service; - import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.zt.plat.module.system.enums.ErrorCodeConstants.*; @@ -24,6 +41,7 @@ import static com.zt.plat.module.system.enums.ErrorCodeConstants.*; * @author ZT */ @Service +@Slf4j public class DictTypeServiceImpl implements DictTypeService { @Resource @@ -92,6 +110,128 @@ public class DictTypeServiceImpl implements DictTypeService { return dictTypeMapper.selectList(); } + @Override + @Transactional(rollbackFor = Exception.class) + public DictImportRespVO importDictList(List importList) { + if (CollUtil.isEmpty(importList)) { + throw exception(DICT_IMPORT_LIST_IS_EMPTY); + } + + Map typeByType = new HashMap<>(); + Map typeByName = new HashMap<>(); + List existingTypes = dictTypeMapper.selectList(); + existingTypes.forEach(item -> { + typeByType.put(item.getType(), item); + typeByName.put(item.getName(), item); + }); + + List createTypeNames = new ArrayList<>(); + Map failureTypeNames = new LinkedHashMap<>(); + List createDataKeys = new ArrayList<>(); + Map failureDataKeys = new LinkedHashMap<>(); + + Map> dataCacheByType = new HashMap<>(); + Set seenRowKeys = new HashSet<>(); + + for (DictImportExcelVO row : importList) { + String dictTypeName = trimToNull(row.getDictTypeName()); + String dictTypeCode = trimToNull(row.getDictType()); + String label = trimToNull(row.getLabel()); + String value = trimToNull(row.getValue()); + String displayTypeKey = StrUtil.nullToDefault(dictTypeName, StrUtil.nullToDefault(dictTypeCode, "未知字典")); + String displayDataKey = String.format("%s-%s", displayTypeKey, StrUtil.nullToDefault(label, "未知标签")); + + if (StrUtil.isEmpty(dictTypeName) || StrUtil.isEmpty(dictTypeCode)) { + failureTypeNames.put(displayTypeKey, "字典名称与字典类型均不能为空"); + continue; + } + if (StrUtil.isEmpty(label) || StrUtil.isEmpty(value)) { + failureDataKeys.put(displayDataKey, "字典标签和值不能为空"); + continue; + } + String rowKey = dictTypeCode.toLowerCase(Locale.ROOT) + "::" + value.toLowerCase(Locale.ROOT); + if (!seenRowKeys.add(rowKey)) { + failureDataKeys.put(displayDataKey, "Excel 中存在重复的字典数据"); + continue; + } + + DictTypeDO dictType = typeByType.get(dictTypeCode); + if (dictType == null) { + dictType = typeByName.get(dictTypeName); + } + try { + if (dictType == null) { + DictTypeSaveReqVO typeReq = new DictTypeSaveReqVO(); + typeReq.setName(dictTypeName); + typeReq.setType(dictTypeCode); + typeReq.setStatus(CommonStatusEnum.ENABLE.getStatus()); + typeReq.setRemark(trimToNull(row.getDictTypeRemark())); + Long id = createDictType(typeReq); + dictType = dictTypeMapper.selectById(id); + if (dictType == null) { + dictType = new DictTypeDO(); + dictType.setId(id); + dictType.setName(typeReq.getName()); + dictType.setType(typeReq.getType()); + dictType.setStatus(typeReq.getStatus()); + dictType.setRemark(typeReq.getRemark()); + } + typeByType.put(dictType.getType(), dictType); + typeByName.put(dictType.getName(), dictType); + createTypeNames.add(dictType.getName()); + } + } catch (Exception ex) { + String message = ex.getMessage(); + failureTypeNames.put(displayTypeKey, StrUtil.blankToDefault(message, "导入失败")); + log.warn("Import dict type failed, key={}, message=", displayTypeKey, ex); + continue; + } + + try { + Map cache = dataCacheByType.computeIfAbsent(dictType.getType(), + key -> CollectionUtils.convertMap(dictDataService.getDictDataListByDictType(key), DictDataDO::getValue)); + DictDataDO existsData = cache.get(value); + if (existsData != null) { + failureDataKeys.put(displayDataKey, "字典数据已存在,不允许重复导入"); + continue; + } + DictDataSaveReqVO dataReq = new DictDataSaveReqVO(); + dataReq.setDictType(dictType.getType()); + dataReq.setLabel(label); + dataReq.setValue(value); + dataReq.setSort(ObjectUtil.defaultIfNull(row.getSort(), 0)); + dataReq.setStatus(CommonStatusEnum.ENABLE.getStatus()); + dataReq.setColorType(trimToNull(row.getColorType())); + dataReq.setCssClass(trimToNull(row.getCssClass())); + dataReq.setRemark(trimToNull(row.getDataRemark())); + Long dataId = dictDataService.createDictData(dataReq); + DictDataDO created = dictDataService.getDictData(dataId); + if (created == null) { + created = new DictDataDO(); + created.setId(dataId); + created.setDictType(dataReq.getDictType()); + created.setLabel(dataReq.getLabel()); + created.setValue(dataReq.getValue()); + } + cache.put(created.getValue(), created); + createDataKeys.add(displayDataKey); + } catch (Exception ex) { + String message = ex.getMessage(); + failureDataKeys.put(displayDataKey, StrUtil.blankToDefault(message, "导入失败")); + log.warn("Import dict data failed, key={}, message=", displayDataKey, ex); + } + } + + return DictImportRespVO.builder() + .createDictTypeNames(createTypeNames) + .updateDictTypeNames(List.of()) + .failureDictTypeNames(failureTypeNames) + .createDictDataKeys(createDataKeys) + .updateDictDataKeys(List.of()) + .failureDictDataKeys(failureDataKeys) + .build(); + } + @VisibleForTesting void validateDictTypeNameUnique(Long id, String name) { DictTypeDO dictType = dictTypeMapper.selectByName(name); @@ -137,4 +277,12 @@ public class DictTypeServiceImpl implements DictTypeService { return dictType; } + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trim = StrUtil.trim(value); + return StrUtil.isEmpty(trim) ? null : trim; + } + }