Merge remote-tracking branch 'base-version/main' into dev
This commit is contained in:
@@ -48,7 +48,7 @@ public class DesensitizeTest {
|
||||
DesensitizeDemo d = JsonUtils.parseObject(JsonUtils.toJsonString(desensitizeDemo), DesensitizeDemo.class);
|
||||
// 断言
|
||||
assertNotNull(d);
|
||||
assertEquals("芋***", d.getNickname());
|
||||
assertEquals("Z***", d.getNickname());
|
||||
assertEquals("998800********31", d.getBankCard());
|
||||
assertEquals("粤A6***6", d.getCarLicense());
|
||||
assertEquals("0108*****22", d.getFixedPhone());
|
||||
|
||||
@@ -273,10 +273,10 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
|
||||
String signatureType = resolveSignatureType(credential, security);
|
||||
try {
|
||||
// boolean valid = CryptoSignatureUtils.verifySignature(signaturePayload, signatureType);
|
||||
// if (!valid) {
|
||||
// throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "签名校验失败");
|
||||
// }
|
||||
boolean valid = CryptoSignatureUtils.verifySignature(signaturePayload, signatureType);
|
||||
if (!valid) {
|
||||
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "签名校验失败");
|
||||
}
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "签名算法配置异常");
|
||||
}
|
||||
|
||||
@@ -393,9 +393,14 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
}
|
||||
|
||||
private boolean supportsRequestBody(HttpMethod method) {
|
||||
// 所有请求都要传递请求体
|
||||
if (method == null) {
|
||||
return true;
|
||||
}
|
||||
return !(HttpMethod.GET.equals(method)
|
||||
|| HttpMethod.HEAD.equals(method)
|
||||
|| HttpMethod.OPTIONS.equals(method)
|
||||
|| HttpMethod.TRACE.equals(method));
|
||||
}
|
||||
|
||||
private Mono<Object> applyResilientRetry(Mono<Object> responseMono, ApiStepDefinition stepDefinition) {
|
||||
return responseMono.retryWhen(Retry.fixedDelay(RETRY_ATTEMPTS, RETRY_DELAY)
|
||||
|
||||
@@ -2,8 +2,11 @@ package com.zt.plat.module.databus.controller.admin.gateway;
|
||||
|
||||
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.IntegrationFlowManager;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
|
||||
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayAccessLogger;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -48,9 +51,18 @@ class ApiGatewayControllerTest {
|
||||
@MockBean
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@MockBean
|
||||
private IntegrationFlowManager integrationFlowManager;
|
||||
|
||||
@MockBean
|
||||
private ApiClientCredentialService apiClientCredentialService;
|
||||
|
||||
@MockBean
|
||||
private ApiAnonymousUserService apiAnonymousUserService;
|
||||
|
||||
@MockBean
|
||||
private ApiGatewayAccessLogger apiGatewayAccessLogger;
|
||||
|
||||
@Test
|
||||
void invokeShouldReturnGatewayEnvelope() throws Exception {
|
||||
ApiGatewayResponse response = ApiGatewayResponse.builder()
|
||||
|
||||
@@ -28,16 +28,10 @@ import java.util.UUID;
|
||||
public final class DatabusApiInvocationExample {
|
||||
|
||||
public static final String TIMESTAMP = Long.toString(System.currentTimeMillis());
|
||||
// private static final String APP_ID = "ztmy";
|
||||
// private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
|
||||
// private static final String APP_ID = "test";
|
||||
// private static final String APP_SECRET = "RSYtKXrXPLMy3oeh0cOro6QCioRUgqfnKCkDkNq78sI=";
|
||||
private static final String APP_ID = "testAnnoy";
|
||||
private static final String APP_SECRET = "jyGCymUjCFL2i3a4Tm3qBIkUrUl4ZgKPYvOU/47ZWcM=";
|
||||
private static final String APP_ID = "ztmy";
|
||||
private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
|
||||
private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
|
||||
// private static final String TARGET_API = "http://172.16.46.63:30081/admin-api/databus/api/portal/lgstOpenApi/v1";
|
||||
private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/test/1";
|
||||
// private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/lgstOpenApi/v1";
|
||||
private static final String TARGET_API = "http://172.16.46.63:30081/admin-api/databus/api/portal/callback/v1";
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
@@ -88,8 +82,11 @@ public final class DatabusApiInvocationExample {
|
||||
Map<String, Object> queryParams = new LinkedHashMap<>();
|
||||
|
||||
long extraTimestamp = 1761556157185L;
|
||||
// String bodyJson = String.format("""
|
||||
// {"operateFlag":"I","__interfaceType__":"R_MY_JY_03","data":{"endAddressName":"1","customerCompanyName":"中铜国贸","endAddressDetail":"测试地址","remark":" ","custSuppType":"1","shipperCompanyName":"中铜国贸","consigneeCorpCode":" ","consignerContactPhone":" 11","importFlag":"10","businessSupplierCode":" ","entrustMainCode":"WT3162251027027","endAddressCode":" ","specifyCarrierCorpCode":"10086689","materDetail":[{"detailStatus":"10","batchNo":"ZLTD2510ZTGM0017001","measureCodeMdm":"CU032110001","packType":" ","quantityPlanDetail":1,"deliveryOrderNo":"ZLTD2510ZTGM0017001","measureCode":"CU032110001","goodsSpecification":" ","measureUnitCode":"PAC","entrustDetailCode":"WT3162251027027001","brand":" ","soNumber":"68ecf0055502d565d22b378a"}],"operateFlag":1,"custSuppName":"上海锦生金属有限公司","startAddressCode":" ","planStartTime":1761556166000,"customerCompanyCode":0,"importMethod":"EXW","startAddressType":"10","shipperCompanyCode":"3162","deliverCondition":"20","businessSupplierName":" ","startAddressDetail":" 111","transType":"30","endAddressType":"20","planEndTime":1761556166000,"specifyCarrierCorpName":null,"custSuppFlag":"0101","businessType":"20","consigneeCorpName":" ","custSuppCode":"10086689","startAddressName":" 111","consignerContactName":" 11"},"datetime":"20251027170929","busiBillCode":"WT3162251027027","system":"BRMS","__requestId__":"f918841c-14fb-49eb-9640-c5d1b3d46bd1"}
|
||||
// """, extraTimestamp);
|
||||
String bodyJson = String.format("""
|
||||
{"operateFlag":"I","__interfaceType__":"R_MY_JY_03","data":{"endAddressName":"1","customerCompanyName":"中铜国贸","endAddressDetail":"测试地址","remark":" ","custSuppType":"1","shipperCompanyName":"中铜国贸","consigneeCorpCode":" ","consignerContactPhone":" 11","importFlag":"10","businessSupplierCode":" ","entrustMainCode":"WT3162251027027","endAddressCode":" ","specifyCarrierCorpCode":"10086689","materDetail":[{"detailStatus":"10","batchNo":"ZLTD2510ZTGM0017001","measureCodeMdm":"CU032110001","packType":" ","quantityPlanDetail":1,"deliveryOrderNo":"ZLTD2510ZTGM0017001","measureCode":"CU032110001","goodsSpecification":" ","measureUnitCode":"PAC","entrustDetailCode":"WT3162251027027001","brand":" ","soNumber":"68ecf0055502d565d22b378a"}],"operateFlag":1,"custSuppName":"上海锦生金属有限公司","startAddressCode":" ","planStartTime":1761556166000,"customerCompanyCode":0,"importMethod":"EXW","startAddressType":"10","shipperCompanyCode":"3162","deliverCondition":"20","businessSupplierName":" ","startAddressDetail":" 111","transType":"30","endAddressType":"20","planEndTime":1761556166000,"specifyCarrierCorpName":null,"custSuppFlag":"0101","businessType":"20","consigneeCorpName":" ","custSuppCode":"10086689","startAddressName":" 111","consignerContactName":" 11"},"datetime":"20251027170929","busiBillCode":"WT3162251027027","system":"BRMS","__requestId__":"f918841c-14fb-49eb-9640-c5d1b3d46bd1"}
|
||||
{}
|
||||
""", extraTimestamp);
|
||||
|
||||
Map<String, Object> bodyParams = parseBodyJson(bodyJson);
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
|
||||
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
|
||||
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayAccessLogger;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -25,7 +26,9 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
import static com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties.*;
|
||||
@@ -50,8 +53,9 @@ class GatewaySecurityFilterTest {
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
ApiGatewayAccessLogger accessLogger = mock(ApiGatewayAccessLogger.class);
|
||||
when(accessLogger.logEntrance(any())).thenReturn(1L);
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper(), accessLogger);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
@@ -74,7 +78,9 @@ class GatewaySecurityFilterTest {
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
ApiGatewayAccessLogger accessLogger = mock(ApiGatewayAccessLogger.class);
|
||||
when(accessLogger.logEntrance(any())).thenReturn(1L);
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper(), accessLogger);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("10.0.0.1");
|
||||
@@ -97,6 +103,8 @@ class GatewaySecurityFilterTest {
|
||||
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
ApiGatewayAccessLogger accessLogger = mock(ApiGatewayAccessLogger.class);
|
||||
when(accessLogger.logEntrance(any())).thenReturn(1L);
|
||||
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
|
||||
ApiClientCredentialDO credential = new ApiClientCredentialDO();
|
||||
credential.setAppId("demo-app");
|
||||
@@ -108,13 +116,13 @@ class GatewaySecurityFilterTest {
|
||||
properties.getSecurity().setRequireBodyEncryption(false);
|
||||
properties.getSecurity().setEncryptResponse(false);
|
||||
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper(), accessLogger);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
|
||||
String signature = signatureForApp("demo-app");
|
||||
String signature = signatureForApp("demo-app", timestamp);
|
||||
request.addHeader(APP_ID_HEADER, "demo-app");
|
||||
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
|
||||
request.addHeader(NONCE_HEADER, nonce);
|
||||
@@ -142,13 +150,15 @@ class GatewaySecurityFilterTest {
|
||||
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
ApiGatewayAccessLogger accessLogger = mock(ApiGatewayAccessLogger.class);
|
||||
when(accessLogger.logEntrance(any())).thenReturn(1L);
|
||||
ApiClientCredentialDO credential = new ApiClientCredentialDO();
|
||||
credential.setAppId("demo-app");
|
||||
credential.setEncryptionKey("demo-secret-key");
|
||||
credential.setEncryptionType(CryptoSignatureUtils.ENCRYPT_TYPE_AES);
|
||||
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential));
|
||||
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper(), accessLogger);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
@@ -183,6 +193,8 @@ class GatewaySecurityFilterTest {
|
||||
|
||||
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
|
||||
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
|
||||
ApiGatewayAccessLogger accessLogger = mock(ApiGatewayAccessLogger.class);
|
||||
when(accessLogger.logEntrance(any())).thenReturn(1L);
|
||||
ApiClientCredentialDO credential = new ApiClientCredentialDO();
|
||||
credential.setAppId("demo-app");
|
||||
credential.setSignatureType(null);
|
||||
@@ -200,7 +212,7 @@ class GatewaySecurityFilterTest {
|
||||
when(anonymousUserService.find(99L)).thenReturn(Optional.of(details));
|
||||
when(anonymousUserService.issueAccessToken(details)).thenReturn(Optional.of("mock-token"));
|
||||
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
|
||||
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper(), accessLogger);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
|
||||
request.setRemoteAddr("127.0.0.1");
|
||||
@@ -209,7 +221,7 @@ class GatewaySecurityFilterTest {
|
||||
request.addHeader(APP_ID_HEADER, "demo-app");
|
||||
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
|
||||
request.addHeader(NONCE_HEADER, nonce);
|
||||
request.addHeader(SIGNATURE_HEADER, signatureForApp("demo-app"));
|
||||
request.addHeader(SIGNATURE_HEADER, signatureForApp("demo-app", timestamp));
|
||||
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
@@ -231,7 +243,20 @@ class GatewaySecurityFilterTest {
|
||||
return properties;
|
||||
}
|
||||
|
||||
private String signatureForApp(String appId) {
|
||||
return SecureUtil.md5("appId=" + appId);
|
||||
private String signatureForApp(String appId, long timestamp) {
|
||||
Map<String, Object> payload = new TreeMap<>();
|
||||
payload.put(APP_ID_HEADER, appId);
|
||||
payload.put(TIMESTAMP_HEADER, String.valueOf(timestamp));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
payload.forEach((key, value) -> {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
sb.append(key).append('=').append(value);
|
||||
});
|
||||
return SecureUtil.md5(sb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
package com.zt.plat.module.databus.framework.integration.gateway.step.impl;
|
||||
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
import reactor.netty.http.client.PrematureCloseException;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
import reactor.util.retry.Retry;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Demonstrates the stale-connection scenario using the legacy vs the deferred retry pipeline.
|
||||
*/
|
||||
class HttpStepHandlerConnectionResetScenarioTest {
|
||||
|
||||
private static final Duration RETRY_DELAY = Duration.ofMillis(200);
|
||||
private static final int RETRY_ATTEMPTS = 3;
|
||||
private static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration RESET_WAIT = Duration.ofMillis(300);
|
||||
|
||||
@Test
|
||||
void legacyPipelineLosesSuccessfulRetry() throws Exception {
|
||||
try (ResetOnceHttpServer server = new ResetOnceHttpServer()) {
|
||||
WebClient webClient = createWebClient();
|
||||
URI uri = server.uri("/demo");
|
||||
|
||||
warmUp(server, webClient, uri);
|
||||
server.awaitWarmupConnectionReset(RESET_WAIT);
|
||||
|
||||
legacyInvoke(webClient, uri, Map.of("mode", "legacy"));
|
||||
|
||||
server.awaitFreshResponses(1, Duration.ofSeconds(2));
|
||||
assertThat(server.getFreshResponseCount()).isEqualTo(1);
|
||||
assertThat(server.getServedBodies()).contains("reset", "fresh");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deferredPipelinePropagatesSuccessfulRetry() throws Exception {
|
||||
try (ResetOnceHttpServer server = new ResetOnceHttpServer()) {
|
||||
WebClient webClient = createWebClient();
|
||||
URI uri = server.uri("/demo");
|
||||
|
||||
warmUp(server, webClient, uri);
|
||||
server.awaitWarmupConnectionReset(RESET_WAIT);
|
||||
|
||||
Object result = deferredInvoke(webClient, uri, Map.of("mode", "defer"));
|
||||
assertThat(result).isInstanceOf(Map.class);
|
||||
Map<?, ?> resultMap = (Map<?, ?>) result;
|
||||
assertThat(resultMap.get("stage")).isEqualTo("fresh");
|
||||
|
||||
server.awaitFreshResponses(1, Duration.ofSeconds(2));
|
||||
assertThat(server.getFreshResponseCount()).isEqualTo(1);
|
||||
assertThat(server.getServedBodies()).contains("reset", "fresh");
|
||||
}
|
||||
}
|
||||
|
||||
private WebClient createWebClient() {
|
||||
ConnectionProvider provider = ConnectionProvider.builder("http-step-handler-demo")
|
||||
.maxConnections(1)
|
||||
.pendingAcquireMaxCount(-1)
|
||||
.maxIdleTime(Duration.ofSeconds(5))
|
||||
.build();
|
||||
HttpClient httpClient = HttpClient.create(provider).compress(true);
|
||||
return WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void warmUp(ResetOnceHttpServer server, WebClient webClient, URI uri) {
|
||||
webClient.post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("warm", true))
|
||||
.retrieve()
|
||||
.bodyToMono(Object.class)
|
||||
.block(BLOCK_TIMEOUT);
|
||||
server.awaitWarmupResponse(Duration.ofSeconds(2));
|
||||
}
|
||||
|
||||
private Object legacyInvoke(WebClient webClient, URI uri, Object body) {
|
||||
WebClient.RequestHeadersSpec<?> spec = webClient.post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body);
|
||||
Mono<Object> responseMono = spec.retrieve()
|
||||
.bodyToMono(Object.class)
|
||||
// 模拟业务中首次订阅后缓存失败结果的场景
|
||||
.cache();
|
||||
return responseMono.retryWhen(Retry.fixedDelay(RETRY_ATTEMPTS, RETRY_DELAY)
|
||||
.filter(this::isRetryableException)
|
||||
.onRetryExhaustedThrow((specification, signal) -> signal.failure()))
|
||||
.block(BLOCK_TIMEOUT);
|
||||
}
|
||||
|
||||
private Object deferredInvoke(WebClient webClient, URI uri, Object body) {
|
||||
Mono<Object> responseMono = Mono.defer(() -> webClient.post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(Object.class)
|
||||
// 通过 defer,每次重试都会重新创建带缓存的响应 Mono
|
||||
.cache());
|
||||
return responseMono.retryWhen(Retry.fixedDelay(RETRY_ATTEMPTS, RETRY_DELAY)
|
||||
.filter(this::isRetryableException))
|
||||
.block(BLOCK_TIMEOUT);
|
||||
}
|
||||
|
||||
private boolean isRetryableException(Throwable throwable) {
|
||||
if (throwable == null) {
|
||||
return false;
|
||||
}
|
||||
Throwable cursor = throwable;
|
||||
while (cursor != null) {
|
||||
if (cursor instanceof ServiceException) {
|
||||
return false;
|
||||
}
|
||||
if (cursor instanceof PrematureCloseException) {
|
||||
return true;
|
||||
}
|
||||
if (cursor instanceof IOException) {
|
||||
return true;
|
||||
}
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final class ResetOnceHttpServer implements AutoCloseable {
|
||||
|
||||
private static final Duration RESET_DELAY = Duration.ofMillis(250);
|
||||
|
||||
private final ServerSocket serverSocket;
|
||||
private final ExecutorService acceptExecutor;
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private final AtomicInteger connectionCount = new AtomicInteger();
|
||||
private final AtomicInteger freshResponses = new AtomicInteger();
|
||||
private final CountDownLatch warmupResponseSent = new CountDownLatch(1);
|
||||
private final CountDownLatch warmupReset = new CountDownLatch(1);
|
||||
private final List<String> servedBodies = new CopyOnWriteArrayList<>();
|
||||
private volatile boolean running = true;
|
||||
private volatile Socket warmupSocket;
|
||||
|
||||
ResetOnceHttpServer() throws IOException {
|
||||
this.serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
|
||||
this.serverSocket.setReuseAddress(true);
|
||||
this.acceptExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "reset-once-http-accept");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "reset-once-http-scheduler");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
acceptExecutor.submit(this::acceptLoop);
|
||||
}
|
||||
|
||||
URI uri(String path) {
|
||||
Objects.requireNonNull(path, "path");
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
return URI.create("http://127.0.0.1:" + serverSocket.getLocalPort() + path);
|
||||
}
|
||||
|
||||
List<String> getServedBodies() {
|
||||
return new ArrayList<>(servedBodies);
|
||||
}
|
||||
|
||||
int getFreshResponseCount() {
|
||||
return freshResponses.get();
|
||||
}
|
||||
|
||||
void awaitWarmupResponse(Duration timeout) {
|
||||
awaitLatch(warmupResponseSent, timeout);
|
||||
}
|
||||
|
||||
void awaitWarmupConnectionReset(Duration timeout) {
|
||||
awaitLatch(warmupReset, timeout);
|
||||
}
|
||||
|
||||
void awaitFreshResponses(int expected, Duration timeout) {
|
||||
long deadline = System.nanoTime() + timeout.toNanos();
|
||||
while (freshResponses.get() < expected && System.nanoTime() < deadline) {
|
||||
try {
|
||||
Thread.sleep(10);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitLatch(CountDownLatch latch, Duration timeout) {
|
||||
try {
|
||||
if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
throw new IllegalStateException("Timed out waiting for latch");
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void acceptLoop() {
|
||||
try {
|
||||
while (running) {
|
||||
Socket socket = serverSocket.accept();
|
||||
int index = connectionCount.incrementAndGet();
|
||||
handle(socket, index);
|
||||
}
|
||||
} catch (SocketException ex) {
|
||||
if (running) {
|
||||
throw new IllegalStateException("Unexpected server socket error", ex);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
if (running) {
|
||||
throw new IllegalStateException("I/O error in server", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(Socket socket, int index) {
|
||||
try {
|
||||
socket.setTcpNoDelay(true);
|
||||
RequestMetadata metadata = readRequest(socket);
|
||||
if (index == 1) {
|
||||
warmupSocket = socket;
|
||||
String body = "{\"stage\":\"warmup\",\"path\":\"" + metadata.path + "\"}";
|
||||
writeResponse(socket, body, true);
|
||||
servedBodies.add("warmup");
|
||||
warmupResponseSent.countDown();
|
||||
scheduler.schedule(() -> forceReset(socket), RESET_DELAY.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} else if (index == 2) {
|
||||
// 模拟客户端复用到仍在连接池中的旧连接,但服务端已在请求到达后立即复位。
|
||||
servedBodies.add("reset");
|
||||
scheduler.schedule(() -> closeWithReset(socket), 10, TimeUnit.MILLISECONDS);
|
||||
} else {
|
||||
String body = "{\"stage\":\"fresh\",\"attempt\":" + index + "}";
|
||||
writeResponse(socket, body, false);
|
||||
servedBodies.add("fresh");
|
||||
freshResponses.incrementAndGet();
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
// ignore for the purpose of the test
|
||||
}
|
||||
}
|
||||
|
||||
private void forceReset(Socket socket) {
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
servedBodies.add("reset");
|
||||
closeWithReset(socket);
|
||||
}
|
||||
} finally {
|
||||
warmupReset.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeWithReset(Socket socket) {
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
socket.setSoLinger(true, 0);
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private RequestMetadata readRequest(Socket socket) throws IOException {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
|
||||
String requestLine = reader.readLine();
|
||||
if (requestLine == null) {
|
||||
return new RequestMetadata("unknown", 0);
|
||||
}
|
||||
String path = requestLine.split(" ", 3)[1];
|
||||
int contentLength = 0;
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null && !line.isEmpty()) {
|
||||
if (line.toLowerCase(Locale.ROOT).startsWith("content-length:")) {
|
||||
contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim());
|
||||
}
|
||||
}
|
||||
if (contentLength > 0) {
|
||||
char[] buffer = new char[contentLength];
|
||||
int read = 0;
|
||||
while (read < contentLength) {
|
||||
int r = reader.read(buffer, read, contentLength - read);
|
||||
if (r < 0) {
|
||||
break;
|
||||
}
|
||||
read += r;
|
||||
}
|
||||
}
|
||||
return new RequestMetadata(path, contentLength);
|
||||
}
|
||||
|
||||
private void writeResponse(Socket socket, String body, boolean keepAlive) throws IOException {
|
||||
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
StringBuilder builder = new StringBuilder()
|
||||
.append("HTTP/1.1 200 OK\r\n")
|
||||
.append("Content-Type: application/json\r\n")
|
||||
.append("Content-Length: ").append(bodyBytes.length).append("\r\n");
|
||||
if (keepAlive) {
|
||||
builder.append("Connection: keep-alive\r\n");
|
||||
} else {
|
||||
builder.append("Connection: close\r\n");
|
||||
}
|
||||
builder.append("\r\n");
|
||||
OutputStream outputStream = socket.getOutputStream();
|
||||
outputStream.write(builder.toString().getBytes(StandardCharsets.US_ASCII));
|
||||
outputStream.write(bodyBytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
running = false;
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
if (warmupSocket != null && !warmupSocket.isClosed()) {
|
||||
try {
|
||||
warmupSocket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
scheduler.shutdownNow();
|
||||
acceptExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private record RequestMetadata(String path, int contentLength) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyRateLimitMapper;
|
||||
import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper;
|
||||
import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper;
|
||||
import com.zt.plat.module.databus.enums.gateway.ApiStatusEnum;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiVersionService;
|
||||
import com.zt.plat.module.databus.service.gateway.impl.ApiDefinitionServiceImpl;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -63,6 +64,9 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
|
||||
@MockBean
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@MockBean
|
||||
private ApiVersionService apiVersionService;
|
||||
|
||||
@TestConfiguration
|
||||
static class JacksonTestConfiguration {
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.zt.plat.module.infra.api.businessfile.dto.BusinessFilePageReqDTO;
|
||||
import com.zt.plat.module.infra.api.businessfile.dto.BusinessFileRespDTO;
|
||||
import com.zt.plat.module.infra.api.businessfile.dto.BusinessFileSaveReqDTO;
|
||||
import com.zt.plat.module.infra.api.businessfile.dto.BusinessFileWithUrlRespDTO;
|
||||
import com.zt.plat.module.infra.enums.ApiConstants;
|
||||
import com.zt.plat.framework.common.enums.RpcConstants;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -19,11 +19,11 @@ import java.util.List;
|
||||
/**
|
||||
* @author chenbowen
|
||||
*/
|
||||
@FeignClient(name = ApiConstants.NAME)
|
||||
@FeignClient(name = RpcConstants.INFRA_NAME)
|
||||
@Tag(name = "RPC 服务 - 业务附件关联")
|
||||
public interface BusinessFileApi {
|
||||
|
||||
String PREFIX = ApiConstants.PREFIX + "/business-file";
|
||||
String PREFIX = RpcConstants.INFRA_PREFIX + "/business-file";
|
||||
|
||||
@PostMapping(PREFIX + "/create")
|
||||
@Operation(summary = "创建业务附件关联")
|
||||
@@ -52,6 +52,11 @@ public interface BusinessFileApi {
|
||||
@Parameter(name = "id", description = "编号", required = true)
|
||||
CommonResult<BusinessFileRespDTO> getBusinessFile(@RequestParam("id") Long id);
|
||||
|
||||
@GetMapping(PREFIX + "/get-by-code")
|
||||
@Operation(summary = "根据业务编码获得业务附件关联")
|
||||
@Parameter(name = "businessCode", description = "业务编码", required = true)
|
||||
CommonResult<BusinessFileRespDTO> getBusinessFileByBusinessCode(@RequestParam("businessCode") String businessCode);
|
||||
|
||||
@PostMapping(PREFIX + "/page")
|
||||
@Operation(summary = "获得业务附件关联分页")
|
||||
CommonResult<PageResult<BusinessFileRespDTO>> getBusinessFilePage(@RequestBody BusinessFilePageReqDTO pageReqDTO);
|
||||
|
||||
@@ -67,6 +67,12 @@ public class BusinessFileApiImpl implements BusinessFileApi {
|
||||
return success(BeanUtils.toBean(businessFile, BusinessFileRespDTO.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<BusinessFileRespDTO> getBusinessFileByBusinessCode(String businessCode) {
|
||||
BusinessFileDO businessFile = businessFileService.getBusinessFileByBusinessCode(businessCode);
|
||||
return success(BeanUtils.toBean(businessFile, BusinessFileRespDTO.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<PageResult<BusinessFileRespDTO>> getBusinessFilePage(BusinessFilePageReqDTO pageReqDTO) {
|
||||
PageResult<BusinessFileDO> pageResult = businessFileService.getBusinessFilePage(BeanUtils.toBean(pageReqDTO, BusinessFilePageReqVO.class));
|
||||
|
||||
@@ -90,6 +90,15 @@ public class BusinessFileController {
|
||||
return success(BeanUtils.toBean(businessFile, BusinessFileRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/get-by-code")
|
||||
@Operation(summary = "根据业务编码获得业务附件关联")
|
||||
@Parameter(name = "businessCode", description = "业务编码", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('infra:business-file:query')")
|
||||
public CommonResult<BusinessFileRespVO> getBusinessFileByBusinessCode(@RequestParam("businessCode") String businessCode) {
|
||||
BusinessFileDO businessFile = businessFileService.getBusinessFileByBusinessCode(businessCode);
|
||||
return success(BeanUtils.toBean(businessFile, BusinessFileRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获得业务附件关联分页")
|
||||
@PreAuthorize("@ss.hasAnyPermissions({'infra:business-file:query','supply:purchase-credit-granting-form-template:query-list','supply:purchase-amount-request-form-template:query-list'})")
|
||||
|
||||
@@ -27,4 +27,8 @@ public interface BusinessFileMapper extends BaseMapperX<BusinessFileDO> {
|
||||
.orderByDesc(BusinessFileDO::getId));
|
||||
}
|
||||
|
||||
default BusinessFileDO selectByBusinessCode(String businessCode) {
|
||||
return selectFirstOne(BusinessFileDO::getBusinessCode, businessCode);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,6 +53,14 @@ public interface BusinessFileService {
|
||||
*/
|
||||
BusinessFileDO getBusinessFile(Long id);
|
||||
|
||||
/**
|
||||
* 根据业务编码获得业务附件关联
|
||||
*
|
||||
* @param businessCode 业务编码
|
||||
* @return 业务附件关联
|
||||
*/
|
||||
BusinessFileDO getBusinessFileByBusinessCode(String businessCode);
|
||||
|
||||
/**
|
||||
* 获得业务附件关联分页
|
||||
*
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.zt.plat.module.system.api.user.AdminUserApi;
|
||||
import com.zt.plat.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.*;
|
||||
@@ -99,6 +100,18 @@ public class BusinessFileServiceImpl implements BusinessFileService {
|
||||
return businessFileMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BusinessFileDO getBusinessFileByBusinessCode(String businessCode) {
|
||||
if (!StringUtils.hasText(businessCode)) {
|
||||
throw exception(BUSINESS_FILE_NOT_EXISTS);
|
||||
}
|
||||
BusinessFileDO businessFile = businessFileMapper.selectByBusinessCode(businessCode.trim());
|
||||
if (businessFile == null) {
|
||||
throw exception(BUSINESS_FILE_NOT_EXISTS);
|
||||
}
|
||||
return businessFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<BusinessFileDO> getBusinessFilePage(BusinessFilePageReqVO pageReqVO) {
|
||||
return businessFileMapper.selectPage(pageReqVO);
|
||||
|
||||
@@ -12,9 +12,9 @@ public class IWorkFileCallbackReqVO {
|
||||
@NotBlank(message = "文件 URL 不能为空")
|
||||
private String fileUrl;
|
||||
|
||||
@Schema(description = "业务 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@NotBlank(message = "业务 ID 不能为空")
|
||||
private String businessId;
|
||||
@Schema(description = "业务编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "DJ-2025-0001")
|
||||
@NotBlank(message = "业务编码不能为空")
|
||||
private String businessCode;
|
||||
|
||||
@Schema(description = "文件名称,可选", example = "合同附件.pdf")
|
||||
private String fileName;
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.module.infra.api.businessfile.BusinessFileApi;
|
||||
import com.zt.plat.module.infra.api.businessfile.dto.BusinessFileRespDTO;
|
||||
import com.zt.plat.module.infra.api.businessfile.dto.BusinessFileSaveReqDTO;
|
||||
import com.zt.plat.module.infra.api.file.FileApi;
|
||||
import com.zt.plat.module.infra.api.file.dto.FileCreateReqDTO;
|
||||
@@ -161,20 +162,16 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(), "回调请求不能为空");
|
||||
}
|
||||
String fileUrl = Optional.ofNullable(reqVO.getFileUrl()).map(String::trim).orElse("");
|
||||
String businessIdStr = Optional.ofNullable(reqVO.getBusinessId()).map(String::trim).orElse("");
|
||||
String businessCode = Optional.ofNullable(reqVO.getBusinessCode()).map(String::trim).orElse("");
|
||||
if (!StringUtils.hasText(fileUrl)) {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(), "文件 URL 不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(businessIdStr)) {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(), "业务 ID 不能为空");
|
||||
if (!StringUtils.hasText(businessCode)) {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(), "业务编码不能为空");
|
||||
}
|
||||
|
||||
Long businessId;
|
||||
try {
|
||||
businessId = Long.valueOf(businessIdStr);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(), "业务 ID 必须是数字: " + businessIdStr);
|
||||
}
|
||||
BusinessFileRespDTO referenceBusinessFile = loadBusinessFileByBusinessCode(businessCode);
|
||||
Long businessId = referenceBusinessFile.getBusinessId();
|
||||
|
||||
// 通过文件 API 创建文件
|
||||
FileCreateReqDTO fileCreateReqDTO = new FileCreateReqDTO();
|
||||
@@ -192,6 +189,7 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
|
||||
|
||||
BusinessFileSaveReqDTO businessReq = BusinessFileSaveReqDTO.builder()
|
||||
.businessId(businessId)
|
||||
.businessCode(referenceBusinessFile.getBusinessCode())
|
||||
.fileId(fileId)
|
||||
.fileName(fileResult.getData().getName())
|
||||
.source("iwork")
|
||||
@@ -204,6 +202,21 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
|
||||
return bizResult.getData();
|
||||
}
|
||||
|
||||
private BusinessFileRespDTO loadBusinessFileByBusinessCode(String businessCode) {
|
||||
CommonResult<BusinessFileRespDTO> businessResult = businessFileApi.getBusinessFileByBusinessCode(businessCode);
|
||||
if (businessResult == null || !businessResult.isSuccess() || businessResult.getData() == null) {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(),
|
||||
"根据业务编码获取业务附件关联失败: " + Optional.ofNullable(businessResult)
|
||||
.map(CommonResult::getMsg)
|
||||
.orElse("未知错误"));
|
||||
}
|
||||
BusinessFileRespDTO businessFile = businessResult.getData();
|
||||
if (businessFile.getBusinessId() == null) {
|
||||
throw new ServiceException(IWORK_CONFIGURATION_INVALID.getCode(), "业务编码未绑定业务 ID: " + businessCode);
|
||||
}
|
||||
return businessFile;
|
||||
}
|
||||
|
||||
private byte[] downloadFileBytes(String fileUrl) {
|
||||
OkHttpClient client = okHttpClient();
|
||||
Request request = new Request.Builder().url(fileUrl).get().build();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.zt.plat.module.template;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 项目的启动类
|
||||
@@ -9,6 +10,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
* @author 周迪
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class TemplateServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.zt.plat.module.template.dal.dataobject.databus;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.zt.plat.framework.mybatis.core.dataobject.BaseDO;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* Databus 请求与响应日志表。
|
||||
*/
|
||||
@TableName("template_databus_request_log")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TemplateDatabusRequestLogDO extends BaseDO {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 请求唯一标识。
|
||||
*/
|
||||
@TableField("REQUEST_ID")
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 实际请求 URL。
|
||||
*/
|
||||
@TableField("TARGET_URL")
|
||||
private String targetUrl;
|
||||
|
||||
/**
|
||||
* HTTP 方法。
|
||||
*/
|
||||
@TableField("HTTP_METHOD")
|
||||
private String httpMethod;
|
||||
|
||||
/**
|
||||
* Query 参数 JSON。
|
||||
*/
|
||||
@TableField("QUERY_PARAMS")
|
||||
private String queryParams;
|
||||
|
||||
/**
|
||||
* 请求头 JSON。
|
||||
*/
|
||||
@TableField("REQUEST_HEADERS")
|
||||
private String requestHeaders;
|
||||
|
||||
/**
|
||||
* 原始请求体。
|
||||
*/
|
||||
@TableField("REQUEST_BODY")
|
||||
private String requestBody;
|
||||
|
||||
/**
|
||||
* 加密后的请求体。
|
||||
*/
|
||||
@TableField("ENCRYPTED_REQUEST_BODY")
|
||||
private String encryptedRequestBody;
|
||||
|
||||
/**
|
||||
* 签名。
|
||||
*/
|
||||
@TableField("SIGNATURE")
|
||||
private String signature;
|
||||
|
||||
/**
|
||||
* 随机串。
|
||||
*/
|
||||
@TableField("NONCE")
|
||||
private String nonce;
|
||||
|
||||
/**
|
||||
* 时间戳。
|
||||
*/
|
||||
@TableField("ZT_TIMESTAMP")
|
||||
private String ztTimestamp;
|
||||
|
||||
/**
|
||||
* HTTP 返回状态码。
|
||||
*/
|
||||
@TableField("RESPONSE_STATUS")
|
||||
private Integer responseStatus;
|
||||
|
||||
/**
|
||||
* 加密响应体。
|
||||
*/
|
||||
@TableField("ENCRYPTED_RESPONSE_BODY")
|
||||
private String encryptedResponseBody;
|
||||
|
||||
/**
|
||||
* 解密后的响应体。
|
||||
*/
|
||||
@TableField("RESPONSE_BODY")
|
||||
private String responseBody;
|
||||
|
||||
/**
|
||||
* 是否调用成功。
|
||||
*/
|
||||
@TableField("SUCCESS")
|
||||
private Boolean success;
|
||||
|
||||
/**
|
||||
* 错误信息。
|
||||
*/
|
||||
@TableField("ERROR_MESSAGE")
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 调用耗时(毫秒)。
|
||||
*/
|
||||
@TableField("DURATION_MS")
|
||||
private Long durationMs;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.zt.plat.module.template.dal.mysql.databus;
|
||||
|
||||
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.zt.plat.module.template.dal.dataobject.databus.TemplateDatabusRequestLogDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Databus 请求日志 Mapper。
|
||||
*/
|
||||
@Mapper
|
||||
public interface TemplateDatabusRequestLogMapper extends BaseMapperX<TemplateDatabusRequestLogDO> {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.zt.plat.module.template.job.databus;
|
||||
|
||||
import com.zt.plat.module.template.service.databus.TemplateDatabusInvokeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 基于 Spring 的 Databus 调度任务。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TemplateDatabusScheduler {
|
||||
|
||||
private final TemplateDatabusInvokeService invokeService;
|
||||
|
||||
@Scheduled(cron = "0 0/10 * * * ?")
|
||||
public void execute() {
|
||||
invokeService.invokeAndRecord();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package com.zt.plat.module.template.service.databus;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
|
||||
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
|
||||
import com.zt.plat.module.template.dal.dataobject.databus.TemplateDatabusRequestLogDO;
|
||||
import com.zt.plat.module.template.dal.mysql.databus.TemplateDatabusRequestLogMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 调用 Databus 接口并记录日志。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TemplateDatabusInvokeService {
|
||||
|
||||
private static final TypeReference<LinkedHashMap<String, Object>> MAP_TYPE = new TypeReference<>() {
|
||||
};
|
||||
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final String HEADER_ZT_APP_ID = "ZT-App-Id";
|
||||
private static final String HEADER_ZT_TIMESTAMP = "ZT-Timestamp";
|
||||
private static final String HEADER_ZT_NONCE = "ZT-Nonce";
|
||||
private static final String HEADER_ZT_SIGNATURE = "ZT-Signature";
|
||||
private static final String HEADER_ZT_AUTH_TOKEN = "ZT-Auth-Token";
|
||||
private static final String HEADER_CONTENT_TYPE = "Content-Type";
|
||||
private static final String TARGET_URL = "http://172.16.46.63:30081/admin-api/databus/api/portal/callback/v1";
|
||||
private static final String APP_ID = "ztmy";
|
||||
private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
|
||||
private static final String AUTH_TOKEN = "a5d7cf609c0b47038ea405c660726ee9";
|
||||
private static final String DEFAULT_HTTP_METHOD = "POST";
|
||||
private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Map<String, String> BASE_QUERY_PARAMS = Map.of(
|
||||
"businessCode", "11",
|
||||
"fileId", "11"
|
||||
);
|
||||
private static final Map<String, String> EXTRA_HEADERS = Map.of();
|
||||
private static final String DEFAULT_REQUEST_BODY = """
|
||||
{
|
||||
}
|
||||
""";
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.build();
|
||||
|
||||
private final TemplateDatabusRequestLogMapper requestLogMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@TenantIgnore
|
||||
public void invokeAndRecord() {
|
||||
TemplateDatabusRequestLogDO logDO = TemplateDatabusRequestLogDO.builder()
|
||||
.requestId(generateRequestId())
|
||||
.httpMethod(DEFAULT_HTTP_METHOD)
|
||||
.targetUrl(TARGET_URL)
|
||||
.success(Boolean.FALSE)
|
||||
.build();
|
||||
Instant start = Instant.now();
|
||||
try {
|
||||
Map<String, Object> queryParams = buildQueryParams();
|
||||
Map<String, Object> bodyParams = buildBodyParams(logDO.getRequestId());
|
||||
String requestBody = toJson(bodyParams);
|
||||
String serializedQuery = toJson(queryParams);
|
||||
logDO.setRequestBody(requestBody);
|
||||
logDO.setQueryParams(serializedQuery);
|
||||
|
||||
String timestamp = Long.toString(System.currentTimeMillis());
|
||||
logDO.setZtTimestamp(timestamp);
|
||||
String nonce = generateNonce();
|
||||
logDO.setNonce(nonce);
|
||||
String signature = generateSignature(queryParams, bodyParams, timestamp);
|
||||
logDO.setSignature(signature);
|
||||
|
||||
String encryptedBody = encryptPayload(requestBody);
|
||||
logDO.setEncryptedRequestBody(encryptedBody);
|
||||
|
||||
URI uri = buildUri(TARGET_URL, queryParams);
|
||||
logDO.setTargetUrl(uri.toString());
|
||||
Map<String, String> headers = buildHeaders(nonce, timestamp, signature);
|
||||
logDO.setRequestHeaders(toJson(headers));
|
||||
|
||||
HttpRequest request = buildHttpRequest(uri, headers, encryptedBody);
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
logDO.setResponseStatus(response.statusCode());
|
||||
logDO.setEncryptedResponseBody(response.body());
|
||||
logDO.setResponseBody(tryDecrypt(response.body()));
|
||||
boolean success = response.statusCode() >= 200 && response.statusCode() < 300;
|
||||
logDO.setSuccess(success);
|
||||
if (!success) {
|
||||
log.warn("Databus API 返回非 2xx 状态码: {}", response.statusCode());
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
logDO.setErrorMessage(truncate("Interrupted: " + ex.getMessage(), 1000));
|
||||
log.warn("Databus 调度被中断: {}", ex.getMessage());
|
||||
} catch (Exception ex) {
|
||||
logDO.setErrorMessage(truncate(ex.getMessage(), 1000));
|
||||
log.error("Databus 调度执行异常", ex);
|
||||
} finally {
|
||||
logDO.setDurationMs(Duration.between(start, Instant.now()).toMillis());
|
||||
requestLogMapper.insert(logDO);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildQueryParams() {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
BASE_QUERY_PARAMS.forEach(params::put);
|
||||
return params;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildBodyParams(String requestId) {
|
||||
Map<String, Object> body = new LinkedHashMap<>(parseTemplateBody());
|
||||
body.put("__requestId__", requestId);
|
||||
body.put("datetime", DATETIME_FORMATTER.format(LocalDateTime.now()));
|
||||
body.putIfAbsent("system", "TEMPLATE");
|
||||
return body;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseTemplateBody() {
|
||||
try {
|
||||
return objectMapper.readValue(DEFAULT_REQUEST_BODY, MAP_TYPE);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new IllegalStateException("内置 Databus 请求体 JSON 解析失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> buildHeaders(String nonce, String timestamp, String signature) {
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put(HEADER_ZT_APP_ID, APP_ID);
|
||||
headers.put(HEADER_ZT_TIMESTAMP, timestamp);
|
||||
headers.put(HEADER_ZT_NONCE, nonce);
|
||||
headers.put(HEADER_ZT_SIGNATURE, signature);
|
||||
if (StringUtils.hasText(AUTH_TOKEN)) {
|
||||
headers.put(HEADER_ZT_AUTH_TOKEN, AUTH_TOKEN);
|
||||
}
|
||||
headers.put(HEADER_CONTENT_TYPE, "application/json");
|
||||
EXTRA_HEADERS.forEach((key, value) -> {
|
||||
if (StringUtils.hasText(key) && value != null) {
|
||||
headers.put(key, value);
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
private HttpRequest buildHttpRequest(URI uri, Map<String, String> headers, String encryptedBody) {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
|
||||
.timeout(READ_TIMEOUT);
|
||||
headers.forEach(builder::header);
|
||||
HttpRequest.BodyPublisher publisher = HttpRequest.BodyPublishers.ofString(encryptedBody, StandardCharsets.UTF_8);
|
||||
switch (DEFAULT_HTTP_METHOD) {
|
||||
case "GET":
|
||||
builder.GET();
|
||||
break;
|
||||
case "PUT":
|
||||
builder.PUT(publisher);
|
||||
break;
|
||||
case "PATCH":
|
||||
builder.method("PATCH", publisher);
|
||||
break;
|
||||
case "DELETE":
|
||||
builder.method("DELETE", publisher);
|
||||
break;
|
||||
default:
|
||||
builder.POST(publisher);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private String encryptPayload(String plaintext) {
|
||||
try {
|
||||
return CryptoSignatureUtils.encrypt(plaintext, APP_SECRET, ENCRYPTION_TYPE);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("请求体加密失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String tryDecrypt(String cipherText) {
|
||||
if (!StringUtils.hasText(cipherText)) {
|
||||
return cipherText;
|
||||
}
|
||||
try {
|
||||
return CryptoSignatureUtils.decrypt(cipherText, APP_SECRET, ENCRYPTION_TYPE);
|
||||
} catch (Exception ex) {
|
||||
return "<unable to decrypt> " + ex.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private URI buildUri(String baseUrl, Map<String, Object> queryParams) {
|
||||
if (queryParams.isEmpty()) {
|
||||
return URI.create(baseUrl);
|
||||
}
|
||||
StringBuilder builder = new StringBuilder(baseUrl);
|
||||
builder.append(baseUrl.contains("?") ? '&' : '?');
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : queryParams.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append('&');
|
||||
}
|
||||
first = false;
|
||||
builder.append(encode(entry.getKey()))
|
||||
.append('=')
|
||||
.append(encode(Objects.toString(entry.getValue(), "")));
|
||||
}
|
||||
return URI.create(builder.toString());
|
||||
}
|
||||
|
||||
private String generateSignature(Map<String, Object> queryParams, Map<String, Object> bodyParams, String timestamp) {
|
||||
TreeMap<String, Object> sorted = new TreeMap<>();
|
||||
queryParams.forEach((key, value) -> sorted.put(key, normalizeValue(value)));
|
||||
bodyParams.forEach((key, value) -> sorted.put(key, normalizeValue(value)));
|
||||
sorted.put(HEADER_ZT_APP_ID, APP_ID);
|
||||
sorted.put(HEADER_ZT_TIMESTAMP, timestamp);
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
sorted.forEach((key, value) -> {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (canonical.length() > 0) {
|
||||
canonical.append('&');
|
||||
}
|
||||
canonical.append(key).append('=').append(value);
|
||||
});
|
||||
return md5Hex(canonical.toString());
|
||||
}
|
||||
|
||||
private Object normalizeValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Map || value instanceof Iterable) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException ex) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String md5Hex(String source) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||
byte[] bytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(bytes.length * 2);
|
||||
for (byte aByte : bytes) {
|
||||
String part = Integer.toHexString(aByte & 0xFF);
|
||||
if (part.length() == 1) {
|
||||
builder.append('0');
|
||||
}
|
||||
builder.append(part);
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("MD5 算法不可用", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String generateRequestId() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private String generateNonce() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException ex) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (!StringUtils.hasText(value) || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
CREATE TABLE template_databus_request_log (
|
||||
ID BIGINT NOT NULL,
|
||||
REQUEST_ID VARCHAR(64) NOT NULL,
|
||||
TARGET_URL VARCHAR(512) NOT NULL,
|
||||
HTTP_METHOD VARCHAR(16) NOT NULL,
|
||||
QUERY_PARAMS TEXT,
|
||||
REQUEST_HEADERS TEXT,
|
||||
REQUEST_BODY TEXT,
|
||||
ENCRYPTED_REQUEST_BODY TEXT,
|
||||
SIGNATURE VARCHAR(128),
|
||||
NONCE VARCHAR(64),
|
||||
ZT_TIMESTAMP VARCHAR(32),
|
||||
RESPONSE_STATUS INT,
|
||||
ENCRYPTED_RESPONSE_BODY TEXT,
|
||||
RESPONSE_BODY TEXT,
|
||||
SUCCESS BIT DEFAULT '0' NOT NULL,
|
||||
ERROR_MESSAGE TEXT,
|
||||
DURATION_MS BIGINT,
|
||||
CREATE_TIME DATETIME(6) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
UPDATE_TIME DATETIME(6) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CREATOR VARCHAR(64),
|
||||
UPDATER VARCHAR(64),
|
||||
DELETED BIT DEFAULT '0' NOT NULL,
|
||||
PRIMARY KEY (ID)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE template_databus_request_log IS 'Databus 请求调度日志表';
|
||||
COMMENT ON COLUMN template_databus_request_log.ID IS '主键';
|
||||
COMMENT ON COLUMN template_databus_request_log.REQUEST_ID IS '请求唯一标识';
|
||||
COMMENT ON COLUMN template_databus_request_log.TARGET_URL IS '目标地址(含 Query)';
|
||||
COMMENT ON COLUMN template_databus_request_log.HTTP_METHOD IS 'HTTP 方法';
|
||||
COMMENT ON COLUMN template_databus_request_log.QUERY_PARAMS IS 'Query 参数 JSON';
|
||||
COMMENT ON COLUMN template_databus_request_log.REQUEST_HEADERS IS '请求头 JSON';
|
||||
COMMENT ON COLUMN template_databus_request_log.REQUEST_BODY IS '原始请求体';
|
||||
COMMENT ON COLUMN template_databus_request_log.ENCRYPTED_REQUEST_BODY IS '加密请求体';
|
||||
COMMENT ON COLUMN template_databus_request_log.SIGNATURE IS '签名';
|
||||
COMMENT ON COLUMN template_databus_request_log.NONCE IS '随机串';
|
||||
COMMENT ON COLUMN template_databus_request_log.ZT_TIMESTAMP IS '时间戳';
|
||||
COMMENT ON COLUMN template_databus_request_log.RESPONSE_STATUS IS '响应状态码';
|
||||
COMMENT ON COLUMN template_databus_request_log.ENCRYPTED_RESPONSE_BODY IS '加密响应体';
|
||||
COMMENT ON COLUMN template_databus_request_log.RESPONSE_BODY IS '解密后的响应体';
|
||||
COMMENT ON COLUMN template_databus_request_log.SUCCESS IS '是否成功';
|
||||
COMMENT ON COLUMN template_databus_request_log.ERROR_MESSAGE IS '错误信息';
|
||||
COMMENT ON COLUMN template_databus_request_log.DURATION_MS IS '耗时(毫秒)';
|
||||
COMMENT ON COLUMN template_databus_request_log.CREATE_TIME IS '创建时间';
|
||||
COMMENT ON COLUMN template_databus_request_log.UPDATE_TIME IS '更新时间';
|
||||
COMMENT ON COLUMN template_databus_request_log.CREATOR IS '创建人';
|
||||
COMMENT ON COLUMN template_databus_request_log.UPDATER IS '更新人';
|
||||
COMMENT ON COLUMN template_databus_request_log.DELETED IS '逻辑删除标记';
|
||||
Reference in New Issue
Block a user