feat(channel): 主应用与通道子应用 HTTP 报文 AES-GCM 透明加密

强制常开传输加密,按 one/two 分别配置密钥;出站 Interceptor 加解密,异常文案走 i18n。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-23 11:42:45 +08:00
parent 32fc52dd0a
commit d5f132aa85
31 changed files with 618 additions and 240 deletions

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.adapay.config;
import cn.daxpay.open.channel.adapay.client.AdapayChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # Adapay 通道客户端配置
/// # 汇付天下 Adapay 通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class AdapayClientConfig {
@Bean
public AdapayChannelClient adapayChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(AdapayChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), AdapayChannelClient.class);
}
}

View File

@@ -2,33 +2,23 @@ package cn.daxpay.open.channel.alipay.config;
import cn.daxpay.open.channel.alipay.client.AlipayChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 支付宝通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-one baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-one baseUrl 并强制挂载传输加密。
@Configuration
public class AlipayClientConfig {
@Bean
public AlipayChannelClient alipayChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getOne().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(AlipayChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getOne(), AlipayChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.dougong.config;
import cn.daxpay.open.channel.dougong.client.DougongChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 斗拱通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取(斗拱部署在 channel-two 子应用)。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class DougongClientConfig {
@Bean
public DougongChannelClient dougongChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(DougongChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), DougongChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.douyin.config;
import cn.daxpay.open.channel.douyin.client.DouyinChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 抖音通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-one baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-one baseUrl 并强制挂载传输加密。
@Configuration
public class DouyinClientConfig {
@Bean
public DouyinChannelClient douyinChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getOne().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(DouyinChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getOne(), DouyinChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.fuyou.config;
import cn.daxpay.open.channel.fuyou.client.FuyouChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 富友通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class FuyouClientConfig {
@Bean
public FuyouChannelClient fuyouChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(FuyouChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), FuyouChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.hkrt.config;
import cn.daxpay.open.channel.hkrt.client.HkrtChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 海科融通通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class HkrtClientConfig {
@Bean
public HkrtChannelClient hkrtChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(HkrtChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), HkrtChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.hmpay.config;
import cn.daxpay.open.channel.hmpay.client.HmpayChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 河马付通道客户端配置
/// # 河马付通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取(河马付部署在 channel-two 子应用)。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class HmpayClientConfig {
@Bean
public HmpayChannelClient hmpayChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(HmpayChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), HmpayChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.lakala.config;
import cn.daxpay.open.channel.lakala.client.LakalaChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 拉卡拉通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class LakalaClientConfig {
@Bean
public LakalaChannelClient lakalaChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(LakalaChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), LakalaChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.leshua.config;
import cn.daxpay.open.channel.leshua.client.LeshuaChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 乐刷通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class LeshuaClientConfig {
@Bean
public LeshuaChannelClient leshuaChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(LeshuaChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), LeshuaChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.ums.config;
import cn.daxpay.open.channel.ums.client.UmsChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 银联商务通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-one baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-one baseUrl 并强制挂载传输加密。
@Configuration
public class UmsClientConfig {
@Bean
public UmsChannelClient umsChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getOne().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(UmsChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getOne(), UmsChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.vbill.config;
import cn.daxpay.open.channel.vbill.client.VbillChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 随行付通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class VbillClientConfig {
@Bean
public VbillChannelClient vbillChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(VbillChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), VbillChannelClient.class);
}
}

View File

@@ -2,33 +2,23 @@ package cn.daxpay.open.channel.wechat.config;
import cn.daxpay.open.channel.wechat.client.WechatChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 微信通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-one baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-one baseUrl 并强制挂载传输加密。
@Configuration
public class WechatClientConfig {
@Bean
public WechatChannelClient wechatChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getOne().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(WechatChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getOne(), WechatChannelClient.class);
}
}

View File

@@ -2,34 +2,23 @@ package cn.daxpay.open.channel.yeepay.config;
import cn.daxpay.open.channel.yeepay.client.YeepayChannelClient;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.common.spring.channel.ChannelRestClientSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 易宝通道客户端配置
///
/// 复用全局 [RestClient](由 common-spring 的 RestClientConfiguration 提供, 已预装:
/// OTel traceparent 透传 / BusinessContextInterceptor 业务上下文透传 / Apache HttpClient5 连接池),
/// 经 `mutate()` 派生出绑定 channel-two baseUrl 的实例。
///
/// baseUrl 从 [DaxpayChannelProperties] 统一读取(易宝部署在子应用二)。
/// 复用全局 RestClient,经 [ChannelRestClientSupport] 绑定 channel-two baseUrl 并强制挂载传输加密。
@Configuration
public class YeepayClientConfig {
@Bean
public YeepayChannelClient yeepayChannelClient(
RestClient restClient,
DaxpayChannelProperties channelProperties) {
// mutate 派生: 继承全局 RestClient 的拦截器 / observation / requestFactory, 仅覆盖 baseUrl
RestClient channelClient = restClient
.mutate()
.baseUrl(channelProperties.getTwo().getBaseUrl())
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(YeepayChannelClient.class);
DaxpayChannelProperties channelProperties,
ChannelRestClientSupport channelRestClientSupport) {
return channelRestClientSupport.createClient(
restClient, channelProperties.getTwo(), YeepayChannelClient.class);
}
}

View File

@@ -0,0 +1,121 @@
package cn.daxpay.open.platform.common.config.encrypt;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizErrorException;
import cn.daxpay.open.platform.core.exception.BizException;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/// # 通道传输 AES-256-GCM 加密器(单密钥、无版本前缀)
///
/// 用于主应用 ↔ 通道子应用 HTTP 报文透明加解密。
/// 与存储侧 [SecureAesGcmEncryptor] 算法参数一致,但密文不落库、不分版本:
/// `Base64(IV(12) || ciphertext+tag)`。
///
/// 加解密失败抛 [BizException]i18n messageKey避免传输层静默得到 null 后被 Jackson 误解析。
public class ChannelAesGcmEncryptor {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
/// 密钥须为恰好 32 个 UTF-8 字符AES-256
public static final int KEY_LENGTH = 32;
/// 通道传输加密密钥长度非法
public static final String MSG_KEY_INVALID = "channel.error.transportEncrypt.keyInvalid";
/// 通道传输加密失败
public static final String MSG_ENCRYPT_FAILED = "channel.error.transportEncrypt.encryptFailed";
/// 通道传输密文长度非法
public static final String MSG_CIPHERTEXT_INVALID = "channel.error.transportEncrypt.ciphertextInvalid";
/// 通道传输解密失败
public static final String MSG_DECRYPT_FAILED = "channel.error.transportEncrypt.decryptFailed";
private final SecretKey secretKey;
/// @param key AES-256 密钥,恰好 32 字符
public ChannelAesGcmEncryptor(String key) {
validateKey(key);
this.secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
}
/// 校验密钥长度
public static void validateKey(String key) {
if (key == null || key.length() != KEY_LENGTH) {
// 通道传输加密密钥长度非法
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, MSG_KEY_INVALID, KEY_LENGTH);
}
}
/// 加密明文
/// @param plaintext UTF-8 明文(通常为 JSON
/// @return Base64(IV || ciphertext+tag)
public String encrypt(String plaintext) {
if (plaintext == null) {
return null;
}
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
// 通道传输加密失败
BizErrorException ex = new BizErrorException(CommonErrorCode.SYSTEM_ERROR, MSG_ENCRYPT_FAILED);
ex.initCause(e);
throw ex;
}
}
/// 解密密文
/// @param ciphertext Base64(IV || ciphertext+tag)
/// @return UTF-8 明文
public String decrypt(String ciphertext) {
if (ciphertext == null) {
return null;
}
try {
byte[] combined = Base64.getDecoder().decode(ciphertext);
if (combined.length <= GCM_IV_LENGTH) {
// 通道传输密文长度非法
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, MSG_CIPHERTEXT_INVALID);
}
byte[] iv = new byte[GCM_IV_LENGTH];
byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, iv.length);
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (BizException e) {
throw e;
} catch (Exception e) {
// 通道传输解密失败
BizErrorException ex = new BizErrorException(CommonErrorCode.SYSTEM_ERROR, MSG_DECRYPT_FAILED);
ex.initCause(e);
throw ex;
}
}
}

View File

@@ -6,8 +6,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
/// # 通道适配子应用配置
///
/// 统一管理各通道适配子应用(dax-pay-channel-one/two/...)的连接地址。
/// 各通道模块通过注入本类获取对应子应用的 baseUrl。
/// 统一管理各通道适配子应用(dax-pay-channel-one/two/...)的连接地址与传输加密密钥
/// 各通道模块通过注入本类获取对应子应用的 baseUrl 与 transportEncrypt
@Data
@Accessors(chain = true)
@ConfigurationProperties(prefix = "daxpay.channel")
@@ -29,5 +29,19 @@ public class DaxpayChannelProperties {
/// 基础地址
private String baseUrl = "http://127.0.0.1:20100";
/// 传输加密强制常开key 必填)
private TransportEncrypt transportEncrypt = new TransportEncrypt();
}
/// # 通道 HTTP 报文传输加密配置
///
/// 与 `daxpay.platform.config.encrypt`DB/缓存存储加密)密钥隔离。
@Data
@Accessors(chain = true)
public static class TransportEncrypt {
/// AES-256 密钥,恰好 32 个 UTF-8 字符;创建通道 Client 时强校验
private String key;
}
}

View File

@@ -0,0 +1,85 @@
package cn.daxpay.open.platform.common.config.encrypt;
import cn.daxpay.open.platform.core.exception.BizErrorException;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.security.SecureRandom;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
/// # 通道传输 AES-256-GCM 加密器测试
class ChannelAesGcmEncryptorTest {
private ChannelAesGcmEncryptor encryptor;
@BeforeEach
void initEncryptor() {
encryptor = new ChannelAesGcmEncryptor(generateKey(32));
}
@Test
@DisplayName("密钥长度非法应抛业务异常")
void shouldRejectInvalidKeyLength() {
assertThrows(BizInfoException.class, () -> new ChannelAesGcmEncryptor(null));
assertThrows(BizInfoException.class, () -> new ChannelAesGcmEncryptor("short"));
assertThrows(BizInfoException.class, () -> new ChannelAesGcmEncryptor(generateKey(31)));
}
@Test
@DisplayName("加密和解密往返一致")
void shouldEncryptAndDecrypt() {
String plaintext = "这是一段需要加密的敏感数据,包含中文和特殊字符!@#$%";
String encrypted = encryptor.encrypt(plaintext);
String decrypted = encryptor.decrypt(encrypted);
assertEquals(plaintext, decrypted);
assertNotEquals(plaintext, encrypted);
// 无版本前缀
assertFalse(encrypted.startsWith("v"));
}
@Test
@DisplayName("空字符串加密测试")
void shouldEncryptAndDecryptEmptyString() {
String encrypted = encryptor.encrypt("");
assertEquals("", encryptor.decrypt(encrypted));
}
@Test
@DisplayName("长文本加密测试")
void shouldEncryptAndDecryptLongText() {
String plaintext = "这是一段很长的测试数据。".repeat(100);
assertEquals(plaintext, encryptor.decrypt(encryptor.encrypt(plaintext)));
}
@Test
@DisplayName("同一明文两次密文不同(随机 IV")
void shouldProduceDifferentCiphertext() {
String plaintext = "{\"amount\":100}";
String a = encryptor.encrypt(plaintext);
String b = encryptor.encrypt(plaintext);
assertNotEquals(a, b);
assertEquals(plaintext, encryptor.decrypt(a));
assertEquals(plaintext, encryptor.decrypt(b));
}
@Test
@DisplayName("错误密钥解密应失败")
void shouldFailWithWrongKey() {
String encrypted = encryptor.encrypt("{\"ok\":true}");
ChannelAesGcmEncryptor other = new ChannelAesGcmEncryptor(generateKey(32));
assertThrows(BizErrorException.class, () -> other.decrypt(encrypted));
}
/// 生成指定长度的随机密钥字符串
private static String generateKey(int length) {
byte[] keyBytes = new byte[length];
new SecureRandom().nextBytes(keyBytes);
return Base64.getEncoder().encodeToString(keyBytes).substring(0, length);
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "Callback signature verification failed",
"cacheConcurrentConflict": "Concurrent conflict",
"systemError": "Internal system error",
"validateParams": "Parameter validation failed"
"validateParams": "Parameter validation failed",
"transportEncrypt": {
"appConfigMissing": "Channel app config is missing: {0}",
"configMissing": "Channel transport encrypt config is missing: {0}",
"keyInvalid": "Channel transport encrypt key must be {0} characters",
"encryptFailed": "Channel transport encrypt failed",
"ciphertextInvalid": "Channel transport ciphertext length is invalid",
"decryptFailed": "Channel transport decrypt failed",
"responseHeaderMissing": "Channel response missing transport encrypt header {0}",
"requestHeaderMissing": "Channel transport encrypt: request missing encrypt header"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "Verifikasi tanda tangan panggilan balik gagal",
"cacheConcurrentConflict": "Konflik yang terjadi bersamaan",
"systemError": "Kesalahan sistem internal",
"validateParams": "Validasi parameter gagal"
"validateParams": "Validasi parameter gagal",
"transportEncrypt": {
"appConfigMissing": "Konfigurasi aplikasi saluran kosong: {0}",
"configMissing": "Konfigurasi enkripsi transport saluran hilang: {0}",
"keyInvalid": "Kunci enkripsi transport saluran harus {0} karakter",
"encryptFailed": "Enkripsi transport saluran gagal",
"ciphertextInvalid": "Panjang ciphertext transport saluran tidak valid",
"decryptFailed": "Dekripsi transport saluran gagal",
"responseHeaderMissing": "Respons saluran tidak memiliki header enkripsi transport {0}",
"requestHeaderMissing": "Enkripsi transport saluran: permintaan tanpa header enkripsi"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "コールバック署名の検証に失敗しました",
"cacheConcurrentConflict": "同時競合",
"systemError": "内部システムエラー",
"validateParams": "パラメータの検証に失敗しました"
"validateParams": "パラメータの検証に失敗しました",
"transportEncrypt": {
"appConfigMissing": "チャネルサブアプリ設定が空です: {0}",
"configMissing": "チャネル転送暗号化設定がありません: {0}",
"keyInvalid": "チャネル転送暗号化キーは {0} 文字である必要があります",
"encryptFailed": "チャネル転送の暗号化に失敗しました",
"ciphertextInvalid": "チャネル転送の暗号文の長さが不正です",
"decryptFailed": "チャネル転送の復号に失敗しました",
"responseHeaderMissing": "チャネル応答に転送暗号化ヘッダー {0} がありません",
"requestHeaderMissing": "チャネル転送暗号化: リクエストに暗号化ヘッダーがありません"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "콜백 서명 확인에 실패했습니다.",
"cacheConcurrentConflict": "동시 충돌",
"systemError": "내부 시스템 오류",
"validateParams": "매개변수 검증 실패"
"validateParams": "매개변수 검증 실패",
"transportEncrypt": {
"appConfigMissing": "채널 서브앱 설정이 비어 있습니다: {0}",
"configMissing": "채널 전송 암호화 설정이 없습니다: {0}",
"keyInvalid": "채널 전송 암호화 키 길이는 {0}자여야 합니다",
"encryptFailed": "채널 전송 암호화에 실패했습니다",
"ciphertextInvalid": "채널 전송 암호문 길이가 잘못되었습니다",
"decryptFailed": "채널 전송 복호화에 실패했습니다",
"responseHeaderMissing": "채널 응답에 전송 암호화 헤더 {0}가 없습니다",
"requestHeaderMissing": "채널 전송 암호화: 요청에 암호화 헤더가 없습니다"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "Pengesahan tandatangan panggilan balik gagal",
"cacheConcurrentConflict": "Konflik serentak",
"systemError": "Ralat sistem dalaman",
"validateParams": "Pengesahan parameter gagal"
"validateParams": "Pengesahan parameter gagal",
"transportEncrypt": {
"appConfigMissing": "Konfigurasi aplikasi saluran kosong: {0}",
"configMissing": "Konfigurasi penyulitan pengangkutan saluran tiada: {0}",
"keyInvalid": "Kunci penyulitan pengangkutan saluran mestilah {0} aksara",
"encryptFailed": "Penyulitan pengangkutan saluran gagal",
"ciphertextInvalid": "Panjang ciphertext pengangkutan saluran tidak sah",
"decryptFailed": "Penyahsulitan pengangkutan saluran gagal",
"responseHeaderMissing": "Respons saluran tiada pengepala penyulitan pengangkutan {0}",
"requestHeaderMissing": "Penyulitan pengangkutan saluran: permintaan tiada pengepala penyulitan"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "การตรวจสอบลายเซ็นโทรกลับล้มเหลว",
"cacheConcurrentConflict": "ความขัดแย้งที่เกิดขึ้นพร้อมกัน",
"systemError": "ข้อผิดพลาดของระบบภายใน",
"validateParams": "การตรวจสอบพารามิเตอร์ล้มเหลว"
"validateParams": "การตรวจสอบพารามิเตอร์ล้มเหลว",
"transportEncrypt": {
"appConfigMissing": "การตั้งค่าแอปช่องทางว่างเปล่า: {0}",
"configMissing": "ไม่มีการตั้งค่าการเข้ารหัสการส่งช่องทาง: {0}",
"keyInvalid": "คีย์เข้ารหัสการส่งช่องทางต้องมี {0} ตัวอักษร",
"encryptFailed": "การเข้ารหัสการส่งช่องทางล้มเหลว",
"ciphertextInvalid": "ความยาวciphertext การส่งช่องทางไม่ถูกต้อง",
"decryptFailed": "การถอดรหัสการส่งช่องทางล้มเหลว",
"responseHeaderMissing": "การตอบกลับช่องทางไม่มีส่วนหัวเข้ารหัสการส่ง {0}",
"requestHeaderMissing": "การเข้ารหัสการส่งช่องทาง: คำขอไม่มีส่วนหัวเข้ารหัส"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "Xác minh chữ ký gọi lại không thành công",
"cacheConcurrentConflict": "Xung đột đồng thời",
"systemError": "Lỗi hệ thống nội bộ",
"validateParams": "Xác thực tham số không thành công"
"validateParams": "Xác thực tham số không thành công",
"transportEncrypt": {
"appConfigMissing": "Cấu hình ứng dụng kênh trống: {0}",
"configMissing": "Thiếu cấu hình mã hóa truyền tải kênh: {0}",
"keyInvalid": "Khóa mã hóa truyền tải kênh phải có {0} ký tự",
"encryptFailed": "Mã hóa truyền tải kênh thất bại",
"ciphertextInvalid": "Độ dài bản mã truyền tải kênh không hợp lệ",
"decryptFailed": "Giải mã truyền tải kênh thất bại",
"responseHeaderMissing": "Phản hồi kênh thiếu header mã hóa truyền tải {0}",
"requestHeaderMissing": "Mã hóa truyền tải kênh: yêu cầu thiếu header mã hóa"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "回调验签失败",
"cacheConcurrentConflict": "并发冲突",
"systemError": "系统内部错误",
"validateParams": "参数校验失败"
"validateParams": "参数校验失败",
"transportEncrypt": {
"appConfigMissing": "通道子应用配置不能为空: {0}",
"configMissing": "缺少通道传输加密配置: {0}",
"keyInvalid": "通道传输加密密钥长度须为 {0} 个字符",
"encryptFailed": "通道传输加密失败",
"ciphertextInvalid": "通道传输密文长度非法",
"decryptFailed": "通道传输解密失败",
"responseHeaderMissing": "通道子应用响应未携带传输加密头 {0}",
"requestHeaderMissing": "通道传输加密:请求未携带加密头"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "回調驗籤失敗",
"cacheConcurrentConflict": "併發衝突",
"systemError": "系統內部錯誤",
"validateParams": "參數校驗失敗"
"validateParams": "參數校驗失敗",
"transportEncrypt": {
"appConfigMissing": "通道子應用配置不能為空: {0}",
"configMissing": "缺少通道傳輸加密配置: {0}",
"keyInvalid": "通道傳輸加密密鑰長度須為 {0} 個字元",
"encryptFailed": "通道傳輸加密失敗",
"ciphertextInvalid": "通道傳輸密文長度非法",
"decryptFailed": "通道傳輸解密失敗",
"responseHeaderMissing": "通道子應用回應未攜帶傳輸加密頭 {0}",
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭"
}
}

View File

@@ -8,5 +8,15 @@
"callbackVerifyFailed": "回撥驗籤失敗",
"cacheConcurrentConflict": "併發衝突",
"systemError": "系統內部錯誤",
"validateParams": "引數校驗失敗"
"validateParams": "引數校驗失敗",
"transportEncrypt": {
"appConfigMissing": "通道子應用配置不能為空: {0}",
"configMissing": "缺少通道傳輸加密配置: {0}",
"keyInvalid": "通道傳輸加密金鑰長度須為 {0} 個字元",
"encryptFailed": "通道傳輸加密失敗",
"ciphertextInvalid": "通道傳輸密文長度非法",
"decryptFailed": "通道傳輸解密失敗",
"responseHeaderMissing": "通道子應用回應未攜帶傳輸加密頭 {0}",
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭"
}
}

View File

@@ -0,0 +1,58 @@
package cn.daxpay.open.platform.common.spring.channel;
import cn.daxpay.open.platform.common.config.encrypt.ChannelAesGcmEncryptor;
import cn.daxpay.open.platform.common.config.properties.DaxpayChannelProperties;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/// # 通道 RestClient 工厂
///
/// 为各 HttpExchange 通道 Client 派生带 baseUrl + 传输加密拦截器的 RestClient。
/// 传输加密强制常开key 非法则创建 Client 时直接失败,避免带病启动后明文外泄。
@Slf4j
@Component
public class ChannelRestClientSupport {
/// 通道子应用配置不能为空
public static final String MSG_APP_CONFIG_MISSING = "channel.error.transportEncrypt.appConfigMissing";
/// 缺少通道传输加密配置
public static final String MSG_CONFIG_MISSING = "channel.error.transportEncrypt.configMissing";
/// 创建声明式通道 HTTP Client
/// @param restClient 全局 RestClient已含 OTel / 业务上下文拦截器)
/// @param app 子应用连接与传输加密配置
/// @param clientType @HttpExchange 接口类型
public <T> T createClient(RestClient restClient,
DaxpayChannelProperties.ChannelApp app,
Class<T> clientType) {
if (app == null) {
// 通道子应用配置不能为空
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, MSG_APP_CONFIG_MISSING, clientType.getSimpleName());
}
var transportEncrypt = app.getTransportEncrypt();
if (transportEncrypt == null) {
// 缺少通道传输加密配置
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, MSG_CONFIG_MISSING, clientType.getSimpleName());
}
ChannelAesGcmEncryptor encryptor = new ChannelAesGcmEncryptor(transportEncrypt.getKey());
RestClient channelClient = restClient
.mutate()
.baseUrl(app.getBaseUrl())
.requestInterceptor(new ChannelTransportEncryptInterceptor(encryptor))
.build();
log.info("已创建通道 Client {}baseUrl={},传输加密=强制开启",
clientType.getSimpleName(), app.getBaseUrl());
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(channelClient))
.build()
.createClient(clientType);
}
}

View File

@@ -0,0 +1,124 @@
package cn.daxpay.open.platform.common.spring.channel;
import cn.daxpay.open.platform.common.config.encrypt.ChannelAesGcmEncryptor;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.StreamUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/// # 通道传输报文加解密拦截器
///
/// 主应用出站:非空请求 body 强制 AES-GCM 加密;响应强制解密后交给业务层。
/// 对业务 HttpExchange Client 透明。
///
/// Header 名与 WebHeaderCode.X_DAX_PAYLOAD_ENCRYPTED 保持一致,
/// 此处硬编码以避免 common-spring → platform-core 依赖。
@Slf4j
@RequiredArgsConstructor
public class ChannelTransportEncryptInterceptor implements ClientHttpRequestInterceptor {
/// 通道传输报文已加密标记(与 WebHeaderCode.X_DAX_PAYLOAD_ENCRYPTED 一致)
public static final String HEADER_X_DAX_PAYLOAD_ENCRYPTED = "X-Dax-Payload-Encrypted";
/// 通道子应用响应未携带传输加密头
public static final String MSG_RESPONSE_HEADER_MISSING =
"channel.error.transportEncrypt.responseHeaderMissing";
private static final MediaType TEXT_PLAIN_UTF8 =
new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8);
private final ChannelAesGcmEncryptor encryptor;
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
byte[] requestBody = body;
if (body.length > 0) {
String ciphertext = encryptor.encrypt(new String(body, StandardCharsets.UTF_8));
requestBody = ciphertext.getBytes(StandardCharsets.UTF_8);
request.getHeaders().set(HEADER_X_DAX_PAYLOAD_ENCRYPTED, "true");
request.getHeaders().setContentType(TEXT_PLAIN_UTF8);
request.getHeaders().setContentLength(requestBody.length);
}
ClientHttpResponse response = execution.execute(request, requestBody);
return decryptResponse(response);
}
/// 解密响应体;无 body 原样返回;有 body 但无加密头视为协议错误
private ClientHttpResponse decryptResponse(ClientHttpResponse response) throws IOException {
byte[] responseBytes = StreamUtils.copyToByteArray(response.getBody());
if (responseBytes.length == 0) {
return new DecryptedClientHttpResponse(response, responseBytes, false);
}
String encryptedFlag = response.getHeaders().getFirst(HEADER_X_DAX_PAYLOAD_ENCRYPTED);
if (!"true".equalsIgnoreCase(encryptedFlag)) {
response.close();
// 通道子应用响应未携带传输加密头
throw new BizInfoException(
CommonErrorCode.SYSTEM_ERROR, MSG_RESPONSE_HEADER_MISSING, HEADER_X_DAX_PAYLOAD_ENCRYPTED);
}
String plaintext = encryptor.decrypt(new String(responseBytes, StandardCharsets.UTF_8));
byte[] plainBytes = plaintext.getBytes(StandardCharsets.UTF_8);
return new DecryptedClientHttpResponse(response, plainBytes, true);
}
/// 包装已解密的响应,供 RestClient/HttpExchange 按 JSON 解析
static final class DecryptedClientHttpResponse implements ClientHttpResponse {
private final ClientHttpResponse delegate;
private final byte[] body;
private final HttpHeaders headers;
DecryptedClientHttpResponse(ClientHttpResponse delegate, byte[] body, boolean decrypted) {
this.delegate = delegate;
this.body = body;
this.headers = new HttpHeaders();
this.headers.putAll(delegate.getHeaders());
if (decrypted) {
this.headers.setContentType(MediaType.APPLICATION_JSON);
this.headers.setContentLength(body.length);
}
}
@Override
public HttpStatusCode getStatusCode() throws IOException {
return delegate.getStatusCode();
}
@Override
public String getStatusText() throws IOException {
return delegate.getStatusText();
}
@Override
public void close() {
delegate.close();
}
@Override
public InputStream getBody() {
return new ByteArrayInputStream(body);
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
}
}

View File

@@ -22,4 +22,7 @@ public interface WebHeaderCode {
/// 国际化语言
String ACCEPT_LANGUAGE = "accept-language";
/// 通道传输报文已 AES-GCM 加密标记(主应用 ↔ 通道子应用)
String X_DAX_PAYLOAD_ENCRYPTED = "X-Dax-Payload-Encrypted";
}

View File

@@ -171,12 +171,19 @@ daxpay:
# 拆分目的: 通道 SDK 依赖隔离(避免冲突) + 独立升级 + 弹性伸缩
# 子应用编号(one/two/three)固定且不可改名, 与路由策略中的通道分配一一对应。
channel:
# 子应用1: 支付宝 + 微信支付(已启用, 本机启动的 dax-pay-channel-one 服务)
# 子应用1: 支付宝/微信支付/银联商务/(已启用, 本机启动的 dax-pay-channel-one 服务)
one:
base-url: http://127.0.0.1:20100
# 子应用2: 其他支付通道(channel-two 已搭建架子, 通道待对接)
# 通道传输 AES 密钥(强制常开,恰好 32 字符;仅本地占位,勿用于生产)
transport-encrypt:
key: z0Vd8jDKB80pA6OOptGLO+qDVvWboEko
# 子应用2: 其他支付通道
two:
base-url: http://127.0.0.1:20200
# 子应用3: 抖音 + 其他通道(未来扩展, 暂未启用)
transport-encrypt:
key: z0Vd8jDKB80pA6OOptGLO+qDVvWboEko
# 子应用3: 其他通道(未来扩展, 暂未启用)
# three:
# base-url: http://127.0.0.1:20300
# base-url: http://127.0.0.1:20300
# transport-encrypt:
# key: ${CHANNEL_THREE_TRANSPORT_KEY}

View File

@@ -141,9 +141,16 @@ daxpay:
# 子应用1: 支付宝 + 微信支付(已启用, Docker Compose 中 channel-one 容器)
one:
base-url: ${CHANNEL_ONE_BASE_URL:http://channel-one:20100}
# 通道传输 AES 密钥(强制常开,恰好 32 字符;生产必须由环境变量注入,无默认值)
transport-encrypt:
key: ${CHANNEL_ONE_TRANSPORT_KEY}
# 子应用2: 其他支付通道(channel-two 已搭建架子, 通道待对接)
two:
base-url: ${CHANNEL_TWO_BASE_URL:http://channel-two:20200}
transport-encrypt:
key: ${CHANNEL_TWO_TRANSPORT_KEY}
# 子应用3: 抖音 + 其他通道(未来扩展, 暂未启用)
# three:
# base-url: ${CHANNEL_THREE_BASE_URL:http://channel-three:20300}
# base-url: ${CHANNEL_THREE_BASE_URL:http://channel-three:20300}
# transport-encrypt:
# key: ${CHANNEL_THREE_TRANSPORT_KEY}