mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-08 06:05:33 +08:00
feat(channel): 主应用与通道子应用 HTTP 报文 AES-GCM 透明加密
强制常开传输加密,按 one/two 分别配置密钥;出站 Interceptor 加解密,异常文案走 i18n。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,15 @@
|
||||
"callbackVerifyFailed": "コールバック署名の検証に失敗しました",
|
||||
"cacheConcurrentConflict": "同時競合",
|
||||
"systemError": "内部システムエラー",
|
||||
"validateParams": "パラメータの検証に失敗しました"
|
||||
"validateParams": "パラメータの検証に失敗しました",
|
||||
"transportEncrypt": {
|
||||
"appConfigMissing": "チャネルサブアプリ設定が空です: {0}",
|
||||
"configMissing": "チャネル転送暗号化設定がありません: {0}",
|
||||
"keyInvalid": "チャネル転送暗号化キーは {0} 文字である必要があります",
|
||||
"encryptFailed": "チャネル転送の暗号化に失敗しました",
|
||||
"ciphertextInvalid": "チャネル転送の暗号文の長さが不正です",
|
||||
"decryptFailed": "チャネル転送の復号に失敗しました",
|
||||
"responseHeaderMissing": "チャネル応答に転送暗号化ヘッダー {0} がありません",
|
||||
"requestHeaderMissing": "チャネル転送暗号化: リクエストに暗号化ヘッダーがありません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,15 @@
|
||||
"callbackVerifyFailed": "콜백 서명 확인에 실패했습니다.",
|
||||
"cacheConcurrentConflict": "동시 충돌",
|
||||
"systemError": "내부 시스템 오류",
|
||||
"validateParams": "매개변수 검증 실패"
|
||||
"validateParams": "매개변수 검증 실패",
|
||||
"transportEncrypt": {
|
||||
"appConfigMissing": "채널 서브앱 설정이 비어 있습니다: {0}",
|
||||
"configMissing": "채널 전송 암호화 설정이 없습니다: {0}",
|
||||
"keyInvalid": "채널 전송 암호화 키 길이는 {0}자여야 합니다",
|
||||
"encryptFailed": "채널 전송 암호화에 실패했습니다",
|
||||
"ciphertextInvalid": "채널 전송 암호문 길이가 잘못되었습니다",
|
||||
"decryptFailed": "채널 전송 복호화에 실패했습니다",
|
||||
"responseHeaderMissing": "채널 응답에 전송 암호화 헤더 {0}가 없습니다",
|
||||
"requestHeaderMissing": "채널 전송 암호화: 요청에 암호화 헤더가 없습니다"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,15 @@
|
||||
"callbackVerifyFailed": "การตรวจสอบลายเซ็นโทรกลับล้มเหลว",
|
||||
"cacheConcurrentConflict": "ความขัดแย้งที่เกิดขึ้นพร้อมกัน",
|
||||
"systemError": "ข้อผิดพลาดของระบบภายใน",
|
||||
"validateParams": "การตรวจสอบพารามิเตอร์ล้มเหลว"
|
||||
"validateParams": "การตรวจสอบพารามิเตอร์ล้มเหลว",
|
||||
"transportEncrypt": {
|
||||
"appConfigMissing": "การตั้งค่าแอปช่องทางว่างเปล่า: {0}",
|
||||
"configMissing": "ไม่มีการตั้งค่าการเข้ารหัสการส่งช่องทาง: {0}",
|
||||
"keyInvalid": "คีย์เข้ารหัสการส่งช่องทางต้องมี {0} ตัวอักษร",
|
||||
"encryptFailed": "การเข้ารหัสการส่งช่องทางล้มเหลว",
|
||||
"ciphertextInvalid": "ความยาวciphertext การส่งช่องทางไม่ถูกต้อง",
|
||||
"decryptFailed": "การถอดรหัสการส่งช่องทางล้มเหลว",
|
||||
"responseHeaderMissing": "การตอบกลับช่องทางไม่มีส่วนหัวเข้ารหัสการส่ง {0}",
|
||||
"requestHeaderMissing": "การเข้ารหัสการส่งช่องทาง: คำขอไม่มีส่วนหัวเข้ารหัส"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,15 @@
|
||||
"callbackVerifyFailed": "回调验签失败",
|
||||
"cacheConcurrentConflict": "并发冲突",
|
||||
"systemError": "系统内部错误",
|
||||
"validateParams": "参数校验失败"
|
||||
"validateParams": "参数校验失败",
|
||||
"transportEncrypt": {
|
||||
"appConfigMissing": "通道子应用配置不能为空: {0}",
|
||||
"configMissing": "缺少通道传输加密配置: {0}",
|
||||
"keyInvalid": "通道传输加密密钥长度须为 {0} 个字符",
|
||||
"encryptFailed": "通道传输加密失败",
|
||||
"ciphertextInvalid": "通道传输密文长度非法",
|
||||
"decryptFailed": "通道传输解密失败",
|
||||
"responseHeaderMissing": "通道子应用响应未携带传输加密头 {0}",
|
||||
"requestHeaderMissing": "通道传输加密:请求未携带加密头"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,15 @@
|
||||
"callbackVerifyFailed": "回調驗籤失敗",
|
||||
"cacheConcurrentConflict": "併發衝突",
|
||||
"systemError": "系統內部錯誤",
|
||||
"validateParams": "參數校驗失敗"
|
||||
"validateParams": "參數校驗失敗",
|
||||
"transportEncrypt": {
|
||||
"appConfigMissing": "通道子應用配置不能為空: {0}",
|
||||
"configMissing": "缺少通道傳輸加密配置: {0}",
|
||||
"keyInvalid": "通道傳輸加密密鑰長度須為 {0} 個字元",
|
||||
"encryptFailed": "通道傳輸加密失敗",
|
||||
"ciphertextInvalid": "通道傳輸密文長度非法",
|
||||
"decryptFailed": "通道傳輸解密失敗",
|
||||
"responseHeaderMissing": "通道子應用回應未攜帶傳輸加密頭 {0}",
|
||||
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,15 @@
|
||||
"callbackVerifyFailed": "回撥驗籤失敗",
|
||||
"cacheConcurrentConflict": "併發衝突",
|
||||
"systemError": "系統內部錯誤",
|
||||
"validateParams": "引數校驗失敗"
|
||||
"validateParams": "引數校驗失敗",
|
||||
"transportEncrypt": {
|
||||
"appConfigMissing": "通道子應用配置不能為空: {0}",
|
||||
"configMissing": "缺少通道傳輸加密配置: {0}",
|
||||
"keyInvalid": "通道傳輸加密金鑰長度須為 {0} 個字元",
|
||||
"encryptFailed": "通道傳輸加密失敗",
|
||||
"ciphertextInvalid": "通道傳輸密文長度非法",
|
||||
"decryptFailed": "通道傳輸解密失敗",
|
||||
"responseHeaderMissing": "通道子應用回應未攜帶傳輸加密頭 {0}",
|
||||
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,7 @@ public interface WebHeaderCode {
|
||||
/// 国际化语言
|
||||
String ACCEPT_LANGUAGE = "accept-language";
|
||||
|
||||
/// 通道传输报文已 AES-GCM 加密标记(主应用 ↔ 通道子应用)
|
||||
String X_DAX_PAYLOAD_ENCRYPTED = "X-Dax-Payload-Encrypted";
|
||||
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user