listenerSet = this.listeners.get(serviceKey);
- if (listenerSet != null) {
- listenerSet.remove(listener);
- if (listenerSet.isEmpty()) {
- this.listeners.remove(serviceKey);
- }
- }
- }
-
- public Boolean isEmpty() {
- return this.listeners.isEmpty();
- }
-
- @Override
- public void onMessage(String key, String msg) {
- logger.info("sub from redis:" + key + " message:" + msg);
- String applicationNames = getMappingData(buildMappingKey(DEFAULT_MAPPING_GROUP), msg);
- MappingChangedEvent mappingChangedEvent = new MappingChangedEvent(msg, getAppNames(applicationNames));
- if (!CollectionUtils.isEmpty(listeners.get(msg))) {
- for (MappingListener mappingListener : listeners.get(msg)) {
- mappingListener.onEvent(mappingChangedEvent);
- }
- }
- }
-
- @Override
- public void onPMessage(String pattern, String key, String msg) {
- onMessage(key, msg);
- }
-
- @Override
- public void onPSubscribe(String pattern, int subscribedChannels) {
- super.onPSubscribe(pattern, subscribedChannels);
- }
- }
-
- /**
- * Subscribe application names change message.
- */
- class MappingDataListener extends Thread {
-
- private String path;
-
- private final NotifySub notifySub = new NotifySub();
- // for test
- protected volatile boolean running = true;
-
- public MappingDataListener(String path) {
- this.path = path;
- }
-
- public NotifySub getNotifySub() {
- return notifySub;
- }
-
- @Override
- public void run() {
- while (running) {
- if (pool != null) {
- try (Jedis jedis = pool.getResource()) {
- jedis.subscribe(notifySub, path);
- } catch (Throwable e) {
- String msg = "Failed to subscribe " + path + ", cause: " + e.getMessage();
- logger.error(TRANSPORT_FAILED_RESPONSE, "", "", msg, e);
- throw new RpcException(msg, e);
- }
- } else {
- try (JedisCluster jedisCluster = new JedisCluster(
- jedisClusterNodes, timeout, timeout, 2, password, new GenericObjectPoolConfig<>())) {
- jedisCluster.subscribe(notifySub, path);
- } catch (Throwable e) {
- String msg = "Failed to subscribe " + path + ", cause: " + e.getMessage();
- logger.error(TRANSPORT_FAILED_RESPONSE, "", "", msg, e);
- throw new RpcException(msg, e);
- }
- }
- }
- }
-
- public void shutdown() {
- try {
- running = false;
- notifySub.unsubscribe(path);
- } catch (Throwable e) {
- String msg = "Failed to unsubscribe " + path + ", cause: " + e.getMessage();
- logger.error(TRANSPORT_FAILED_RESPONSE, "", "", msg, e);
- }
- }
- }
-}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/config/CustomBeanFactoryPostProcessor.java b/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/config/CustomBeanFactoryPostProcessor.java
deleted file mode 100644
index a4aefa7c8..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/config/CustomBeanFactoryPostProcessor.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package org.dromara.common.dubbo.config;
-
-import org.apache.dubbo.common.constants.CommonConstants;
-import org.dromara.common.core.utils.StringUtils;
-import org.springframework.beans.BeansException;
-import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
-import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
-import org.springframework.boot.context.properties.bind.Binder;
-import org.springframework.cloud.commons.util.InetUtils;
-import org.springframework.cloud.commons.util.InetUtilsProperties;
-import org.springframework.context.EnvironmentAware;
-import org.springframework.core.Ordered;
-import org.springframework.core.env.Environment;
-
-import java.net.Inet6Address;
-import java.net.InetAddress;
-
-/**
- * dubbo自定义IP注入(避免IP不正确问题)
- *
- * @author Lion Li
- */
-public class CustomBeanFactoryPostProcessor implements BeanFactoryPostProcessor, Ordered, EnvironmentAware {
-
- private Environment environment;
-
- /**
- * 设置此组件运行的应用环境。
- * 由 Spring 容器回调注入。
- *
- * @param environment 当前应用环境对象
- */
- @Override
- public void setEnvironment(Environment environment) {
- this.environment = environment;
- }
-
- /**
- * 获取该 BeanFactoryPostProcessor 的顺序,确保它在容器初始化过程中具有最高优先级
- *
- * @return 优先级顺序值,越小优先级越高
- */
- @Override
- public int getOrder() {
- return Ordered.HIGHEST_PRECEDENCE;
- }
-
- /**
- * 在 Spring 容器初始化过程中对 Bean 工厂进行后置处理
- *
- * @param beanFactory 可配置的 Bean 工厂
- * @throws BeansException 如果在处理过程中发生错误
- */
- @Override
- public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
- String property = System.getProperty(CommonConstants.DubboProperty.DUBBO_IP_TO_REGISTRY);
- if (StringUtils.isNotBlank(property)) {
- return;
- }
- // 手动绑定 InetUtilsProperties,避免早期初始化导致配置未注入
- InetUtilsProperties properties = Binder.get(environment)
- .bind(InetUtilsProperties.PREFIX, InetUtilsProperties.class)
- .orElseGet(InetUtilsProperties::new);
-
- // 创建临时的 InetUtils 实例
- try (InetUtils inetUtils = new InetUtils(properties)) {
- String ip = "127.0.0.1";
- // 获取第一个非回环地址
- InetAddress address = inetUtils.findFirstNonLoopbackAddress();
- if (address != null) {
- if (address instanceof Inet6Address) {
- // 处理 IPv6 地址
- String ipv6AddressString = address.getHostAddress();
- if (ipv6AddressString.contains("%")) {
- // 去掉可能存在的范围 ID
- ipv6AddressString = ipv6AddressString.substring(0, ipv6AddressString.indexOf("%"));
- }
- ip = ipv6AddressString;
- } else {
- // 处理 IPv4 地址
- ip = inetUtils.findFirstNonLoopbackHostInfo().getIpAddress();
- }
- }
- // 设置系统属性 DUBBO_IP_TO_REGISTRY 为获取到的 IP 地址
- System.setProperty(CommonConstants.DubboProperty.DUBBO_IP_TO_REGISTRY, ip);
- }
- }
-}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/config/DubboConfiguration.java b/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/config/DubboConfiguration.java
deleted file mode 100644
index 4a87e22cc..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/config/DubboConfiguration.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package org.dromara.common.dubbo.config;
-
-import org.dromara.common.core.factory.YmlPropertySourceFactory;
-import org.dromara.common.dubbo.handler.DubboExceptionHandler;
-import org.dromara.common.dubbo.properties.DubboCustomProperties;
-import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
-import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.PropertySource;
-
-/**
- * dubbo 配置类
- */
-@AutoConfiguration
-@EnableConfigurationProperties(DubboCustomProperties.class)
-@PropertySource(value = "classpath:common-dubbo.yml", factory = YmlPropertySourceFactory.class)
-public class DubboConfiguration {
-
- /**
- * dubbo自定义IP注入(避免IP不正确问题)
- */
- @Bean
- public BeanFactoryPostProcessor customBeanFactoryPostProcessor() {
- return new CustomBeanFactoryPostProcessor();
- }
-
- /**
- * 异常处理器
- */
- @Bean
- public DubboExceptionHandler dubboExceptionHandler() {
- return new DubboExceptionHandler();
- }
-
-}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/filter/DubboRequestFilter.java b/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/filter/DubboRequestFilter.java
deleted file mode 100644
index 5e74c65d2..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/filter/DubboRequestFilter.java
+++ /dev/null
@@ -1,84 +0,0 @@
-package org.dromara.common.dubbo.filter;
-
-import lombok.extern.slf4j.Slf4j;
-import org.apache.dubbo.common.constants.CommonConstants;
-import org.apache.dubbo.common.extension.Activate;
-import org.apache.dubbo.rpc.*;
-import org.apache.dubbo.rpc.service.GenericService;
-import org.dromara.common.core.utils.SpringUtils;
-import org.dromara.common.dubbo.enumd.RequestLogEnum;
-import org.dromara.common.dubbo.properties.DubboCustomProperties;
-import org.dromara.common.json.utils.JsonUtils;
-
-/**
- * Dubbo 日志过滤器
- *
- * 该过滤器通过实现 Dubbo 的 Filter 接口,在服务调用前后记录日志信息
- * 可根据配置开关和日志级别输出不同详细程度的日志信息
- *
- * 激活条件:
- * - 在 Provider 和 Consumer 端都生效
- * - 执行顺序设置为最大值,确保在所有其他过滤器之后执行
- *
- * 使用 SpringUtils 获取配置信息,根据配置决定是否记录日志及日志详细程度
- *
- * 使用 Lombok 的 @Slf4j 注解简化日志记录
- *
- * @author Lion Li
- */
-@Slf4j
-@Activate(group = {CommonConstants.PROVIDER, CommonConstants.CONSUMER}, order = Integer.MAX_VALUE)
-public class DubboRequestFilter implements Filter {
-
- /**
- * Dubbo Filter 接口实现方法,处理服务调用逻辑并记录日志
- *
- * @param invoker Dubbo 服务调用者实例
- * @param invocation 调用的具体方法信息
- * @return 调用结果
- * @throws RpcException 如果调用过程中发生异常
- */
- @Override
- public Result invoke(Invoker> invoker, Invocation invocation) throws RpcException {
- DubboCustomProperties properties = SpringUtils.getBean(DubboCustomProperties.class);
- // 如果未开启请求日志记录,则直接执行服务调用并返回结果
- if (!properties.getRequestLog()) {
- return invoker.invoke(invocation);
- }
-
- // 判断是 Provider 还是 Consumer
- String client = CommonConstants.PROVIDER;
- if (RpcContext.getServiceContext().isConsumerSide()) {
- client = CommonConstants.CONSUMER;
- }
-
- // 构建基础日志信息
- String baselog = "Client[" + client + "],InterfaceName=[" + invocation.getInvoker().getInterface().getSimpleName() + "],MethodName=[" + invocation.getMethodName() + "]";
- // 根据日志级别输出不同详细程度的日志信息
- if (properties.getLogLevel() == RequestLogEnum.INFO) {
- log.info("DUBBO - 服务调用: {}", baselog);
- } else {
- log.info("DUBBO - 服务调用: {},Parameter={}", baselog, invocation.getArguments());
- }
-
- // 记录调用开始时间
- long startTime = System.currentTimeMillis();
- // 执行接口调用逻辑
- Result result = invoker.invoke(invocation);
- // 计算调用耗时
- long elapsed = System.currentTimeMillis() - startTime;
- // 如果发生异常且调用的不是泛化服务,则记录异常日志
- if (result.hasException() && !invoker.getInterface().equals(GenericService.class)) {
- log.error("DUBBO - 服务异常: {},Exception={}", baselog, result.getException());
- } else {
- // 根据日志级别输出服务响应信息
- if (properties.getLogLevel() == RequestLogEnum.INFO) {
- log.info("DUBBO - 服务响应: {},SpendTime=[{}ms]", baselog, elapsed);
- } else if (properties.getLogLevel() == RequestLogEnum.FULL) {
- log.info("DUBBO - 服务响应: {},SpendTime=[{}ms],Response={}", baselog, elapsed, JsonUtils.toJsonString(new Object[]{result.getValue()}));
- }
- }
- return result;
- }
-
-}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/handler/DubboExceptionHandler.java b/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/handler/DubboExceptionHandler.java
deleted file mode 100644
index 5a6149515..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/handler/DubboExceptionHandler.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.dromara.common.dubbo.handler;
-
-import lombok.extern.slf4j.Slf4j;
-import org.apache.dubbo.rpc.RpcException;
-import org.dromara.common.core.domain.R;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.RestControllerAdvice;
-
-/**
- * Dubbo异常处理器
- *
- * @author Lion Li
- */
-@Slf4j
-@RestControllerAdvice
-public class DubboExceptionHandler {
-
- /**
- * 主键或UNIQUE索引,数据重复异常
- */
- @ExceptionHandler(RpcException.class)
- public R handleDubboException(RpcException e) {
- log.error("RPC异常: {}", e.getMessage());
- return R.fail("RPC异常,请联系管理员确认");
- }
-
-}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/properties/DubboCustomProperties.java b/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/properties/DubboCustomProperties.java
deleted file mode 100644
index e0df2cd77..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/properties/DubboCustomProperties.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.dromara.common.dubbo.properties;
-
-import lombok.Data;
-import org.dromara.common.dubbo.enumd.RequestLogEnum;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.cloud.context.config.annotation.RefreshScope;
-
-/**
- * 自定义配置
- *
- * @author Lion Li
- */
-@Data
-@RefreshScope
-@ConfigurationProperties(prefix = "dubbo.custom")
-public class DubboCustomProperties {
-
- /**
- * 是否开启请求日志记录
- */
- private Boolean requestLog;
-
- /**
- * 日志级别
- */
- private RequestLogEnum logLevel;
-
-}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/resources/META-INF/dubbo/org.apache.dubbo.rpc.Filter b/ruoyi-common/ruoyi-common-dubbo/src/main/resources/META-INF/dubbo/org.apache.dubbo.rpc.Filter
deleted file mode 100644
index 6f766ab78..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/resources/META-INF/dubbo/org.apache.dubbo.rpc.Filter
+++ /dev/null
@@ -1 +0,0 @@
-dubboRequestFilter=org.dromara.common.dubbo.filter.DubboRequestFilter
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-dubbo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
deleted file mode 100644
index f60bd3aaf..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ /dev/null
@@ -1 +0,0 @@
-org.dromara.common.dubbo.config.DubboConfiguration
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/resources/common-dubbo.yml b/ruoyi-common/ruoyi-common-dubbo/src/main/resources/common-dubbo.yml
deleted file mode 100644
index ddf7f6980..000000000
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/resources/common-dubbo.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-# 内置配置 不允许修改 如需修改请在 nacos 上写相同配置覆盖
-dubbo:
- application:
- logger: slf4j
- # 元数据中心 local 本地 remote 远程 这里使用远程便于其他服务获取
- metadataType: remote
- # 可选值 interface、instance、all,默认是 all,即接口级地址、应用级地址都注册
- register-mode: instance
- service-discovery:
- # FORCE_INTERFACE,只消费接口级地址,如无地址则报错,单订阅 2.x 地址
- # APPLICATION_FIRST,智能决策接口级/应用级地址,双订阅
- # FORCE_APPLICATION,只消费应用级地址,如无地址则报错,单订阅 3.x 地址
- migration: FORCE_APPLICATION
- # 注册中心配置
- registry:
- address: nacos://${spring.cloud.nacos.server-addr}
- group: DUBBO_GROUP
- username: ${spring.cloud.nacos.username}
- password: ${spring.cloud.nacos.password}
- parameters:
- namespace: ${spring.profiles.active}
- metadata-report:
- address: redis://${spring.data.redis.host:localhost}:${spring.data.redis.port:6379}
- group: DUBBO_GROUP
- username: ${spring.data.redis.username:default}
- password: ${spring.data.redis.password}
- parameters:
- namespace: ${spring.profiles.active}
- database: ${spring.data.redis.database}
- timeout: ${spring.data.redis.timeout}
- # 消费者相关配置
- consumer:
- # 结果缓存(LRU算法)
- # 会有数据不一致问题 建议在注解局部开启
- cache: false
- # 支持校验注解
- validation: jvalidationNew
- # 调用重试 不包括第一次 0为不需要重试
- retries: 0
- # 初始化检查
- check: false
diff --git a/ruoyi-common/ruoyi-common-http/pom.xml b/ruoyi-common/ruoyi-common-http/pom.xml
new file mode 100644
index 000000000..a7789a572
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/pom.xml
@@ -0,0 +1,45 @@
+
+
+
+ org.dromara
+ ruoyi-common
+ ${revision}
+
+ 4.0.0
+
+ ruoyi-common-http
+
+
+ ruoyi-common-http 内部 HTTP 远程调用
+
+
+
+
+ org.dromara
+ ruoyi-common-core
+
+
+
+ org.dromara
+ ruoyi-common-json
+
+
+
+ org.dromara
+ ruoyi-common-satoken
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-loadbalancer
+
+
+
+ org.eclipse.jetty
+ jetty-client
+
+
+
+
diff --git a/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/annotation/RemoteServiceController.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/annotation/RemoteServiceController.java
new file mode 100644
index 000000000..c13dab0bd
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/annotation/RemoteServiceController.java
@@ -0,0 +1,25 @@
+package org.dromara.common.http.annotation;
+
+import org.springframework.core.annotation.AliasFor;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 内部 HTTP 服务控制器.
+ *
+ * @author Lion Li
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@RestController
+public @interface RemoteServiceController {
+
+ @AliasFor(annotation = RestController.class, attribute = "value")
+ String value() default "";
+}
diff --git a/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/config/RemoteHttpAutoConfiguration.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/config/RemoteHttpAutoConfiguration.java
new file mode 100644
index 000000000..de6d05170
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/config/RemoteHttpAutoConfiguration.java
@@ -0,0 +1,233 @@
+package org.dromara.common.http.config;
+
+import cn.dev33.satoken.same.SaSameUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.utils.ServletUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.core.annotation.RemoteHttpService;
+import org.dromara.common.http.annotation.RemoteServiceController;
+import org.dromara.common.http.log.aspect.RemoteHttpProviderLogAspect;
+import org.dromara.common.http.handler.RemoteHttpExceptionHandler;
+import org.dromara.common.http.properties.RemoteHttpProperties;
+import org.dromara.common.http.registrar.RemoteHttpServiceRegistrar;
+import org.dromara.common.http.support.RemoteHttpFallbackProxyPostProcessor;
+import org.dromara.common.http.log.support.LoggingHttpExchangeAdapter;
+import org.dromara.common.http.log.support.RemoteHttpLogSupport;
+import org.dromara.common.json.utils.JsonUtils;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.aop.framework.autoproxy.AutoProxyUtils;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Bean;
+import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.beans.factory.support.BeanDefinitionBuilder;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
+import org.springframework.util.StreamUtils;
+import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 内部 HTTP 远程调用配置.
+ *
+ * 这里把运行时几条链路接起来:
+ * 1. Consumer 发请求前透传认证头和 Seata XID
+ * 2. 远程非 2xx 响应统一转成 ServiceException
+ * 3. 打开请求日志时,为 consumer/provider 两侧挂日志能力
+ * 4. 远程代理失败时按接口声明触发 fallback
+ *
+ * @author Lion Li
+ */
+@Slf4j
+@AutoConfiguration
+@Import(RemoteHttpServiceRegistrar.class)
+@EnableConfigurationProperties(RemoteHttpProperties.class)
+public class RemoteHttpAutoConfiguration {
+
+ @Bean
+ public static BeanFactoryPostProcessor remoteHttpControllerProxyCompatibilityPostProcessor() {
+ return new RemoteHttpInfrastructurePostProcessor();
+ }
+
+ @Bean("remoteHttpHeaderInterceptor")
+ public ClientHttpRequestInterceptor remoteHttpHeaderInterceptor() {
+ return (request, body, execution) -> {
+ HttpHeaders headers = request.getHeaders();
+ HttpServletRequest currentRequest = ServletUtils.getRequest();
+ if (currentRequest != null) {
+ String authorization = currentRequest.getHeader(HttpHeaders.AUTHORIZATION);
+ if (StringUtils.isNotBlank(authorization)) {
+ headers.set(HttpHeaders.AUTHORIZATION, authorization);
+ }
+ }
+ try {
+ // 透传 same-token,保证服务间调用仍然走内网鉴权。
+ headers.set(SaSameUtil.SAME_TOKEN, SaSameUtil.getToken());
+ } catch (Exception ignored) {
+ }
+ relaySeataXid(headers);
+ return execution.execute(request, body);
+ };
+ }
+
+ @Bean
+ public RestClientHttpServiceGroupConfigurer remoteHttpServiceGroupConfigurer(
+ ClientHttpRequestInterceptor remoteHttpHeaderInterceptor,
+ RemoteHttpLogSupport remoteHttpLogSupport) {
+ return groups -> groups.forEachGroup((group, clientBuilder, proxyFactoryBuilder) -> {
+ clientBuilder.requestInterceptor(remoteHttpHeaderInterceptor)
+ // provider 侧远程接口异常会直接映射成非 2xx,这里只按 HTTP 状态处理即可。
+ .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
+ throwServiceException(response.getStatusCode().value(), response.getStatusText(), readResponseBody(response));
+ });
+ if (remoteHttpLogSupport.isEnabled()) {
+ // consumer 侧日志挂在 HttpExchangeAdapter 上,避免碰底层 body 重复读取问题。
+ proxyFactoryBuilder.exchangeAdapterDecorator(adapter -> new LoggingHttpExchangeAdapter(adapter, remoteHttpLogSupport));
+ }
+ });
+ }
+
+ @Bean
+ public RemoteHttpFallbackProxyPostProcessor remoteHttpFallbackProxyPostProcessor() {
+ return new RemoteHttpFallbackProxyPostProcessor();
+ }
+
+ @Bean
+ public RemoteHttpLogSupport remoteHttpLogSupport(RemoteHttpProperties properties) {
+ return new RemoteHttpLogSupport(properties);
+ }
+
+ @Bean
+ public RemoteHttpProviderLogAspect remoteHttpProviderLogAspect(RemoteHttpLogSupport remoteHttpLogSupport) {
+ return new RemoteHttpProviderLogAspect(remoteHttpLogSupport);
+ }
+
+ @Bean
+ public RemoteHttpExceptionHandler remoteHttpExceptionHandler() {
+ return new RemoteHttpExceptionHandler();
+ }
+
+ private void relaySeataXid(HttpHeaders headers) {
+ try {
+ // 通过反射做可选适配,未引入 Seata 时不强依赖该类。
+ Class> rootContextClass = Class.forName("org.apache.seata.core.context.RootContext");
+ String xid = (String) rootContextClass.getMethod("getXID").invoke(null);
+ if (StringUtils.isBlank(xid)) {
+ return;
+ }
+ String headerName = (String) rootContextClass.getField("KEY_XID").get(null);
+ headers.set(headerName, xid);
+ } catch (ClassNotFoundException ignored) {
+ } catch (Exception e) {
+ log.debug("relay seata xid failed", e);
+ }
+ }
+
+ private String readResponseBody(org.springframework.http.client.ClientHttpResponse response) {
+ try {
+ return StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ log.debug("read remote response body failed", e);
+ return null;
+ }
+ }
+
+ private void throwServiceException(int statusCode, String statusText, String responseBody) {
+ if (StringUtils.isNotBlank(responseBody) && JsonUtils.isJsonObject(responseBody)) {
+ try {
+ // 远程服务如果按 R 返回错误信息,优先还原成更友好的业务异常消息。
+ R> result = JsonUtils.parseObject(responseBody, R.class);
+ if (result != null && (result.getCode() == 0 || R.isSuccess(result))) {
+ return;
+ }
+ if (result != null && StringUtils.isNotBlank(result.getMsg())) {
+ throw new ServiceException(result.getMsg(), result.getCode());
+ }
+ } catch (ServiceException se) {
+ throw se;
+ } catch (RuntimeException e) {
+ log.debug("parse remote error body failed: {}", responseBody, e);
+ }
+ }
+ String message = StringUtils.firstNonBlank(responseBody, statusText, "远程服务调用失败");
+ throw new ServiceException(message, statusCode);
+ }
+
+ private static final class RemoteHttpInfrastructurePostProcessor implements BeanFactoryPostProcessor {
+
+ @Override
+ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
+ BeanDefinitionRegistry registry = beanFactory instanceof BeanDefinitionRegistry beanDefinitionRegistry
+ ? beanDefinitionRegistry : null;
+ ClassLoader beanClassLoader = beanFactory.getBeanClassLoader();
+ for (String beanName : beanFactory.getBeanDefinitionNames()) {
+ BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
+ preserveRemoteControllerTargetClass(beanDefinition);
+ registerFallbackBeanDefinition(registry, beanFactory, beanDefinition, beanClassLoader);
+ }
+ }
+
+ private void preserveRemoteControllerTargetClass(BeanDefinition beanDefinition) {
+ if (!(beanDefinition instanceof AnnotatedBeanDefinition annotatedBeanDefinition)) {
+ return;
+ }
+ if (!annotatedBeanDefinition.getMetadata().hasAnnotation(RemoteServiceController.class.getName())) {
+ return;
+ }
+ beanDefinition.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE);
+ }
+
+ private void registerFallbackBeanDefinition(BeanDefinitionRegistry registry,
+ ConfigurableListableBeanFactory beanFactory, BeanDefinition beanDefinition, ClassLoader beanClassLoader) {
+ if (registry == null) {
+ return;
+ }
+ Class> serviceInterface = resolveRemoteServiceInterface(beanDefinition, beanClassLoader);
+ if (serviceInterface == null) {
+ return;
+ }
+ RemoteHttpService remoteHttpService = serviceInterface.getAnnotation(RemoteHttpService.class);
+ if (remoteHttpService == null || remoteHttpService.fallback() == void.class) {
+ return;
+ }
+ Class> fallbackClass = remoteHttpService.fallback();
+ if (!serviceInterface.isAssignableFrom(fallbackClass)) {
+ throw new IllegalStateException("Fallback class must implement remote service interface: "
+ + fallbackClass.getName() + " -> " + serviceInterface.getName());
+ }
+ if (beanFactory.getBeanNamesForType(fallbackClass, false, false).length > 0) {
+ return;
+ }
+ BeanDefinition fallbackBeanDefinition = BeanDefinitionBuilder.genericBeanDefinition(fallbackClass)
+ .setLazyInit(true)
+ .getBeanDefinition();
+ // fallback 只给框架内部按具体类型获取使用,不参与业务侧按接口类型自动注入,
+ // 否则会和真正的远程代理一起成为 RemoteXxxService 的候选 Bean。
+ fallbackBeanDefinition.setAutowireCandidate(false);
+ fallbackBeanDefinition.setPrimary(false);
+ registry.registerBeanDefinition(fallbackClass.getName(), fallbackBeanDefinition);
+ }
+
+ private Class> resolveRemoteServiceInterface(BeanDefinition beanDefinition, ClassLoader beanClassLoader) {
+ String beanClassName = beanDefinition.getBeanClassName();
+ if (beanClassName == null || beanClassLoader == null) {
+ return null;
+ }
+ Class> beanClass = org.springframework.util.ClassUtils.resolveClassName(beanClassName, beanClassLoader);
+ if (!beanClass.isInterface() || !beanClass.isAnnotationPresent(RemoteHttpService.class)) {
+ return null;
+ }
+ return beanClass;
+ }
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/handler/RemoteHttpExceptionHandler.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/handler/RemoteHttpExceptionHandler.java
new file mode 100644
index 000000000..c60037081
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/handler/RemoteHttpExceptionHandler.java
@@ -0,0 +1,151 @@
+package org.dromara.common.http.handler;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.constant.HttpStatus;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.exception.ServiceException;
+import org.dromara.common.core.exception.base.BaseException;
+import org.dromara.common.core.utils.StreamUtils;
+import org.dromara.common.http.annotation.RemoteServiceController;
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingPathVariableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.HandlerMethodValidationException;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+/**
+ * 仅作用于内部远程 HTTP 接口的异常处理器.
+ *
+ * 远程接口与普通对外 API 分开处理:
+ * 1. provider 直接返回非 2xx HTTP 状态,consumer 只按状态码判错
+ * 2. 响应体仍保留 R.code / R.msg,方便把业务码继续透传回消费方
+ */
+@Slf4j
+@Order(org.springframework.core.Ordered.HIGHEST_PRECEDENCE)
+@RestControllerAdvice(annotations = RemoteServiceController.class)
+public class RemoteHttpExceptionHandler {
+
+ @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+ public ResponseEntity> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
+ HttpServletRequest request) {
+ log.error("请求地址'{}',不支持'{}'请求", request.getRequestURI(), e.getMethod());
+ return buildResponse(HttpStatus.BAD_METHOD, e.getMessage());
+ }
+
+ @ExceptionHandler(ServiceException.class)
+ public ResponseEntity> handleServiceException(ServiceException e) {
+ log.error(e.getMessage());
+ int code = resolveBusinessCode(e.getCode(), HttpStatus.ERROR);
+ return buildResponse(code, e.getMessage());
+ }
+
+ @ExceptionHandler(ServletException.class)
+ public ResponseEntity> handleServletException(ServletException e, HttpServletRequest request) {
+ log.error("请求地址'{}',发生未知异常.", request.getRequestURI(), e);
+ return buildResponse(HttpStatus.ERROR, e.getMessage());
+ }
+
+ @ExceptionHandler(BaseException.class)
+ public ResponseEntity> handleBaseException(BaseException e) {
+ log.error(e.getMessage());
+ return buildResponse(HttpStatus.ERROR, e.getMessage());
+ }
+
+ @ExceptionHandler(MissingPathVariableException.class)
+ public ResponseEntity> handleMissingPathVariableException(MissingPathVariableException e, HttpServletRequest request) {
+ log.error("请求路径中缺少必需的路径变量'{}',发生系统异常.", request.getRequestURI());
+ return buildResponse(HttpStatus.BAD_REQUEST, String.format("请求路径中缺少必需的路径变量[%s]", e.getVariableName()));
+ }
+
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ public ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e,
+ HttpServletRequest request) {
+ log.error("请求参数类型不匹配'{}',发生系统异常.", request.getRequestURI());
+ String message = String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'",
+ e.getName(), e.getRequiredType().getName(), e.getValue());
+ return buildResponse(HttpStatus.BAD_REQUEST, message);
+ }
+
+ @ExceptionHandler(NoHandlerFoundException.class)
+ public ResponseEntity> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
+ log.error("请求地址'{}'不存在.", request.getRequestURI());
+ return buildResponse(HttpStatus.NOT_FOUND, e.getMessage());
+ }
+
+ @ExceptionHandler(BindException.class)
+ public ResponseEntity> handleBindException(BindException e) {
+ log.error(e.getMessage());
+ String message = StreamUtils.join(e.getAllErrors(), DefaultMessageSourceResolvable::getDefaultMessage, ", ");
+ return buildResponse(HttpStatus.BAD_REQUEST, message);
+ }
+
+ @ExceptionHandler(ConstraintViolationException.class)
+ public ResponseEntity> constraintViolationException(ConstraintViolationException e) {
+ log.error(e.getMessage());
+ String message = StreamUtils.join(e.getConstraintViolations(), ConstraintViolation::getMessage, ", ");
+ return buildResponse(HttpStatus.BAD_REQUEST, message);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ log.error(e.getMessage());
+ String message = StreamUtils.join(e.getBindingResult().getAllErrors(), DefaultMessageSourceResolvable::getDefaultMessage, ", ");
+ return buildResponse(HttpStatus.BAD_REQUEST, message);
+ }
+
+ @ExceptionHandler(HandlerMethodValidationException.class)
+ public ResponseEntity> handlerMethodValidationException(HandlerMethodValidationException e) {
+ log.error(e.getMessage());
+ String message = StreamUtils.join(e.getAllErrors(), MessageSourceResolvable::getDefaultMessage, ", ");
+ return buildResponse(HttpStatus.BAD_REQUEST, message);
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e,
+ HttpServletRequest request) {
+ log.error("请求地址'{}', 参数解析失败: {}", request.getRequestURI(), e.getMessage());
+ return buildResponse(HttpStatus.BAD_REQUEST, "请求参数格式错误:" + e.getMostSpecificCause().getMessage());
+ }
+
+ @ExceptionHandler(RuntimeException.class)
+ public ResponseEntity> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
+ log.error("请求地址'{}',发生未知异常.", request.getRequestURI(), e);
+ return buildResponse(HttpStatus.ERROR, e.getMessage());
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleException(Exception e, HttpServletRequest request) {
+ log.error("请求地址'{}',发生系统异常.", request.getRequestURI(), e);
+ return buildResponse(HttpStatus.ERROR, e.getMessage());
+ }
+
+ private ResponseEntity> buildResponse(int code, String message) {
+ return ResponseEntity.status(resolveHttpStatus(code))
+ .body(R.fail(code, message));
+ }
+
+ private HttpStatusCode resolveHttpStatus(int code) {
+ if (code >= 100 && code <= 599) {
+ return HttpStatusCode.valueOf(code);
+ }
+ return HttpStatusCode.valueOf(HttpStatus.ERROR);
+ }
+
+ private int resolveBusinessCode(Integer code, int defaultCode) {
+ return code == null ? defaultCode : code;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/aspect/RemoteHttpProviderLogAspect.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/aspect/RemoteHttpProviderLogAspect.java
new file mode 100644
index 000000000..1ca4264d2
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/aspect/RemoteHttpProviderLogAspect.java
@@ -0,0 +1,162 @@
+package org.dromara.common.http.log.aspect;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.dromara.common.core.utils.ServletUtils;
+import org.dromara.common.core.annotation.RemoteHttpService;
+import org.dromara.common.http.log.support.RemoteHttpLogSupport;
+import org.springframework.http.HttpMethod;
+import org.springframework.web.service.annotation.HttpExchange;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.util.StringUtils;
+
+import java.lang.reflect.Method;
+
+/**
+ * 内部 HTTP Provider 日志切面.
+ *
+ * Provider 侧日志不直接读原始请求 body,而是等 Spring 完成参数绑定后
+ * 直接记录方法入参/返回值,这样可以避免 servlet body 重复读取。
+ *
+ * @author Lion Li
+ */
+@Aspect
+@RequiredArgsConstructor
+public class RemoteHttpProviderLogAspect {
+
+ private final RemoteHttpLogSupport logSupport;
+
+ @Around("@within(org.dromara.common.http.annotation.RemoteServiceController) && execution(public * *(..))")
+ public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ Method method = signature.getMethod();
+ Class> targetClass = AopUtils.getTargetClass(joinPoint.getTarget());
+ Object[] arguments = joinPoint.getArgs();
+ HttpServletRequest request = ServletUtils.getRequest();
+ Class> remoteInterface = resolveRemoteInterface(targetClass, method);
+ // 真实 HTTP 调用时优先从 servlet 请求拿 method/path;
+ // 本地短路调用时再回退到接口上的 @HttpExchange 注解。
+ HttpMethod httpMethod = resolveHttpMethod(request, remoteInterface, method);
+ String path = resolvePath(request, remoteInterface, method);
+ this.logSupport.logRequest(RemoteHttpLogSupport.PROVIDER, httpMethod, path, arguments);
+ long startTime = System.currentTimeMillis();
+ try {
+ Object result = joinPoint.proceed();
+ this.logSupport.logResponse(RemoteHttpLogSupport.PROVIDER, httpMethod, path, System.currentTimeMillis() - startTime, result);
+ return result;
+ } catch (Throwable ex) {
+ this.logSupport.logException(RemoteHttpLogSupport.PROVIDER, httpMethod, path, System.currentTimeMillis() - startTime, ex);
+ throw ex;
+ }
+ }
+
+ private HttpMethod resolveHttpMethod(HttpServletRequest request, Class> remoteInterface, Method method) {
+ if (request != null && StringUtils.hasText(request.getMethod())) {
+ return HttpMethod.valueOf(request.getMethod());
+ }
+ HttpExchange methodExchange = resolveMethodExchange(remoteInterface, method);
+ if (methodExchange != null && StringUtils.hasText(methodExchange.method())) {
+ return HttpMethod.valueOf(methodExchange.method());
+ }
+ HttpExchange typeExchange = resolveTypeExchange(remoteInterface);
+ if (typeExchange != null && StringUtils.hasText(typeExchange.method())) {
+ return HttpMethod.valueOf(typeExchange.method());
+ }
+ return null;
+ }
+
+ private String resolvePath(HttpServletRequest request, Class> remoteInterface, Method method) {
+ if (request != null) {
+ String requestUri = request.getRequestURI();
+ if (StringUtils.hasText(requestUri)) {
+ String queryString = request.getQueryString();
+ if (!StringUtils.hasText(queryString)) {
+ return requestUri;
+ }
+ return requestUri + '?' + queryString;
+ }
+ }
+ String typePath = extractPath(resolveTypeExchange(remoteInterface));
+ String methodPath = extractPath(resolveMethodExchange(remoteInterface, method));
+ if (!StringUtils.hasText(typePath)) {
+ return methodPath;
+ }
+ if (!StringUtils.hasText(methodPath)) {
+ return typePath;
+ }
+ // 拼出接口级 + 方法级路径,作为本地短路场景下的日志定位信息。
+ return combinePath(typePath, methodPath);
+ }
+
+ private Class> resolveRemoteInterface(Class> targetClass, Method method) {
+ for (Class> interfaceType : targetClass.getInterfaces()) {
+ if (interfaceType.isAnnotationPresent(RemoteHttpService.class)
+ && org.springframework.util.ReflectionUtils.findMethod(interfaceType, method.getName(), method.getParameterTypes()) != null) {
+ return interfaceType;
+ }
+ }
+ return null;
+ }
+
+ private HttpExchange resolveTypeExchange(Class> remoteInterface) {
+ if (remoteInterface == null) {
+ return null;
+ }
+ return AnnotatedElementUtils.findMergedAnnotation(remoteInterface, HttpExchange.class);
+ }
+
+ private HttpExchange resolveMethodExchange(Class> remoteInterface, Method method) {
+ if (remoteInterface == null) {
+ return null;
+ }
+ Method interfaceMethod = org.springframework.util.ReflectionUtils.findMethod(remoteInterface, method.getName(), method.getParameterTypes());
+ if (interfaceMethod == null) {
+ return null;
+ }
+ return AnnotatedElementUtils.findMergedAnnotation(interfaceMethod, HttpExchange.class);
+ }
+
+ private String extractPath(HttpExchange exchange) {
+ if (exchange == null) {
+ return null;
+ }
+ if (StringUtils.hasText(exchange.url())) {
+ return exchange.url();
+ }
+ if (StringUtils.hasText(exchange.value())) {
+ return exchange.value();
+ }
+ return null;
+ }
+
+ private String combinePath(String typePath, String methodPath) {
+ String normalizedTypePath = trimTrailingSlash(typePath);
+ String normalizedMethodPath = trimLeadingSlash(methodPath);
+ if (!StringUtils.hasText(normalizedTypePath)) {
+ return '/' + normalizedMethodPath;
+ }
+ if (!StringUtils.hasText(normalizedMethodPath)) {
+ return normalizedTypePath;
+ }
+ return normalizedTypePath + '/' + normalizedMethodPath;
+ }
+
+ private String trimTrailingSlash(String path) {
+ if (!StringUtils.hasText(path)) {
+ return path;
+ }
+ return path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
+ }
+
+ private String trimLeadingSlash(String path) {
+ if (!StringUtils.hasText(path)) {
+ return path;
+ }
+ return path.startsWith("/") ? path.substring(1) : path;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/enumd/RequestLogEnum.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/enums/RequestLogEnum.java
similarity index 57%
rename from ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/enumd/RequestLogEnum.java
rename to ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/enums/RequestLogEnum.java
index 950114e7e..b8d93b5d8 100644
--- a/ruoyi-common/ruoyi-common-dubbo/src/main/java/org/dromara/common/dubbo/enumd/RequestLogEnum.java
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/enums/RequestLogEnum.java
@@ -1,9 +1,9 @@
-package org.dromara.common.dubbo.enumd;
+package org.dromara.common.http.log.enums;
import lombok.AllArgsConstructor;
/**
- * 请求日志泛型
+ * 请求日志级别.
*
* @author Lion Li
*/
@@ -11,18 +11,18 @@ import lombok.AllArgsConstructor;
public enum RequestLogEnum {
/**
- * info 基础信息
+ * 基础信息.
*/
INFO,
/**
- * param 参数信息
+ * 参数信息.
*/
PARAM,
/**
- * full 全部
+ * 全量信息.
*/
- FULL;
+ FULL
}
diff --git a/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/support/LoggingHttpExchangeAdapter.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/support/LoggingHttpExchangeAdapter.java
new file mode 100644
index 000000000..87ed90360
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/support/LoggingHttpExchangeAdapter.java
@@ -0,0 +1,114 @@
+package org.dromara.common.http.log.support;
+
+import org.dromara.common.core.exception.ServiceException;
+import org.jspecify.annotations.Nullable;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.StringUtils;
+import org.springframework.web.service.invoker.HttpExchangeAdapter;
+import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator;
+import org.springframework.web.service.invoker.HttpRequestValues;
+
+import java.net.URI;
+import java.util.Map;
+
+/**
+ * 内部 HTTP Consumer 日志装饰器.
+ *
+ * Consumer 侧日志挂在 HttpServiceProxyFactory 的 exchange adapter 上,
+ * 这样可以直接拿到最终请求 method/path 和解码后的返回值,
+ * 比直接拦截底层流更稳定,也更容易规避 body 重复读问题。
+ *
+ * @author Lion Li
+ */
+public class LoggingHttpExchangeAdapter extends HttpExchangeAdapterDecorator {
+
+ private final RemoteHttpLogSupport logSupport;
+
+ public LoggingHttpExchangeAdapter(HttpExchangeAdapter delegate, RemoteHttpLogSupport logSupport) {
+ super(delegate);
+ this.logSupport = logSupport;
+ }
+
+ @Override
+ public void exchange(HttpRequestValues requestValues) {
+ invoke(requestValues, () -> {
+ super.exchange(requestValues);
+ return null;
+ });
+ }
+
+ @Override
+ public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) {
+ return invoke(requestValues, () -> super.exchangeForHeaders(requestValues));
+ }
+
+ @Override
+ public @Nullable T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) {
+ return invoke(requestValues, () -> super.exchangeForBody(requestValues, bodyType));
+ }
+
+ @Override
+ public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) {
+ return invoke(requestValues, () -> super.exchangeForBodilessEntity(requestValues));
+ }
+
+ @Override
+ public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) {
+ return invoke(requestValues, () -> super.exchangeForEntity(requestValues, bodyType));
+ }
+
+ private T invoke(HttpRequestValues requestValues, ThrowingSupplier supplier) {
+ HttpMethod httpMethod = requestValues.getHttpMethod();
+ String path = resolvePath(requestValues);
+ Object bodyValue = requestValues.getBodyValue();
+ Object[] arguments = bodyValue == null ? new Object[0] : bodyValue instanceof Object[] array ? array : new Object[] {bodyValue};
+ this.logSupport.logRequest(RemoteHttpLogSupport.CONSUMER, httpMethod, path, arguments);
+ long startTime = System.currentTimeMillis();
+ try {
+ T result = supplier.get();
+ this.logSupport.logResponse(RemoteHttpLogSupport.CONSUMER, httpMethod, path,
+ System.currentTimeMillis() - startTime, result);
+ return result;
+ } catch (Throwable ex) {
+ this.logSupport.logException(RemoteHttpLogSupport.CONSUMER, httpMethod, path,
+ System.currentTimeMillis() - startTime, ex);
+ switch (ex) {
+ case ServiceException serviceException -> throw serviceException;
+ case RuntimeException runtimeException -> throw runtimeException;
+ case Error error -> throw error;
+ default -> {
+ }
+ }
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private String resolvePath(HttpRequestValues requestValues) {
+ URI uri = requestValues.getUri();
+ if (uri != null) {
+ // 能拿到最终 URI 时优先打印最终请求地址,便于线上排查。
+ return uri.toString();
+ }
+ String uriTemplate = requestValues.getUriTemplate();
+ if (!StringUtils.hasText(uriTemplate)) {
+ return null;
+ }
+ Map uriVariables = requestValues.getUriVariables();
+ String path = uriTemplate;
+ if (uriVariables != null) {
+ for (Map.Entry entry : uriVariables.entrySet()) {
+ path = path.replace("{" + entry.getKey() + "}", String.valueOf(entry.getValue()));
+ }
+ }
+ return path;
+ }
+
+ @FunctionalInterface
+ private interface ThrowingSupplier {
+
+ T get() throws Throwable;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/support/RemoteHttpLogSupport.java b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/support/RemoteHttpLogSupport.java
new file mode 100644
index 000000000..e9cdd7a4f
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-http/src/main/java/org/dromara/common/http/log/support/RemoteHttpLogSupport.java
@@ -0,0 +1,132 @@
+package org.dromara.common.http.log.support;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.http.log.enums.RequestLogEnum;
+import org.dromara.common.http.properties.RemoteHttpProperties;
+import org.dromara.common.json.utils.JsonUtils;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 内部 HTTP 日志支持.
+ *
+ * 这里只做两件事:
+ * 1. 统一 consumer/provider 的日志格式
+ * 2. 对 byte[] 等内容做简单脱敏,避免日志直接刷大块二进制
+ *
+ * @author Lion Li
+ */
+@Slf4j
+@RequiredArgsConstructor
+public class RemoteHttpLogSupport {
+
+ public static final String CONSUMER = "CONSUMER";
+ public static final String PROVIDER = "PROVIDER";
+
+ private final RemoteHttpProperties properties;
+
+ public boolean isEnabled() {
+ return Boolean.TRUE.equals(properties.getRequestLog());
+ }
+
+ public boolean isFullLogEnabled() {
+ return properties.getLogLevel() == RequestLogEnum.FULL;
+ }
+
+ public void logRequest(String client, HttpMethod httpMethod, String path, Object[] arguments) {
+ if (!isEnabled()) {
+ return;
+ }
+ String baseLog = buildBaseLog(client, httpMethod, path);
+ if (properties.getLogLevel() == RequestLogEnum.INFO) {
+ log.info("HTTP - 服务调用: {}", baseLog);
+ return;
+ }
+ log.info("HTTP - 服务调用: {},Parameter={}", baseLog, formatArguments(arguments));
+ }
+
+ public void logResponse(String client, HttpMethod httpMethod, String path, long elapsed, Object response) {
+ if (!isEnabled()) {
+ return;
+ }
+ String baseLog = buildBaseLog(client, httpMethod, path);
+ if (properties.getLogLevel() == RequestLogEnum.FULL) {
+ log.info("HTTP - 服务响应: {},SpendTime=[{}ms],Response={}", baseLog, elapsed, formatValue(unwrapResponse(response)));
+ return;
+ }
+ log.info("HTTP - 服务响应: {},SpendTime=[{}ms]", baseLog, elapsed);
+ }
+
+ public void logException(String client, HttpMethod httpMethod, String path, long elapsed, Throwable throwable) {
+ if (!isEnabled()) {
+ return;
+ }
+ String baseLog = buildBaseLog(client, httpMethod, path);
+ log.error("HTTP - 服务异常: {},SpendTime=[{}ms],Exception={}", baseLog, elapsed, throwable.getMessage(), throwable);
+ }
+
+ private String buildBaseLog(String client, HttpMethod httpMethod, String path) {
+ return "Client[" + client + ']' +
+ ",HttpMethod[" +
+ (httpMethod != null ? httpMethod : "UNKNOWN") +
+ ']' +
+ ",Path[" +
+ (StringUtils.hasText(path) ? path : "UNKNOWN") +
+ ']';
+ }
+
+ private String formatArguments(Object[] arguments) {
+ return formatValue(arguments == null ? new Object[0] : arguments);
+ }
+
+ private Object unwrapResponse(Object response) {
+ if (response instanceof ResponseEntity> responseEntity) {
+ return responseEntity.getBody();
+ }
+ return response;
+ }
+
+ private String formatValue(Object value) {
+ try {
+ return JsonUtils.toJsonString(sanitizeValue(value));
+ } catch (RuntimeException ignored) {
+ return String.valueOf(value);
+ }
+ }
+
+ private Object sanitizeValue(Object value) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof byte[] bytes) {
+ // 文件上传这类场景只记录长度,避免二进制内容直接进日志。
+ return "byte[" + bytes.length + "]";
+ }
+ if (value instanceof Object[] array) {
+ Object[] sanitized = new Object[array.length];
+ for (int i = 0; i < array.length; i++) {
+ sanitized[i] = sanitizeValue(array[i]);
+ }
+ return sanitized;
+ }
+ if (value instanceof Collection> collection) {
+ return collection.stream().map(this::sanitizeValue).toList();
+ }
+ if (value instanceof Map, ?> map) {
+ Map