From 457e43fca832192c1f2c00ff297e932502a6dc8e Mon Sep 17 00:00:00 2001 From: wangxiu <1422795692@qq.com> Date: Thu, 9 Jul 2026 12:28:44 +0800 Subject: [PATCH] init commit --- .gitignore | 35 ++++ Dockerfile | 8 + pom.xml | 81 ++++++++++ .../docking/LyxhApiDockingApplication.java | 14 ++ .../lyxh/api/docking/auth/AuthStrategy.java | 15 ++ .../api/docking/auth/AuthStrategyFactory.java | 30 ++++ .../auth/FortispayHmacAuthStrategy.java | 59 +++++++ .../api/docking/client/ApiCallResult.java | 24 +++ .../api/docking/client/GenericApiClient.java | 151 ++++++++++++++++++ .../lyxh/api/docking/config/CorsConfig.java | 27 ++++ .../api/docking/config/MybatisPlusConfig.java | 20 +++ .../lyxh/api/docking/config/QuartzConfig.java | 103 ++++++++++++ .../controller/ftapi/FortispayController.java | 38 +++++ .../thirdparty/ApiCacheDataController.java | 62 +++++++ .../thirdparty/ApiInvokeLogController.java | 66 ++++++++ .../ThirdPartyConfigController.java | 64 ++++++++ .../lyxh/api/docking/entity/ApiCacheData.java | 50 ++++++ .../lyxh/api/docking/entity/ApiInvokeLog.java | 68 ++++++++ .../api/docking/entity/ThirdPartyConfig.java | 70 ++++++++ .../com/lyxh/api/docking/enums/ApiType.java | 31 ++++ .../docking/exception/ApiCallException.java | 26 +++ .../api/docking/exception/ErrorResponse.java | 17 ++ .../exception/GlobalExceptionHandler.java | 46 ++++++ .../lyxh/api/docking/job/CacheCleanupJob.java | 33 ++++ .../lyxh/api/docking/job/LogCleanupJob.java | 42 +++++ .../docking/mapper/ApiCacheDataMapper.java | 18 +++ .../docking/mapper/ApiInvokeLogMapper.java | 9 ++ .../mapper/ThirdPartyConfigMapper.java | 9 ++ .../docking/model/ftapi/FortispayRequest.java | 20 +++ .../model/ftapi/PaymentProfileRequest.java | 23 +++ .../model/ftapi/PaymentProfileResponse.java | 14 ++ .../docking/service/ApiCacheDataService.java | 20 +++ .../docking/service/ApiInvokeLogService.java | 7 + .../docking/service/GenericApiService.java | 150 +++++++++++++++++ .../service/PaymentProfileService.java | 135 ++++++++++++++++ .../api/docking/service/SignatureService.java | 30 ++++ .../service/ThirdPartyConfigService.java | 10 ++ .../service/impl/ApiCacheDataServiceImpl.java | 51 ++++++ .../service/impl/ApiInvokeLogServiceImpl.java | 13 ++ .../impl/ThirdPartyConfigServiceImpl.java | 24 +++ .../api/docking/util/AuthConfigCrypto.java | 56 +++++++ .../api/docking/util/AuthConfigEncryptor.java | 45 ++++++ .../api/docking/util/HeaderDesensitizer.java | 44 +++++ src/main/resources/application.yml | 34 ++++ .../resources/mapper/ApiCacheDataMapper.xml | 22 +++ .../resources/mapper/ApiInvokeLogMapper.xml | 5 + .../mapper/ThirdPartyConfigMapper.xml | 5 + src/main/resources/sql/init_data.sql | 36 +++++ src/main/resources/sql/schema.sql | 71 ++++++++ 对接接口文档.md | 146 +++++++++++++++++ 50 files changed, 2177 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 pom.xml create mode 100644 src/main/java/com/lyxh/api/docking/LyxhApiDockingApplication.java create mode 100644 src/main/java/com/lyxh/api/docking/auth/AuthStrategy.java create mode 100644 src/main/java/com/lyxh/api/docking/auth/AuthStrategyFactory.java create mode 100644 src/main/java/com/lyxh/api/docking/auth/FortispayHmacAuthStrategy.java create mode 100644 src/main/java/com/lyxh/api/docking/client/ApiCallResult.java create mode 100644 src/main/java/com/lyxh/api/docking/client/GenericApiClient.java create mode 100644 src/main/java/com/lyxh/api/docking/config/CorsConfig.java create mode 100644 src/main/java/com/lyxh/api/docking/config/MybatisPlusConfig.java create mode 100644 src/main/java/com/lyxh/api/docking/config/QuartzConfig.java create mode 100644 src/main/java/com/lyxh/api/docking/controller/ftapi/FortispayController.java create mode 100644 src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiCacheDataController.java create mode 100644 src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiInvokeLogController.java create mode 100644 src/main/java/com/lyxh/api/docking/controller/thirdparty/ThirdPartyConfigController.java create mode 100644 src/main/java/com/lyxh/api/docking/entity/ApiCacheData.java create mode 100644 src/main/java/com/lyxh/api/docking/entity/ApiInvokeLog.java create mode 100644 src/main/java/com/lyxh/api/docking/entity/ThirdPartyConfig.java create mode 100644 src/main/java/com/lyxh/api/docking/enums/ApiType.java create mode 100644 src/main/java/com/lyxh/api/docking/exception/ApiCallException.java create mode 100644 src/main/java/com/lyxh/api/docking/exception/ErrorResponse.java create mode 100644 src/main/java/com/lyxh/api/docking/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/lyxh/api/docking/job/CacheCleanupJob.java create mode 100644 src/main/java/com/lyxh/api/docking/job/LogCleanupJob.java create mode 100644 src/main/java/com/lyxh/api/docking/mapper/ApiCacheDataMapper.java create mode 100644 src/main/java/com/lyxh/api/docking/mapper/ApiInvokeLogMapper.java create mode 100644 src/main/java/com/lyxh/api/docking/mapper/ThirdPartyConfigMapper.java create mode 100644 src/main/java/com/lyxh/api/docking/model/ftapi/FortispayRequest.java create mode 100644 src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileRequest.java create mode 100644 src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileResponse.java create mode 100644 src/main/java/com/lyxh/api/docking/service/ApiCacheDataService.java create mode 100644 src/main/java/com/lyxh/api/docking/service/ApiInvokeLogService.java create mode 100644 src/main/java/com/lyxh/api/docking/service/GenericApiService.java create mode 100644 src/main/java/com/lyxh/api/docking/service/PaymentProfileService.java create mode 100644 src/main/java/com/lyxh/api/docking/service/SignatureService.java create mode 100644 src/main/java/com/lyxh/api/docking/service/ThirdPartyConfigService.java create mode 100644 src/main/java/com/lyxh/api/docking/service/impl/ApiCacheDataServiceImpl.java create mode 100644 src/main/java/com/lyxh/api/docking/service/impl/ApiInvokeLogServiceImpl.java create mode 100644 src/main/java/com/lyxh/api/docking/service/impl/ThirdPartyConfigServiceImpl.java create mode 100644 src/main/java/com/lyxh/api/docking/util/AuthConfigCrypto.java create mode 100644 src/main/java/com/lyxh/api/docking/util/AuthConfigEncryptor.java create mode 100644 src/main/java/com/lyxh/api/docking/util/HeaderDesensitizer.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/mapper/ApiCacheDataMapper.xml create mode 100644 src/main/resources/mapper/ApiInvokeLogMapper.xml create mode 100644 src/main/resources/mapper/ThirdPartyConfigMapper.xml create mode 100644 src/main/resources/sql/init_data.sql create mode 100644 src/main/resources/sql/schema.sql create mode 100644 对接接口文档.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ae0b7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ +sh +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46613a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:17.0.2-slim + +WORKDIR /app/api +COPY target/lyxh-api-docking-1.0.0.jar /app/api/lyxh-api-docking.jar + +EXPOSE 8049 + +ENTRYPOINT ["java", "-jar", "/app/api/lyxh-api-docking.jar"] \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..299529e --- /dev/null +++ b/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.7 + + + + com.lyxh + lyxh-api-docking + 1.0.0 + lyxh-api-docking + Fortispay API docking service + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.7 + + + com.mysql + mysql-connector-j + runtime + + + commons-codec + commons-codec + + + org.springframework.boot + spring-boot-starter-quartz + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/lyxh/api/docking/LyxhApiDockingApplication.java b/src/main/java/com/lyxh/api/docking/LyxhApiDockingApplication.java new file mode 100644 index 0000000..201a625 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/LyxhApiDockingApplication.java @@ -0,0 +1,14 @@ +package com.lyxh.api.docking; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("com.lyxh.api.docking.mapper") +public class LyxhApiDockingApplication { + + public static void main(String[] args) { + SpringApplication.run(LyxhApiDockingApplication.class, args); + } +} diff --git a/src/main/java/com/lyxh/api/docking/auth/AuthStrategy.java b/src/main/java/com/lyxh/api/docking/auth/AuthStrategy.java new file mode 100644 index 0000000..2ebb23e --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/auth/AuthStrategy.java @@ -0,0 +1,15 @@ +package com.lyxh.api.docking.auth; + +import org.springframework.http.HttpHeaders; + +/** + * 认证策略接口 - 根据 auth_config JSON 构建请求头 + */ +public interface AuthStrategy { + + /** 返回此策略对应的 auth_type 标识 */ + String getAuthType(); + + /** 根据 auth_config、请求唯一标识、语言标识构建认证相关的 HTTP 请求头 */ + HttpHeaders prepareHeaders(String authConfigJson, String requestId, String language); +} diff --git a/src/main/java/com/lyxh/api/docking/auth/AuthStrategyFactory.java b/src/main/java/com/lyxh/api/docking/auth/AuthStrategyFactory.java new file mode 100644 index 0000000..19a68a9 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/auth/AuthStrategyFactory.java @@ -0,0 +1,30 @@ +package com.lyxh.api.docking.auth; + +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 认证策略工厂 - 根据 auth_type 返回对应的 AuthStrategy 实现 + */ +@Component +public class AuthStrategyFactory { + + private final Map strategyMap; + + public AuthStrategyFactory(List strategies) { + this.strategyMap = strategies.stream() + .collect(Collectors.toMap(AuthStrategy::getAuthType, Function.identity())); + } + + public AuthStrategy getStrategy(String authType) { + AuthStrategy strategy = strategyMap.get(authType); + if (strategy == null) { + throw new IllegalArgumentException("Unsupported auth_type: " + authType); + } + return strategy; + } +} diff --git a/src/main/java/com/lyxh/api/docking/auth/FortispayHmacAuthStrategy.java b/src/main/java/com/lyxh/api/docking/auth/FortispayHmacAuthStrategy.java new file mode 100644 index 0000000..36926db --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/auth/FortispayHmacAuthStrategy.java @@ -0,0 +1,59 @@ +package com.lyxh.api.docking.auth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lyxh.api.docking.exception.ApiCallException; +import com.lyxh.api.docking.service.SignatureService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +/** + * HMAC-SHA256 签名认证(富通等第三方接口) + * + * auth_config 格式: {"secret_id":"xxx","secret_key":"yyy"} + * 在请求头中添加: requestId, secretId, timestamp, signature + */ +@Component +public class FortispayHmacAuthStrategy implements AuthStrategy { + + private final SignatureService signatureService; + private final ObjectMapper objectMapper; + + public FortispayHmacAuthStrategy(SignatureService signatureService, ObjectMapper objectMapper) { + this.signatureService = signatureService; + this.objectMapper = objectMapper; + } + + @Override + public String getAuthType() { + return "FORTISPAY_HMAC"; + } + + @Override + public HttpHeaders prepareHeaders(String authConfigJson, String requestId, String language) { + long timestamp = signatureService.currentTimestamp(); + + JsonNode node; + try { + node = objectMapper.readTree(authConfigJson); + } catch (Exception e) { + throw new ApiCallException("AUTH_CONFIG_ERROR", + "Failed to parse auth_config: " + e.getMessage(), ""); + } + String secretId = node.get("secret_id").asText(); + String secretKey = node.get("secret_key").asText(); + String signature = signatureService.generateSignature(requestId, timestamp, secretId, secretKey); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("requestId", requestId); + headers.set("secretId", secretId); + headers.set("timestamp", String.valueOf(timestamp)); + headers.set("signature", signature); + if (language != null && !language.isBlank()) { + headers.set("language", language); + } + return headers; + } +} diff --git a/src/main/java/com/lyxh/api/docking/client/ApiCallResult.java b/src/main/java/com/lyxh/api/docking/client/ApiCallResult.java new file mode 100644 index 0000000..c121cde --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/client/ApiCallResult.java @@ -0,0 +1,24 @@ +package com.lyxh.api.docking.client; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApiCallResult { + + /** 请求唯一标识 */ + private final String requestId; + /** 实际请求URL */ + private final String url; + /** 请求头JSON */ + private final String reqHeadersJson; + /** 请求体JSON */ + private final String reqBodyJson; + /** 响应体原始JSON */ + private final String respBodyJson; + /** HTTP状态码 */ + private final int httpStatusCode; + /** 请求耗时(毫秒) */ + private final long costMillis; +} diff --git a/src/main/java/com/lyxh/api/docking/client/GenericApiClient.java b/src/main/java/com/lyxh/api/docking/client/GenericApiClient.java new file mode 100644 index 0000000..933032b --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/client/GenericApiClient.java @@ -0,0 +1,151 @@ +package com.lyxh.api.docking.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lyxh.api.docking.auth.AuthStrategy; +import com.lyxh.api.docking.auth.AuthStrategyFactory; +import com.lyxh.api.docking.entity.ThirdPartyConfig; +import com.lyxh.api.docking.exception.ApiCallException; +import com.lyxh.api.docking.util.AuthConfigCrypto; +import com.lyxh.api.docking.util.HeaderDesensitizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.time.Duration; + +@Component +public class GenericApiClient { + + private static final Logger log = LoggerFactory.getLogger(GenericApiClient.class); + + private final AuthStrategyFactory authFactory; + private final AuthConfigCrypto crypto; + private final HeaderDesensitizer desensitizer; + private final ObjectMapper objectMapper; + + public GenericApiClient(AuthStrategyFactory authFactory, AuthConfigCrypto crypto, + HeaderDesensitizer desensitizer, ObjectMapper objectMapper) { + this.authFactory = authFactory; + this.crypto = crypto; + this.desensitizer = desensitizer; + this.objectMapper = objectMapper; + } + + /** + * 根据 DB 配置调用第三方 API,支持超时、代理、重试,auth_config 解密,请求头脱敏 + */ + public ApiCallResult call(ThirdPartyConfig config, Object requestBody, + String requestId, String language, String authType) { + String url = config.getApiUrl(); + String authConfigDecrypted = crypto.decrypt(config.getAuthConfig()); + AuthStrategy authStrategy = authFactory.getStrategy(authType); + HttpHeaders headers = authStrategy.prepareHeaders(authConfigDecrypted, requestId, language); + + String reqBodyJson = toJson(requestBody); + String reqHeadersJson = toJson(desensitizer.mask(headersToMap(headers))); + + int timeoutSeconds = config.getTimeoutSeconds() != null ? config.getTimeoutSeconds() : 30; + int maxRetries = config.getRetryTimes() != null ? config.getRetryTimes() : 0; + RestTemplate rt = buildRestTemplate(config, timeoutSeconds); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + log.info("Calling third-party API: url={} requestId={} authType={} timeout={}s retries={}", + url, requestId, authType, timeoutSeconds, maxRetries); + long start = System.currentTimeMillis(); + + int attempt = 0; + while (true) { + attempt++; + try { + ResponseEntity response = rt.exchange( + url, HttpMethod.POST, entity, String.class); + + String respBody = response.getBody(); + int httpStatus = response.getStatusCode().value(); + long elapsed = System.currentTimeMillis() - start; + log.info("Third-party response: url={} requestId={} httpStatus={} latencyMs={}", + url, requestId, httpStatus, elapsed); + + if (respBody == null) { + throw new ApiCallException("NULL_RESPONSE", "Empty response body", "") + .withRequest(url, reqHeadersJson); + } + + return new ApiCallResult(requestId, url, reqHeadersJson, reqBodyJson, + respBody, httpStatus, elapsed); + + } catch (ApiCallException e) { + throw e; + } catch (ResourceAccessException e) { + if (attempt <= maxRetries) { + log.warn("Network error, retrying {}/{}: url={} requestId={}", + attempt, maxRetries + 1, url, requestId); + } else { + long elapsed = System.currentTimeMillis() - start; + log.error("Third-party network error after {} attempts: url={} requestId={}", + attempt, url, requestId, e); + throw new ApiCallException("NETWORK_ERROR", + "External API unreachable after " + attempt + " attempts", "") + .withRequest(url, reqHeadersJson); + } + } catch (RestClientException e) { + if (attempt <= maxRetries) { + log.warn("HTTP error, retrying {}/{}: url={} requestId={} error={}", + attempt, maxRetries + 1, url, requestId, e.getMessage()); + } else { + long elapsed = System.currentTimeMillis() - start; + log.error("Third-party HTTP error after {} attempts: url={} requestId={}", + attempt, url, requestId, e); + throw new ApiCallException("HTTP_ERROR", e.getMessage(), "") + .withRequest(url, reqHeadersJson); + } + } + } + } + + private RestTemplate buildRestTemplate(ThirdPartyConfig config, int timeoutSeconds) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + Duration timeout = Duration.ofSeconds(timeoutSeconds); + factory.setConnectTimeout(timeout); + factory.setReadTimeout(timeout); + + if (config.getProxyHost() != null && !config.getProxyHost().isBlank()) { + String[] parts = config.getProxyHost().split(":"); + String host = parts[0]; + // 代理端口不确定,此处后续可做配置优化 + int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 8080; + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); + factory.setProxy(proxy); + log.info("Using proxy: {}:{}", host, port); + } + + return new RestTemplate(factory); + } + + private java.util.Map headersToMap(HttpHeaders headers) { + java.util.LinkedHashMap map = new java.util.LinkedHashMap<>(); + headers.forEach((key, values) -> { + if (!values.isEmpty()) { + map.put(key, String.join(", ", values)); + } + }); + return map; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/lyxh/api/docking/config/CorsConfig.java b/src/main/java/com/lyxh/api/docking/config/CorsConfig.java new file mode 100644 index 0000000..2ebbc83 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/config/CorsConfig.java @@ -0,0 +1,27 @@ +package com.lyxh.api.docking.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 跨域配置 + */ +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOriginPattern("*"); + config.addAllowedMethod("*"); + config.addAllowedHeader("*"); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/src/main/java/com/lyxh/api/docking/config/MybatisPlusConfig.java b/src/main/java/com/lyxh/api/docking/config/MybatisPlusConfig.java new file mode 100644 index 0000000..e26df20 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/config/MybatisPlusConfig.java @@ -0,0 +1,20 @@ +package com.lyxh.api.docking.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +/** + * 分页插件配置 + */ +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/src/main/java/com/lyxh/api/docking/config/QuartzConfig.java b/src/main/java/com/lyxh/api/docking/config/QuartzConfig.java new file mode 100644 index 0000000..a23cc81 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/config/QuartzConfig.java @@ -0,0 +1,103 @@ +package com.lyxh.api.docking.config; + +import com.lyxh.api.docking.job.CacheCleanupJob; +import com.lyxh.api.docking.job.LogCleanupJob; +import org.quartz.JobDetail; +import org.quartz.Trigger; +import org.quartz.spi.TriggerFiredBundle; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.scheduling.quartz.SpringBeanJobFactory; + +import java.util.TimeZone; + +/** + * 定时任务配置 + */ +@Configuration +public class QuartzConfig { + + @Value("${schedule.log-cleanup.cron:0 0 3 ? * SUN}") + private String logCleanupCron; + + @Value("${schedule.cache-cleanup.cron:0 */30 * * * ?}") + private String cacheCleanupCron; + + @Bean + public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) { + AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory(); + jobFactory.setApplicationContext(applicationContext); + return jobFactory; + } + + @Bean + public SchedulerFactoryBean schedulerFactoryBean(SpringBeanJobFactory jobFactory, + JobDetail logCleanupJobDetail, + JobDetail cacheCleanupJobDetail, + Trigger logCleanupTrigger, + Trigger cacheCleanupTrigger) { + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + factory.setJobFactory(jobFactory); + factory.setJobDetails(logCleanupJobDetail, cacheCleanupJobDetail); + factory.setTriggers(logCleanupTrigger, cacheCleanupTrigger); + factory.setOverwriteExistingJobs(true); + return factory; + } + + @Bean + public JobDetailFactoryBean logCleanupJobDetail() { + JobDetailFactoryBean factory = new JobDetailFactoryBean(); + factory.setJobClass(LogCleanupJob.class); + factory.setDurability(true); + return factory; + } + + @Bean + public JobDetailFactoryBean cacheCleanupJobDetail() { + JobDetailFactoryBean factory = new JobDetailFactoryBean(); + factory.setJobClass(CacheCleanupJob.class); + factory.setDurability(true); + return factory; + } + + @Bean + public CronTriggerFactoryBean logCleanupTrigger(JobDetail logCleanupJobDetail) { + CronTriggerFactoryBean factory = new CronTriggerFactoryBean(); + factory.setJobDetail(logCleanupJobDetail); + factory.setCronExpression(logCleanupCron); + factory.setTimeZone(TimeZone.getDefault()); + return factory; + } + + @Bean + public CronTriggerFactoryBean cacheCleanupTrigger(JobDetail cacheCleanupJobDetail) { + CronTriggerFactoryBean factory = new CronTriggerFactoryBean(); + factory.setJobDetail(cacheCleanupJobDetail); + factory.setCronExpression(cacheCleanupCron); + factory.setTimeZone(TimeZone.getDefault()); + return factory; + } + + /** + * 支持在 Quartz Job 中注入 Spring Bean + */ + static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory { + private ApplicationContext applicationContext; + + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { + Object job = super.createJobInstance(bundle); + applicationContext.getAutowireCapableBeanFactory().autowireBean(job); + return job; + } + } +} diff --git a/src/main/java/com/lyxh/api/docking/controller/ftapi/FortispayController.java b/src/main/java/com/lyxh/api/docking/controller/ftapi/FortispayController.java new file mode 100644 index 0000000..49a9d9b --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/controller/ftapi/FortispayController.java @@ -0,0 +1,38 @@ +package com.lyxh.api.docking.controller.ftapi; + +import com.lyxh.api.docking.model.ftapi.PaymentProfileRequest; +import com.lyxh.api.docking.model.ftapi.PaymentProfileResponse; +import com.lyxh.api.docking.service.PaymentProfileService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * Fortispay 对接接口 + */ +@RestController +@RequestMapping("/api/v1/fortispay") +public class FortispayController { + + @Autowired + private PaymentProfileService paymentProfileService; + + /** 印尼支付行为画像 - v1 */ + @PostMapping("/payment-feature") + public ResponseEntity getPaymentFeature( + @Valid @RequestBody PaymentProfileRequest request, + @RequestHeader(value = "X-Request-Id", required = false) String xRequestId, + @RequestHeader(value = "X-Language", required = false) String language) { + String weOrderNo = UUID.randomUUID().toString().replace("-", ""); + PaymentProfileResponse body = paymentProfileService.query(request, xRequestId, language, weOrderNo); + ResponseEntity.BodyBuilder builder = ResponseEntity.ok() + .header("X-Request-Id", xRequestId != null ? xRequestId : weOrderNo); + if (body.getResponseId() != null && !body.getResponseId().isEmpty()) { + builder.header("X-Session-Id", body.getResponseId()); + } + return builder.body(body); + } +} diff --git a/src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiCacheDataController.java b/src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiCacheDataController.java new file mode 100644 index 0000000..f2c0b4b --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiCacheDataController.java @@ -0,0 +1,62 @@ +package com.lyxh.api.docking.controller.thirdparty; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.lyxh.api.docking.entity.ApiCacheData; +import com.lyxh.api.docking.service.ApiCacheDataService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 三方API结果缓存管理 + */ +@RestController +@RequestMapping("/api/v1/cache-data") +public class ApiCacheDataController { + + @Autowired + private ApiCacheDataService apiCacheDataServiceImpl; + + /** 新增缓存记录 */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody ApiCacheData cache) { + apiCacheDataServiceImpl.save(cache); + return ResponseEntity.ok(cache); + } + + /** 更新缓存记录 */ + @PutMapping("/{id}") + public ResponseEntity update(@RequestBody ApiCacheData cache) { + apiCacheDataServiceImpl.updateById(cache); + return ResponseEntity.ok(cache); + } + + /** 删除缓存记录 */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + apiCacheDataServiceImpl.removeById(id); + return ResponseEntity.ok().build(); + } + + /** 根据ID查询缓存记录 */ + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return ResponseEntity.ok(apiCacheDataServiceImpl.getById(id)); + } + + /** 分页查询缓存记录 */ + @GetMapping + public ResponseEntity> page( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String apiCode, + @RequestParam(required = false) Long configId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(apiCode != null, ApiCacheData::getApiCode, apiCode) + .eq(configId != null, ApiCacheData::getConfigId, configId) + .orderByDesc(ApiCacheData::getId); + return ResponseEntity.ok(apiCacheDataServiceImpl.page(new Page<>(page, size), wrapper)); + } +} diff --git a/src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiInvokeLogController.java b/src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiInvokeLogController.java new file mode 100644 index 0000000..95b26fa --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiInvokeLogController.java @@ -0,0 +1,66 @@ +package com.lyxh.api.docking.controller.thirdparty; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.lyxh.api.docking.entity.ApiInvokeLog; +import com.lyxh.api.docking.service.ApiInvokeLogService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * API调用日志管理 + */ +@RestController +@RequestMapping("/api/v1/invoke-log") +public class ApiInvokeLogController { + + @Autowired + private ApiInvokeLogService apiInvokeLogServiceImpl; + + /** 新增调用日志 */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody ApiInvokeLog log) { + apiInvokeLogServiceImpl.save(log); + return ResponseEntity.ok(log); + } + + /** 更新调用日志 */ + @PutMapping("/{id}") + public ResponseEntity update(@RequestBody ApiInvokeLog log) { + apiInvokeLogServiceImpl.updateById(log); + return ResponseEntity.ok(log); + } + + /** 删除调用日志 */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + apiInvokeLogServiceImpl.removeById(id); + return ResponseEntity.ok().build(); + } + + /** 根据ID查询调用日志 */ + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return ResponseEntity.ok(apiInvokeLogServiceImpl.getById(id)); + } + + /** 分页查询调用日志 */ + @GetMapping + public ResponseEntity> page( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String thirdPartyCode, + @RequestParam(required = false) String apiCode, + @RequestParam(required = false) String weOrderNo, + @RequestParam(required = false) String csOrderNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(thirdPartyCode != null, ApiInvokeLog::getThirdPartyCode, thirdPartyCode) + .eq(apiCode != null, ApiInvokeLog::getApiCode, apiCode) + .eq(weOrderNo != null, ApiInvokeLog::getWeOrderNo, weOrderNo) + .eq(csOrderNo != null, ApiInvokeLog::getCsOrderNo, csOrderNo) + .orderByDesc(ApiInvokeLog::getId); + return ResponseEntity.ok(apiInvokeLogServiceImpl.page(new Page<>(page, size), wrapper)); + } +} diff --git a/src/main/java/com/lyxh/api/docking/controller/thirdparty/ThirdPartyConfigController.java b/src/main/java/com/lyxh/api/docking/controller/thirdparty/ThirdPartyConfigController.java new file mode 100644 index 0000000..dd2cc3e --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/controller/thirdparty/ThirdPartyConfigController.java @@ -0,0 +1,64 @@ +package com.lyxh.api.docking.controller.thirdparty; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.lyxh.api.docking.entity.ThirdPartyConfig; +import com.lyxh.api.docking.service.ThirdPartyConfigService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 三方API配置管理 + */ +@RestController +@RequestMapping("/api/v1/third-party-config") +public class ThirdPartyConfigController { + + @Autowired + private ThirdPartyConfigService thirdPartyConfigServiceImpl; + + /** 新增配置 */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody ThirdPartyConfig config) { + thirdPartyConfigServiceImpl.save(config); + return ResponseEntity.ok(config); + } + + /** 更新配置 */ + @PutMapping("/{id}") + public ResponseEntity update(@RequestBody ThirdPartyConfig config) { + thirdPartyConfigServiceImpl.updateById(config); + return ResponseEntity.ok(config); + } + + /** 删除配置 */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + thirdPartyConfigServiceImpl.removeById(id); + return ResponseEntity.ok().build(); + } + + /** 根据ID查询配置 */ + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return ResponseEntity.ok(thirdPartyConfigServiceImpl.getById(id)); + } + + /** 分页查询配置列表 */ + @GetMapping + public ResponseEntity> page( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String configGroup, + @RequestParam(required = false) String thirdPartyCode, + @RequestParam(required = false) String apiCode) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(configGroup != null, ThirdPartyConfig::getConfigGroup, configGroup) + .eq(thirdPartyCode != null, ThirdPartyConfig::getThirdPartyCode, thirdPartyCode) + .eq(apiCode != null, ThirdPartyConfig::getApiCode, apiCode) + .orderByDesc(ThirdPartyConfig::getId); + return ResponseEntity.ok(thirdPartyConfigServiceImpl.page(new Page<>(page, size), wrapper)); + } +} diff --git a/src/main/java/com/lyxh/api/docking/entity/ApiCacheData.java b/src/main/java/com/lyxh/api/docking/entity/ApiCacheData.java new file mode 100644 index 0000000..1942fdc --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/entity/ApiCacheData.java @@ -0,0 +1,50 @@ +package com.lyxh.api.docking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 三方API结果缓存表 + */ +@Data +@TableName("api_cache_data") +public class ApiCacheData { + + /** 主键 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联 third_party_config.id */ + private Long configId; + + /** 接口标识 */ + private String apiCode; + + /** 缓存键 */ + private String cacheKey; + + /** 缓存的响应体 */ + private String cacheValue; + + /** 触发本次缓存的客户订单号 */ + private String csOrderNo; + + /** 触发本次缓存的系统订单号 */ + private String weOrderNo; + + /** 本次缓存对应的三方订单号 */ + private String dsOrderNo; + + /** 缓存过期时间 */ + private LocalDateTime expireTime; + + /** 创建时间 */ + private LocalDateTime createTime; + + /** 更新时间 */ + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/lyxh/api/docking/entity/ApiInvokeLog.java b/src/main/java/com/lyxh/api/docking/entity/ApiInvokeLog.java new file mode 100644 index 0000000..fe0396a --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/entity/ApiInvokeLog.java @@ -0,0 +1,68 @@ +package com.lyxh.api.docking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 三方API调用日志表 + */ +@Data +@TableName("api_invoke_log") +public class ApiInvokeLog { + + /** 主键 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联 third_party_config.id */ + private Long configId; + + /** 第三方公司标识(冗余,方便查询) */ + private String thirdPartyCode; + + /** 接口标识 */ + private String apiCode; + + /** 客户订单号(上游网关传入) */ + private String csOrderNo; + + /** 系统订单号(本服务生成) */ + private String weOrderNo; + + /** 三方订单号(下游三方返回) */ + private String dsOrderNo; + + /** 实际调用的三方地址 */ + private String reqUrl; + + /** 请求方法 */ + private String reqMethod; + + /** 请求头(JSON,注意脱敏) */ + private String reqHeaders; + + /** 请求体 */ + private String reqBody; + + /** HTTP状态码 */ + private Integer respStatusCode; + + /** 响应头(JSON) */ + private String respHeaders; + + /** 响应体 */ + private String respBody; + + /** 异常信息(超时、连接失败等) */ + private String errorMessage; + + /** 请求耗时(毫秒) */ + private Integer costMillis; + + /** 创建时间 */ + private LocalDateTime createTime; +} diff --git a/src/main/java/com/lyxh/api/docking/entity/ThirdPartyConfig.java b/src/main/java/com/lyxh/api/docking/entity/ThirdPartyConfig.java new file mode 100644 index 0000000..03c80ab --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/entity/ThirdPartyConfig.java @@ -0,0 +1,70 @@ +package com.lyxh.api.docking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 三方API配置表 + */ +@Data +@TableName("third_party_config") +public class ThirdPartyConfig { + + /** 主键 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 配置分组,用于区分不同业务线/租户 */ + private String configGroup; + + /** 第三方公司标识 */ + private String thirdPartyCode; + + /** 接口唯一标识 */ + private String apiCode; + + /** 接口名称/描述 */ + private String apiName; + + /** 状态:1-启用,0-禁用 */ + private Integer status; + + /** 接口地址(完整URL) */ + private String apiUrl; + + /** 认证配置(JSON) */ + private String authConfig; + + /** 请求超时(秒) */ + private Integer timeoutSeconds; + + /** 失败重试次数 */ + private Integer retryTimes; + + /** 代理转发地址 */ + private String proxyHost; + + /** 是否启用结果缓存:1-是,0-否 */ + private Integer enableCache; + + /** 缓存有效期(天) */ + private Integer cacheTtlDays; + + /** 是否缓存未查得及调用失败结果:1-是,0-否 */ + private Integer cacheEmptyResult; + + /** 创建时间 */ + private LocalDateTime createTime; + + /** 修改时间 */ + private LocalDateTime modifyTime; + + /** 逻辑删除:0-正常,1-已删除 */ + @TableLogic + private Integer deleteFlag; +} diff --git a/src/main/java/com/lyxh/api/docking/enums/ApiType.java b/src/main/java/com/lyxh/api/docking/enums/ApiType.java new file mode 100644 index 0000000..c496941 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/enums/ApiType.java @@ -0,0 +1,31 @@ +package com.lyxh.api.docking.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 已知的第三方API接口枚举,后期扩展新接口在此添加 + */ +@Getter +@AllArgsConstructor +public enum ApiType { + + /** 印尼支付行为画像升级版V1 */ + ID_PAYMENT_PROF_ADVANCED("v1", "FORTISPAY", "IdPaymentProfAdvanced", + "印尼支付行为画像升级版V1", "FORTISPAY_HMAC"); + + /** 配置分组 */ + private final String configGroup; + + /** 第三方公司标识 */ + private final String thirdPartyCode; + + /** 接口标识 */ + private final String apiCode; + + /** 接口描述 */ + private final String description; + + /** 认证类型,匹配 AuthStrategy.getAuthType() */ + private final String authType; +} diff --git a/src/main/java/com/lyxh/api/docking/exception/ApiCallException.java b/src/main/java/com/lyxh/api/docking/exception/ApiCallException.java new file mode 100644 index 0000000..d131fda --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/exception/ApiCallException.java @@ -0,0 +1,26 @@ +package com.lyxh.api.docking.exception; + +public class ApiCallException extends RuntimeException { + + private final String code; + private final String responseBody; + private String reqUrl; + private String reqHeaders; + + public ApiCallException(String code, String message, String responseBody) { + super(message); + this.code = code; + this.responseBody = responseBody; + } + + public ApiCallException withRequest(String reqUrl, String reqHeaders) { + this.reqUrl = reqUrl; + this.reqHeaders = reqHeaders; + return this; + } + + public String getCode() { return code; } + public String getResponseBody() { return responseBody; } + public String getReqUrl() { return reqUrl; } + public String getReqHeaders() { return reqHeaders; } +} diff --git a/src/main/java/com/lyxh/api/docking/exception/ErrorResponse.java b/src/main/java/com/lyxh/api/docking/exception/ErrorResponse.java new file mode 100644 index 0000000..5ee913b --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/exception/ErrorResponse.java @@ -0,0 +1,17 @@ +package com.lyxh.api.docking.exception; + +import lombok.Data; +import java.time.Instant; + +@Data +public class ErrorResponse { + private final String error; + private final String message; + private final String timestamp; + + public ErrorResponse(String error, String message) { + this.error = error; + this.message = message; + this.timestamp = Instant.now().toString(); + } +} diff --git a/src/main/java/com/lyxh/api/docking/exception/GlobalExceptionHandler.java b/src/main/java/com/lyxh/api/docking/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e5ba3ab --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/exception/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package com.lyxh.api.docking.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String msg = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .reduce((a, b) -> a + "; " + b) + .orElse("validation failed"); + return ResponseEntity.badRequest() + .body(new ErrorResponse("VALIDATION_ERROR", msg)); + } + + @ExceptionHandler(ApiCallException.class) + public ResponseEntity handleApiCall(ApiCallException ex) { + log.warn("API call error: code={} message={}", ex.getCode(), ex.getMessage()); + HttpStatus status = switch (ex.getCode()) { + case "NETWORK_ERROR" -> HttpStatus.SERVICE_UNAVAILABLE; + case "HTTP_ERROR" -> HttpStatus.BAD_GATEWAY; + default -> HttpStatus.BAD_GATEWAY; + }; + String detail = ex.getResponseBody() != null && !ex.getResponseBody().isEmpty() + ? ex.getResponseBody() : ex.getMessage(); + return ResponseEntity.status(status) + .body(new ErrorResponse(ex.getCode(), detail)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneral(Exception ex) { + log.error("Unexpected error", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("INTERNAL_ERROR", "Internal server error")); + } +} diff --git a/src/main/java/com/lyxh/api/docking/job/CacheCleanupJob.java b/src/main/java/com/lyxh/api/docking/job/CacheCleanupJob.java new file mode 100644 index 0000000..4a96d74 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/job/CacheCleanupJob.java @@ -0,0 +1,33 @@ +package com.lyxh.api.docking.job; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.lyxh.api.docking.entity.ApiCacheData; +import com.lyxh.api.docking.service.ApiCacheDataService; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.time.LocalDateTime; + +/** + * 缓存清理定时任务 — 删除 expire_time <= 当前时间 的过期缓存 + */ +public class CacheCleanupJob extends QuartzJobBean { + + private static final Logger log = LoggerFactory.getLogger(CacheCleanupJob.class); + + @Autowired + private ApiCacheDataService cacheDataService; + + @Override + protected void executeInternal(JobExecutionContext context) throws JobExecutionException { + LocalDateTime now = LocalDateTime.now(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.le(ApiCacheData::getExpireTime, now); + boolean removed = cacheDataService.remove(wrapper); + log.info("Cache cleanup executed: removed expired records (expireTime <= {}), result={}", now, removed); + } +} diff --git a/src/main/java/com/lyxh/api/docking/job/LogCleanupJob.java b/src/main/java/com/lyxh/api/docking/job/LogCleanupJob.java new file mode 100644 index 0000000..dedb94d --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/job/LogCleanupJob.java @@ -0,0 +1,42 @@ +package com.lyxh.api.docking.job; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.lyxh.api.docking.entity.ApiInvokeLog; +import com.lyxh.api.docking.service.ApiInvokeLogService; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.time.LocalDate; + +/** + * 日志清理定时任务 — 每隔 intervalDays 天的凌晨执行,删除 create_time < 当天的所有调用日志 + */ +public class LogCleanupJob extends QuartzJobBean { + + private static final Logger log = LoggerFactory.getLogger(LogCleanupJob.class); + + @Autowired + private ApiInvokeLogService invokeLogService; + + @Value("${schedule.log-cleanup.interval-days:7}") + private int intervalDays; + + @Override + protected void executeInternal(JobExecutionContext context) throws JobExecutionException { + LocalDate today = LocalDate.now(); + long daysSinceEpoch = today.toEpochDay(); + if (daysSinceEpoch % intervalDays != 0) { + log.debug("Log cleanup skipped: today is not a cleanup day (interval={} days)", intervalDays); + return; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.lt(ApiInvokeLog::getCreateTime, today); + boolean removed = invokeLogService.remove(wrapper); + log.info("Log cleanup executed: removed records before {}, result={}", today, removed); + } +} diff --git a/src/main/java/com/lyxh/api/docking/mapper/ApiCacheDataMapper.java b/src/main/java/com/lyxh/api/docking/mapper/ApiCacheDataMapper.java new file mode 100644 index 0000000..c792a36 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/mapper/ApiCacheDataMapper.java @@ -0,0 +1,18 @@ +package com.lyxh.api.docking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.lyxh.api.docking.entity.ApiCacheData; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ApiCacheDataMapper extends BaseMapper { + + ApiCacheData selectByCacheKey(@Param("apiCode") String apiCode, + @Param("cacheKey") String cacheKey, + @Param("configId") Long configId); + + ApiCacheData selectNonExpiredCache(@Param("apiCode") String apiCode, + @Param("cacheKey") String cacheKey, + @Param("configId") Long configId); +} diff --git a/src/main/java/com/lyxh/api/docking/mapper/ApiInvokeLogMapper.java b/src/main/java/com/lyxh/api/docking/mapper/ApiInvokeLogMapper.java new file mode 100644 index 0000000..2e7b0d1 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/mapper/ApiInvokeLogMapper.java @@ -0,0 +1,9 @@ +package com.lyxh.api.docking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.lyxh.api.docking.entity.ApiInvokeLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ApiInvokeLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/lyxh/api/docking/mapper/ThirdPartyConfigMapper.java b/src/main/java/com/lyxh/api/docking/mapper/ThirdPartyConfigMapper.java new file mode 100644 index 0000000..e4c4c6c --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/mapper/ThirdPartyConfigMapper.java @@ -0,0 +1,9 @@ +package com.lyxh.api.docking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.lyxh.api.docking.entity.ThirdPartyConfig; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ThirdPartyConfigMapper extends BaseMapper { +} diff --git a/src/main/java/com/lyxh/api/docking/model/ftapi/FortispayRequest.java b/src/main/java/com/lyxh/api/docking/model/ftapi/FortispayRequest.java new file mode 100644 index 0000000..e1f83ed --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/model/ftapi/FortispayRequest.java @@ -0,0 +1,20 @@ +package com.lyxh.api.docking.model.ftapi; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class FortispayRequest { + + private String name; + + @JsonProperty("id_card") + private String idCard; + + private String mobile; + + @JsonProperty("encrypt_algo") + private String encryptAlgo; +} diff --git a/src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileRequest.java b/src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileRequest.java new file mode 100644 index 0000000..1b09811 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileRequest.java @@ -0,0 +1,23 @@ +package com.lyxh.api.docking.model.ftapi; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class PaymentProfileRequest { + + /** 用户姓名 */ + @NotBlank(message = "name is required") + private String name; + + /** 身份证号(支持明文,密文) */ + @NotBlank(message = "idCard is required") + private String idCard; + + /** 国标+手机号(支持明文,密文) */ + @NotBlank(message = "mobile is required") + private String mobile; + + /** 身份证号和手机号的加密算法 */ + private String encryptAlgo; +} diff --git a/src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileResponse.java b/src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileResponse.java new file mode 100644 index 0000000..e191f10 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/model/ftapi/PaymentProfileResponse.java @@ -0,0 +1,14 @@ +package com.lyxh.api.docking.model.ftapi; + +import lombok.Data; +import java.util.Map; + +@Data +public class PaymentProfileResponse { + + private String code; + private String message; + private Integer status; + private String responseId; + private Map data; +} diff --git a/src/main/java/com/lyxh/api/docking/service/ApiCacheDataService.java b/src/main/java/com/lyxh/api/docking/service/ApiCacheDataService.java new file mode 100644 index 0000000..0ffafdc --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/ApiCacheDataService.java @@ -0,0 +1,20 @@ +package com.lyxh.api.docking.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.lyxh.api.docking.entity.ApiCacheData; + +import java.time.LocalDateTime; + +public interface ApiCacheDataService extends IService { + + /** 根据缓存键查询(不判断过期) */ + ApiCacheData getByCacheKey(String apiCode, String cacheKey, Long configId); + + /** 查询未过期的缓存 */ + ApiCacheData getNonExpiredCache(String apiCode, String cacheKey, Long configId); + + /** 新增或更新缓存 */ + void saveOrUpdateCache(String apiCode, String cacheKey, Long configId, + String cacheValue, String weOrderNo, String csOrderNo, String dsOrderNo, + LocalDateTime expireTime); +} diff --git a/src/main/java/com/lyxh/api/docking/service/ApiInvokeLogService.java b/src/main/java/com/lyxh/api/docking/service/ApiInvokeLogService.java new file mode 100644 index 0000000..f47ba2b --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/ApiInvokeLogService.java @@ -0,0 +1,7 @@ +package com.lyxh.api.docking.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.lyxh.api.docking.entity.ApiInvokeLog; + +public interface ApiInvokeLogService extends IService { +} diff --git a/src/main/java/com/lyxh/api/docking/service/GenericApiService.java b/src/main/java/com/lyxh/api/docking/service/GenericApiService.java new file mode 100644 index 0000000..f826b9e --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/GenericApiService.java @@ -0,0 +1,150 @@ +package com.lyxh.api.docking.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lyxh.api.docking.client.ApiCallResult; +import com.lyxh.api.docking.client.GenericApiClient; +import com.lyxh.api.docking.entity.ApiCacheData; +import com.lyxh.api.docking.entity.ApiInvokeLog; +import com.lyxh.api.docking.entity.ThirdPartyConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.function.Predicate; + +/** + * 通用 API 调用编排 — 配置驱动的缓存、日志、调用流程。 + * 新增第三方 API 只需在 third_party_config 表中添加配置, + * 并在 ApiType 枚举中注册即可,无需编写新的 Service。 + */ +@Service +public class GenericApiService { + + private static final Logger log = LoggerFactory.getLogger(GenericApiService.class); + + @Autowired + private ThirdPartyConfigService configService; + @Autowired + private GenericApiClient apiClient; + @Autowired + private ApiCacheDataService cacheDataService; + @Autowired + private ApiInvokeLogService invokeLogService; + @Autowired + private ObjectMapper objectMapper; + + /** + * 执行第三方 API 调用(含缓存、日志),返回响应体原始 JSON + * + * @param configGroup 配置分组 + * @param thirdPartyCode 三方标识 + * @param apiCode 接口标识 + * @param authType 认证类型,对应 AuthStrategy.getAuthType() + * @param requestBody 请求体对象 + * @param cacheKey 缓存键(null 表示不读缓存) + * @param weOrderNo 系统订单号 + * @param csOrderNo 客户订单号 + * @param language 语言标识(null 或空则不设置) + * @param dsOrderNoExtractor 从响应 JSON 中提取三方订单号的函数(可选) + * @param shouldCachePredicate 判断是否应缓存的断言(响应 JSON → boolean),仅在 enableCache==1 且 cacheEmptyResult==0 时使用 + */ + public String execute(String configGroup, String thirdPartyCode, String apiCode, String authType, + Object requestBody, String cacheKey, + String weOrderNo, String csOrderNo, String language, + java.util.function.Function dsOrderNoExtractor, + Predicate shouldCachePredicate) { + + // 1.读取DB中的api配置信息 + ThirdPartyConfig config = configService.getEnabledConfig(configGroup, thirdPartyCode, apiCode); + if (config == null) { + throw new IllegalStateException("No enabled config found for " + + configGroup + "/" + thirdPartyCode + "/" + apiCode); + } + + // 2. 读缓存 + if (isCacheEnabled(config) && cacheKey != null) { + ApiCacheData cached = cacheDataService.getNonExpiredCache(apiCode, cacheKey, config.getId()); + if (cached != null) { + log.info("Cache hit: weOrderNo={} apiCode={} cacheKey={}", weOrderNo, apiCode, cacheKey); + return cached.getCacheValue(); + } + log.info("Cache miss: weOrderNo={} apiCode={} cacheKey={}", weOrderNo, apiCode, cacheKey); + } + + // 3. 调用三方 API(weOrderNo / language 透传) + ApiCallResult result = apiClient.call(config, requestBody, weOrderNo, language, authType); + String respBodyJson = result.getRespBodyJson(); + + // 4. 抽取三方订单号 + String dsOrderNo = dsOrderNoExtractor != null + ? dsOrderNoExtractor.apply(respBodyJson) + : null; + + // 5. 记录调用日志 + saveInvokeLog(config, thirdPartyCode, apiCode, weOrderNo, csOrderNo, dsOrderNo, result, null); + + // 6. 写缓存 + if (isCacheEnabled(config) && cacheKey != null) { + boolean shouldCache; + // 是否缓存未查得及调用失败结果 + if (config.getCacheEmptyResult() != null && config.getCacheEmptyResult() == 1) { + shouldCache = true; + } else { + shouldCache = shouldCachePredicate != null && shouldCachePredicate.test(respBodyJson); + } + if (shouldCache) { + saveCache(config, apiCode, cacheKey, respBodyJson, weOrderNo, csOrderNo, dsOrderNo); + } + } + + return respBodyJson; + } + + + private boolean isCacheEnabled(ThirdPartyConfig config) { + return config.getEnableCache() != null && config.getEnableCache() == 1; + } + + private void saveInvokeLog(ThirdPartyConfig config, String thirdPartyCode, String apiCode, + String weOrderNo, String csOrderNo, String dsOrderNo, + ApiCallResult result, String errorMessage) { + ApiInvokeLog logEntity = new ApiInvokeLog(); + logEntity.setConfigId(config.getId()); + logEntity.setThirdPartyCode(thirdPartyCode); + logEntity.setApiCode(apiCode); + logEntity.setCsOrderNo(csOrderNo); + logEntity.setWeOrderNo(weOrderNo); + logEntity.setDsOrderNo(dsOrderNo); + logEntity.setReqUrl(result.getUrl()); + logEntity.setReqMethod("POST"); + logEntity.setReqHeaders(result.getReqHeadersJson()); + logEntity.setReqBody(result.getReqBodyJson()); + logEntity.setRespStatusCode(result.getHttpStatusCode()); + logEntity.setRespBody(result.getRespBodyJson()); + logEntity.setCostMillis((int) result.getCostMillis()); + logEntity.setErrorMessage(errorMessage); + invokeLogService.save(logEntity); + log.info("Invoke log saved: weOrderNo={}", weOrderNo); + } + + private void saveCache(ThirdPartyConfig config, String apiCode, String cacheKey, + String cacheValue, String weOrderNo, String csOrderNo, String dsOrderNo) { + Duration ttl = Duration.ofDays(config.getCacheTtlDays()); + LocalDateTime expireTime = LocalDateTime.now().plus(ttl); + cacheDataService.saveOrUpdateCache(apiCode, cacheKey, config.getId(), + cacheValue, weOrderNo, csOrderNo, dsOrderNo, expireTime); + log.info("Cache saved: weOrderNo={} apiCode={} cacheKey={}", weOrderNo, apiCode, cacheKey); + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/lyxh/api/docking/service/PaymentProfileService.java b/src/main/java/com/lyxh/api/docking/service/PaymentProfileService.java new file mode 100644 index 0000000..3b2958e --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/PaymentProfileService.java @@ -0,0 +1,135 @@ +package com.lyxh.api.docking.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lyxh.api.docking.enums.ApiType; +import com.lyxh.api.docking.exception.ApiCallException; +import com.lyxh.api.docking.model.ftapi.FortispayRequest; +import com.lyxh.api.docking.model.ftapi.PaymentProfileRequest; +import com.lyxh.api.docking.model.ftapi.PaymentProfileResponse; +import org.apache.commons.codec.digest.DigestUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * 印尼支付行为画像 — 特征查询,缓存/日志/调用委托给 {@link GenericApiService} + */ +@Service +public class PaymentProfileService { + + private static final Logger log = LoggerFactory.getLogger(PaymentProfileService.class); + private static final ApiType API_TYPE = ApiType.ID_PAYMENT_PROF_ADVANCED; + + private static final Set FEATURE_FIELDS = Set.of( + "app_behavior", "loan_behavior", "repay_behavior", + "time_structure", "delinq_monitor", "flow_anomaly" + ); + + @Autowired + private GenericApiService genericApiService; + + @Autowired + private ObjectMapper objectMapper; + + public PaymentProfileResponse query(PaymentProfileRequest request, String xRequestId, String language, + String weOrderNo) { + boolean isEncrypted = request.getEncryptAlgo() != null && !request.getEncryptAlgo().isBlank(); + String idCardForCache = isEncrypted ? request.getIdCard() : DigestUtils.md5Hex(request.getIdCard()); + String mobileForCache = isEncrypted ? request.getMobile() : DigestUtils.md5Hex(request.getMobile()); + String cacheKey = DigestUtils.md5Hex(request.getName() + "|" + idCardForCache + "|" + mobileForCache + + "|" + (language != null ? language : "")); + + FortispayRequest ftRequest = FortispayRequest.builder() + .name(request.getName()) + .idCard(request.getIdCard()) + .mobile(request.getMobile()) + .encryptAlgo(request.getEncryptAlgo()) + .build(); + + try { + String respJson = genericApiService.execute( + API_TYPE.getConfigGroup(), + API_TYPE.getThirdPartyCode(), + API_TYPE.getApiCode(), + API_TYPE.getAuthType(), + ftRequest, + cacheKey, + weOrderNo, + xRequestId, + language, + this::extractDsOrderNo, + this::shouldCache + ); + JsonNode root = objectMapper.readTree(respJson); + return parseResponse(root); + } catch (JsonProcessingException e) { + throw new ApiCallException("PARSE_ERROR", "Failed to parse Fortispay response: " + e.getMessage(), null); + } catch (ApiCallException e) { +// genericApiService.logError( +// API_TYPE.getConfigGroup(), +// API_TYPE.getThirdPartyCode(), +// API_TYPE.getApiCode(), +// ftRequest, +// weOrderNo, +// xRequestId, +// e.getCode() + ": " + e.getMessage(), +// e.getReqUrl(), +// e.getReqHeaders() +// ); + throw e; + } + } + + private String extractDsOrderNo(String respJson) { + try { + JsonNode node = objectMapper.readTree(respJson); + JsonNode responseId = node.get("responseId"); + return responseId != null ? responseId.asText() : null; + } catch (Exception e) { + return null; + } + } + + private boolean shouldCache(String respJson) { + try { + JsonNode node = objectMapper.readTree(respJson); + return node.path("data").path("status").asInt(0) == 1; + } catch (Exception e) { + return false; + } + } + + private PaymentProfileResponse parseResponse(JsonNode node) { + PaymentProfileResponse response = new PaymentProfileResponse(); + response.setCode(node.path("code").asText()); + response.setMessage(node.path("message").asText()); + JsonNode responseId = node.get("responseId"); + if (responseId != null) { + response.setResponseId(responseId.asText()); + } + JsonNode data = node.get("data"); + if (data != null) { + response.setStatus(data.path("status").asInt()); + JsonNode result = data.get("result"); + if (result != null) { + @SuppressWarnings("unchecked") + Map resultMap = objectMapper.convertValue(result, Map.class); + Map filtered = new LinkedHashMap<>(); + for (String field : FEATURE_FIELDS) { + if (resultMap.containsKey(field)) { + filtered.put(field, resultMap.get(field)); + } + } + response.setData(filtered); + } + } + return response; + } +} diff --git a/src/main/java/com/lyxh/api/docking/service/SignatureService.java b/src/main/java/com/lyxh/api/docking/service/SignatureService.java new file mode 100644 index 0000000..a292a80 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/SignatureService.java @@ -0,0 +1,30 @@ +package com.lyxh.api.docking.service; + +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +public class SignatureService { + + /** 生成32位无横线UUID */ + public String generateRequestId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + /** 获取当前毫秒时间戳 */ + public long currentTimestamp() { + return System.currentTimeMillis(); + } + + /** 根据传入的secretId、secretKey生成HMAC-SHA256签名 */ + public String generateSignature(String requestId, long timestamp, String secretId, String secretKey) { + String plaintext = "requestId=" + requestId + + "&secretId=" + secretId + + "×tamp=" + timestamp; + HmacUtils hmac = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secretKey); + return hmac.hmacHex(plaintext); + } +} diff --git a/src/main/java/com/lyxh/api/docking/service/ThirdPartyConfigService.java b/src/main/java/com/lyxh/api/docking/service/ThirdPartyConfigService.java new file mode 100644 index 0000000..92baef8 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/ThirdPartyConfigService.java @@ -0,0 +1,10 @@ +package com.lyxh.api.docking.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.lyxh.api.docking.entity.ThirdPartyConfig; + +public interface ThirdPartyConfigService extends IService { + + /** 根据分组+三方标识+接口标识查询启用的配置 */ + ThirdPartyConfig getEnabledConfig(String configGroup, String thirdPartyCode, String apiCode); +} diff --git a/src/main/java/com/lyxh/api/docking/service/impl/ApiCacheDataServiceImpl.java b/src/main/java/com/lyxh/api/docking/service/impl/ApiCacheDataServiceImpl.java new file mode 100644 index 0000000..caa2205 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/impl/ApiCacheDataServiceImpl.java @@ -0,0 +1,51 @@ +package com.lyxh.api.docking.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.lyxh.api.docking.entity.ApiCacheData; +import com.lyxh.api.docking.mapper.ApiCacheDataMapper; +import com.lyxh.api.docking.service.ApiCacheDataService; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +public class ApiCacheDataServiceImpl + extends ServiceImpl + implements ApiCacheDataService { + + @Override + public ApiCacheData getByCacheKey(String apiCode, String cacheKey, Long configId) { + return baseMapper.selectByCacheKey(apiCode, cacheKey, configId); + } + + @Override + public ApiCacheData getNonExpiredCache(String apiCode, String cacheKey, Long configId) { + return baseMapper.selectNonExpiredCache(apiCode, cacheKey, configId); + } + + @Override + public void saveOrUpdateCache(String apiCode, String cacheKey, Long configId, + String cacheValue, String weOrderNo, String csOrderNo, String dsOrderNo, + LocalDateTime expireTime) { + ApiCacheData existing = getByCacheKey(apiCode, cacheKey, configId); + if (existing != null) { + existing.setCacheValue(cacheValue); + existing.setWeOrderNo(weOrderNo); + existing.setCsOrderNo(csOrderNo); + existing.setDsOrderNo(dsOrderNo); + existing.setExpireTime(expireTime); + updateById(existing); + } else { + ApiCacheData cache = new ApiCacheData(); + cache.setConfigId(configId); + cache.setApiCode(apiCode); + cache.setCacheKey(cacheKey); + cache.setCacheValue(cacheValue); + cache.setWeOrderNo(weOrderNo); + cache.setCsOrderNo(csOrderNo); + cache.setDsOrderNo(dsOrderNo); + cache.setExpireTime(expireTime); + save(cache); + } + } +} diff --git a/src/main/java/com/lyxh/api/docking/service/impl/ApiInvokeLogServiceImpl.java b/src/main/java/com/lyxh/api/docking/service/impl/ApiInvokeLogServiceImpl.java new file mode 100644 index 0000000..74f1810 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/impl/ApiInvokeLogServiceImpl.java @@ -0,0 +1,13 @@ +package com.lyxh.api.docking.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.lyxh.api.docking.entity.ApiInvokeLog; +import com.lyxh.api.docking.mapper.ApiInvokeLogMapper; +import com.lyxh.api.docking.service.ApiInvokeLogService; +import org.springframework.stereotype.Service; + +@Service +public class ApiInvokeLogServiceImpl + extends ServiceImpl + implements ApiInvokeLogService { +} diff --git a/src/main/java/com/lyxh/api/docking/service/impl/ThirdPartyConfigServiceImpl.java b/src/main/java/com/lyxh/api/docking/service/impl/ThirdPartyConfigServiceImpl.java new file mode 100644 index 0000000..3565913 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/service/impl/ThirdPartyConfigServiceImpl.java @@ -0,0 +1,24 @@ +package com.lyxh.api.docking.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.lyxh.api.docking.entity.ThirdPartyConfig; +import com.lyxh.api.docking.mapper.ThirdPartyConfigMapper; +import com.lyxh.api.docking.service.ThirdPartyConfigService; +import org.springframework.stereotype.Service; + +@Service +public class ThirdPartyConfigServiceImpl + extends ServiceImpl + implements ThirdPartyConfigService { + + @Override + public ThirdPartyConfig getEnabledConfig(String configGroup, String thirdPartyCode, String apiCode) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ThirdPartyConfig::getConfigGroup, configGroup) + .eq(ThirdPartyConfig::getThirdPartyCode, thirdPartyCode) + .eq(ThirdPartyConfig::getApiCode, apiCode) + .eq(ThirdPartyConfig::getStatus, 1); + return getOne(wrapper); + } +} diff --git a/src/main/java/com/lyxh/api/docking/util/AuthConfigCrypto.java b/src/main/java/com/lyxh/api/docking/util/AuthConfigCrypto.java new file mode 100644 index 0000000..3071e1f --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/util/AuthConfigCrypto.java @@ -0,0 +1,56 @@ +package com.lyxh.api.docking.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * auth_config 字段 AES 加解密工具。 + * 密钥通过环境变量 AUTH_CONFIG_SECRET_KEY 注入,至少 16 字符。 + */ +@Component +public class AuthConfigCrypto { + + private static final String ALGORITHM = "AES"; + + private final String secretKey; + + public AuthConfigCrypto(@Value("${auth.config.secret-key:LYXH-FT-DOCKING-}") String secretKey) { + // 不足 16 字节时填充 + if (secretKey.length() < 16) { + secretKey = String.format("%-16s", secretKey).replace(' ', '0'); + } + this.secretKey = secretKey.substring(0, 16); + } + + /** 加密(写入 DB 时使用) */ + public String encrypt(String plaintext) { + try { + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt auth_config", e); + } + } + + /** 解密(读取 DB 后使用) */ + public String decrypt(String ciphertext) { + try { + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt auth_config", e); + } + } + +} diff --git a/src/main/java/com/lyxh/api/docking/util/AuthConfigEncryptor.java b/src/main/java/com/lyxh/api/docking/util/AuthConfigEncryptor.java new file mode 100644 index 0000000..3137857 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/util/AuthConfigEncryptor.java @@ -0,0 +1,45 @@ +package com.lyxh.api.docking.util; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * auth_config 加密工具(仅用于初始化 DB 数据时生成密文) + * + * 用法: java AuthConfigEncryptor "" "" + * 示例: java AuthConfigEncryptor "{\"secret_id\":\"xxx\",\"secret_key\":\"yyy\"}" "LYXH-API-DOCKING-" + */ +public final class AuthConfigEncryptor { + + private static final String ALGORITHM = "AES"; + + private AuthConfigEncryptor() {} + + public static void main(String[] args) { +// if (args.length < 2) { +// System.out.println("用法: java AuthConfigEncryptor <secretKey>"); +// System.exit(1); +// } + String plaintext = "{\"secret_id\":\"3c89a386dc35146a2a1f816beac0669d\",\"secret_key\":\"0d196da358860805e76b697c2c126329\"}"; + String key = "LYXH-API-DOCKING-"; + if (key.length() < 16) { + key = String.format("%-16s", key).replace(' ', '0'); + } + key = key.substring(0, 16); + + try { + SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + String encoded = Base64.getEncoder().encodeToString(encrypted); + System.out.println("Plaintext: " + plaintext); + System.out.println("Encrypted: " + encoded); + } catch (Exception e) { + System.err.println("Encryption failed: " + e.getMessage()); + System.exit(1); + } + } +} diff --git a/src/main/java/com/lyxh/api/docking/util/HeaderDesensitizer.java b/src/main/java/com/lyxh/api/docking/util/HeaderDesensitizer.java new file mode 100644 index 0000000..4c65ad3 --- /dev/null +++ b/src/main/java/com/lyxh/api/docking/util/HeaderDesensitizer.java @@ -0,0 +1,44 @@ +package com.lyxh.api.docking.util; + +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * 请求头脱敏 — 对敏感字段(secretId、signature、api_key 等)做掩码处理 + * 仅用于写入 api_invoke_log.req_headers,不影响实际 HTTP 请求 + */ +@Component +public class HeaderDesensitizer { + + private static final Set<String> SENSITIVE_KEYS = Set.of( + "secretid", "signature", "api_key", "apikey", + "authorization", "token", "access_token", "secret_key" + ); + + /** 脱敏:保留前 4 位 + "****" + 后 4 位 */ + public Map<String, String> mask(Map<String, String> headers) { + Map<String, String> masked = new LinkedHashMap<>(); + headers.forEach((key, value) -> { + if (isSensitive(key)) { + masked.put(key, maskValue(value)); + } else { + masked.put(key, value); + } + }); + return masked; + } + + private boolean isSensitive(String key) { + return SENSITIVE_KEYS.contains(key.toLowerCase().replace("-", "_")); + } + + private String maskValue(String value) { + if (value == null || value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..50e1ddc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,34 @@ +server: + port: ${SERVER_PORT:8049} + +spring: + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3306/lyxh_api_docking?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:123456} + driver-class-name: com.mysql.cj.jdbc.Driver + +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + global-config: + db-config: + id-type: auto + logic-delete-field: deleteFlag + logic-delete-value: 1 + logic-not-delete-value: 0 + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +auth: + config: + secret-key: ${AUTH_CONFIG_SECRET_KEY:LYXH-API-DOCKING-} + map-underscore-to-camel-case: true + +schedule: + # 日志清理 每天凌晨3点触发,实际每隔 interval-days 天才执行一次清理 + log-cleanup: + cron: ${SCHEDULE_LOG_CLEANUP_CRON:0 0 3 * * ?} + interval-days: ${SCHEDULE_LOG_CLEANUP_INTERVAL_DAYS:20} + # 缓存数据清理 每30分钟执行 + cache-cleanup: + cron: ${SCHEDULE_CACHE_CLEANUP_CRON:0 */30 * * * ?} diff --git a/src/main/resources/mapper/ApiCacheDataMapper.xml b/src/main/resources/mapper/ApiCacheDataMapper.xml new file mode 100644 index 0000000..599b816 --- /dev/null +++ b/src/main/resources/mapper/ApiCacheDataMapper.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.lyxh.api.docking.mapper.ApiCacheDataMapper"> + + <select id="selectByCacheKey" resultType="com.lyxh.api.docking.entity.ApiCacheData"> + SELECT * + FROM api_cache_data + WHERE api_code = #{apiCode} + AND cache_key = #{cacheKey} + AND config_id = #{configId} + </select> + + <select id="selectNonExpiredCache" resultType="com.lyxh.api.docking.entity.ApiCacheData"> + SELECT * + FROM api_cache_data + WHERE api_code = #{apiCode} + AND cache_key = #{cacheKey} + AND config_id = #{configId} + AND expire_time &gt; NOW() + </select> + +</mapper> diff --git a/src/main/resources/mapper/ApiInvokeLogMapper.xml b/src/main/resources/mapper/ApiInvokeLogMapper.xml new file mode 100644 index 0000000..a853a21 --- /dev/null +++ b/src/main/resources/mapper/ApiInvokeLogMapper.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.lyxh.api.docking.mapper.ApiInvokeLogMapper"> + +</mapper> diff --git a/src/main/resources/mapper/ThirdPartyConfigMapper.xml b/src/main/resources/mapper/ThirdPartyConfigMapper.xml new file mode 100644 index 0000000..be0d5a3 --- /dev/null +++ b/src/main/resources/mapper/ThirdPartyConfigMapper.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> +<mapper namespace="com.lyxh.api.docking.mapper.ThirdPartyConfigMapper"> + +</mapper> diff --git a/src/main/resources/sql/init_data.sql b/src/main/resources/sql/init_data.sql new file mode 100644 index 0000000..b715765 --- /dev/null +++ b/src/main/resources/sql/init_data.sql @@ -0,0 +1,36 @@ +-- Fortispay 印尼支付行为画像升级版V1 配置 +-- 联调环境:api_url = https://openapi-dev.fortispaytech.com/data-service/query/IdPaymentProfAdvanced +-- 生产环境:api_url = https://openapi.fortispaytech.com/data-service/query/IdPaymentProfAdvanced + +-- auth_config 为 AES 加密存储,明文: {"secret_id":"xxx","secret_key":"yyy"} +-- 联调环境:auth_config = eUh5XEM+MyeY3/n9+c+BVcOTBQPccDhaRyzcpROYjCMRJ3+wu3s05/M1l/LP6ooOGbdZtD7T1YNmyoVtwpwkx3szkpi36DoAyV1s3hsBm5daeILFXfgJjIOBKLfbP9RWVdX7zYGxrQ4H/j7MmHqZew== +-- 生产环境:auth_config = eUh5XEM+MyeY3/n9+c+BVcOTBQPccDhaRyzcpROYjCMRJ3+wu3s05/M1l/LP6ooOaZG/07mJkMOFzCZTAg8kTRfFztaTZkzVvlhkWBg4SyeZeo0XgQ6gZk4zmi1Odj0wVdX7zYGxrQ4H/j7MmHqZew== +INSERT INTO `third_party_config` ( + `config_group`, + `third_party_code`, + `api_code`, + `api_name`, + `status`, + `api_url`, + `auth_config`, + `timeout_seconds`, + `retry_times`, + `proxy_host`, + `enable_cache`, + `cache_ttl_days`, + `cache_empty_result` +) VALUES ( + 'v1', + 'FORTISPAY', + 'IdPaymentProfAdvanced', + '印尼支付行为画像升级版V1', + 1, + 'https://openapi-dev.fortispaytech.com/data-service/query/IdPaymentProfAdvanced', + 'eUh5XEM+MyeY3/n9+c+BVcOTBQPccDhaRyzcpROYjCMRJ3+wu3s05/M1l/LP6ooOGbdZtD7T1YNmyoVtwpwkx3szkpi36DoAyV1s3hsBm5daeILFXfgJjIOBKLfbP9RWVdX7zYGxrQ4H/j7MmHqZew==', + 30, + 0, + NULL, + 1, + 30, + 0 +); diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..9fbcecb --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,71 @@ +CREATE TABLE `third_party_config` +( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `config_group` varchar(64) NOT NULL DEFAULT 'default' COMMENT '配置分组,用于区分不同业务线/租户', + `third_party_code` varchar(64) NOT NULL COMMENT '第三方公司标识,唯一,如 HU_TONG', + `api_code` varchar(64) NOT NULL COMMENT '接口唯一标识,如 QUERY_ORDER、CREATE_ORDER', + `api_name` varchar(255) DEFAULT NULL COMMENT '接口名称/描述', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用,0-禁用', + `api_url` varchar(1024) NOT NULL COMMENT '接口地址', + `auth_config` text NOT NULL COMMENT '认证配置(JSON),如 {"secret_id":"xxx","secret_key":"xxx"},存储时务必加密', + `timeout_seconds` int DEFAULT '30' COMMENT '请求超时(秒)', + `retry_times` int DEFAULT '0' COMMENT '失败重试次数', + `proxy_host` varchar(255) DEFAULT NULL COMMENT '代理转发地址', + `enable_cache` tinyint(1) DEFAULT '0' COMMENT '是否启用结果缓存:1-是,0-否', + `cache_ttl_days` int DEFAULT '1' COMMENT '缓存有效期(天)', + `cache_empty_result` tinyint(1) DEFAULT '0' COMMENT '是否缓存未查得及调用失败结果:1-是,0-否''', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `delete_flag` tinyint(1) DEFAULT '0' COMMENT '逻辑删除:0-正常,1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_config` (`config_group`, `third_party_code`, `api_code`, `delete_flag`) COMMENT '同一分组下接口唯一(含删除标记)', + KEY `idx_third_party_code` (`third_party_code`), + KEY `idx_api_code` (`api_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='三方API配置表'; + + +CREATE TABLE `api_invoke_log` +( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `config_id` bigint DEFAULT NULL COMMENT '关联 third_party_config.id,追溯调用时的配置快照', + `third_party_code` varchar(64) NOT NULL COMMENT '第三方公司标识(冗余,方便查询)', + `api_code` varchar(64) NOT NULL COMMENT '接口标识', + `cs_order_no` varchar(255) DEFAULT NULL COMMENT '客户订单号(上游网管传入)', + `we_order_no` varchar(255) DEFAULT NULL COMMENT '系统订单号(本服务生成)', + `ds_order_no` varchar(255) DEFAULT NULL COMMENT '三方订单号(下游三方返回)', + `req_url` varchar(1024) DEFAULT NULL COMMENT '实际调用的三方地址', + `req_method` varchar(10) DEFAULT 'POST' COMMENT '请求方法', + `req_headers` text COMMENT '请求头(JSON,注意脱敏)', + `req_body` longtext COMMENT '请求体', + `resp_status_code` int DEFAULT NULL COMMENT 'HTTP状态码', + `resp_headers` text COMMENT '响应头(JSON)', + `resp_body` longtext COMMENT '响应体', + `error_message` text COMMENT '异常信息(超时、连接失败等)', + `cost_millis` int DEFAULT NULL COMMENT '请求耗时(毫秒)', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_config_id` (`config_id`), + KEY `idx_cs_order_no` (`cs_order_no`), + KEY `idx_we_order_no` (`we_order_no`), + KEY `idx_ds_order_no` (`ds_order_no`), + KEY `idx_api_code_time` (`api_code`, `create_time`), + KEY `idx_create_time` (`create_time`) COMMENT '用于按时间清理数据' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='三方API调用日志表'; + +CREATE TABLE `api_cache_data` +( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `config_id` bigint DEFAULT NULL COMMENT '关联 third_party_config.id(若需按配置隔离缓存)', + `api_code` varchar(64) NOT NULL COMMENT '接口标识', + `cache_key` varchar(256) NOT NULL COMMENT '缓存键(由config_group,third_party_code,api_code和加密后的请求参数生成的业务指纹)', + `cache_value` longtext NOT NULL COMMENT '缓存的响应体', + `cs_order_no` varchar(255) DEFAULT NULL COMMENT '触发本次缓存的客户订单号', + `we_order_no` varchar(255) DEFAULT NULL COMMENT '触发本次缓存的系统订单号', + `ds_order_no` varchar(255) DEFAULT NULL COMMENT '本次缓存对应的三方订单号', + `expire_time` datetime NOT NULL COMMENT '缓存过期时间', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cache` (`api_code`, `cache_key`, `config_id`) COMMENT '同一配置下缓存键唯一', + KEY `idx_expire_time` (`expire_time`) COMMENT '用于定时清理过期缓存' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='三方API结果缓存表'; \ No newline at end of file diff --git a/对接接口文档.md b/对接接口文档.md new file mode 100644 index 0000000..5e15205 --- /dev/null +++ b/对接接口文档.md @@ -0,0 +1,146 @@ +# 印尼支付行为画像查询接口 + +## 接口信息 + +| 项目 | 说明 | +|---|---| +| 接口路径 | `POST /api/v1/fortispay/payment-feature` | +| 请求格式 | `application/json; charset=UTF-8` | +| 响应格式 | `application/json` | + +## 请求头部 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|------------------------------| +| Content-Type | String | Y | `application/json` | +| X-Request-Id | String | N | 网关请求唯一标识ID,用于全链路追踪,原样回显在响应头中 | +| X-Language | String | N | 语言标识,传 `en` 返回英文结果,不传默认中文 | + +## 响应头部 + +| 参数 | 类型 | 说明 | +|---|---|---| +| X-Request-Id | String | 回显请求头中的 X-Request-Id,未传则自动生成 32 位 UUID | +| X-Session-Id | String | 三方服务返回的唯一会话标识(Fortispay responseId) | + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| name | String | Y | 用户姓名 | +| idCard | String | Y | 身份证号,支持明文或 MD5 密文 | +| mobile | String | Y | 手机号(带国家码),支持明文或 MD5 密文 | +| encryptAlgo | String | N | 加密算法。**不传**表示明文,服务端原样透传给三方;传入 `MD5` 表示已加密,直接透传 | + +### 请求示例 + +```json +{ + "name": "ABCXYZ", + "idCard": "3201190108990002", + "mobile": "6285887456612" +} +``` + +> idCard、mobile 传明文时无需传 encryptAlgo,服务端原样透传给三方。 + +```json +{ + "name": "ABCXYZ", + "idCard": "cf0ab15098ab149e5273dd82f2c737e6", + "mobile": "bd82af6340240c43175a80aedf7e6eef", + "encryptAlgo": "MD5" +} +``` + +> idCard、mobile 传密文时必须传 encryptAlgo="MD5"。 + +## 响应体 + +返回以下 6 个特征字段: + +- `app_behavior` — 申请行为变量 +- `loan_behavior` — 放款行为变量 +- `repay_behavior` — 还款行为变量 +- `time_structure` — 行为时间结构变量 +- `delinq_monitor` — 逾期行为监控 +- `flow_anomaly` — 资金流量异常监控变量 + +| 字段 | 类型 | 说明 | +|---|---|-------------------------------------------| +| code | String | 响应码,`"0"` 表示成功,更多请查看富通接口规范.pdf 常见响应编码表 | +| message | String | 提示消息,如 `"success"` | +| status | Integer | 查询状态,仅code为 `"0"` 时有值(`1` = 查得,`0` = 未查得) | +| responseId | String | 三方响应的唯一标识ID | +| data | Object | 结果数据(Map),仅code为 `"0"` 时不为null | + +### 成功响应示例 + +```json +{ + "code": "0", + "message": "success", + "status": 1, + "responseId": "0b5a09b6ea7d77dbbbb92e05dc9744ad", + "data": { + "app_behavior": { "..." : "..." }, + "loan_behavior": { "..." : "..." }, + "repay_behavior": { "..." : "..." }, + "time_structure": { "..." : "..." }, + "delinq_monitor": { "..." : "..." }, + "flow_anomaly": { "..." : "..." } + } +} +``` + +### 未查得响应示例 + +```json +{ + "code": "0", + "message": "success", + "status": 0, + "responseId": "0b5a09b6ea7d77dbbbb92e05dc9744ad", + "data": { + "app_behavior": { "..." : null }, + "loan_behavior": { "..." : null }, + "repay_behavior": { "..." : null }, + "time_structure": { "..." : null }, + "delinq_monitor": { "..." : null }, + "flow_anomaly": { "..." : null } + } +} +``` + +## 错误响应 + +统一错误格式: + +```json +{ + "error": "ERROR_CODE", + "message": "错误描述", + "timestamp": "2026-07-08T10:30:00Z" +} +``` + +| HTTP状态码 | error | 说明 | +|---|---|------------------| +| 400 | VALIDATION_ERROR | 请求参数校验失败 | +| 502 | HTTP_ERROR | 三方 API HTTP 错误 | +| 503 | NETWORK_ERROR | 三方 API 网络不可达 | +| 500 | INTERNAL_ERROR | 服务内部未知异常 api配置信息未找到 | + +## 完整调用示例 + +```bash +curl -X POST http://{host}:{port}/api/v1/fortispay/payment-feature \ + -H "Content-Type: application/json" \ + -H "X-Request-Id: 9c6ccc0f3ad3886dfe56ed364b6dc3e8" \ + -H "X-Language: en" \ + -d '{ + "name": "ABCXYZ", + "idCard": "3201190108990002", + "mobile": "6285887456612" + }' +```