init commit
This commit is contained in:
commit
457e43fca8
|
|
@ -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/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.7</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.lyxh</groupId>
|
||||
<artifactId>lyxh-api-docking</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>lyxh-api-docking</name>
|
||||
<description>Fortispay API docking service</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>3.5.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-quartz</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<String, AuthStrategy> strategyMap;
|
||||
|
||||
public AuthStrategyFactory(List<AuthStrategy> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Object> 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<String> 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<String, String> headersToMap(HttpHeaders headers) {
|
||||
java.util.LinkedHashMap<String, String> 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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PaymentProfileResponse> 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);
|
||||
}
|
||||
}
|
||||
62
src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiCacheDataController.java
vendored
Normal file
62
src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiCacheDataController.java
vendored
Normal file
|
|
@ -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<ApiCacheData> create(@Valid @RequestBody ApiCacheData cache) {
|
||||
apiCacheDataServiceImpl.save(cache);
|
||||
return ResponseEntity.ok(cache);
|
||||
}
|
||||
|
||||
/** 更新缓存记录 */
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiCacheData> update(@RequestBody ApiCacheData cache) {
|
||||
apiCacheDataServiceImpl.updateById(cache);
|
||||
return ResponseEntity.ok(cache);
|
||||
}
|
||||
|
||||
/** 删除缓存记录 */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
apiCacheDataServiceImpl.removeById(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/** 根据ID查询缓存记录 */
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiCacheData> getById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(apiCacheDataServiceImpl.getById(id));
|
||||
}
|
||||
|
||||
/** 分页查询缓存记录 */
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ApiCacheData>> page(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String apiCode,
|
||||
@RequestParam(required = false) Long configId) {
|
||||
LambdaQueryWrapper<ApiCacheData> 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));
|
||||
}
|
||||
}
|
||||
66
src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiInvokeLogController.java
vendored
Normal file
66
src/main/java/com/lyxh/api/docking/controller/thirdparty/ApiInvokeLogController.java
vendored
Normal file
|
|
@ -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<ApiInvokeLog> create(@Valid @RequestBody ApiInvokeLog log) {
|
||||
apiInvokeLogServiceImpl.save(log);
|
||||
return ResponseEntity.ok(log);
|
||||
}
|
||||
|
||||
/** 更新调用日志 */
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiInvokeLog> update(@RequestBody ApiInvokeLog log) {
|
||||
apiInvokeLogServiceImpl.updateById(log);
|
||||
return ResponseEntity.ok(log);
|
||||
}
|
||||
|
||||
/** 删除调用日志 */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
apiInvokeLogServiceImpl.removeById(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/** 根据ID查询调用日志 */
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiInvokeLog> getById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(apiInvokeLogServiceImpl.getById(id));
|
||||
}
|
||||
|
||||
/** 分页查询调用日志 */
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ApiInvokeLog>> 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<ApiInvokeLog> 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));
|
||||
}
|
||||
}
|
||||
64
src/main/java/com/lyxh/api/docking/controller/thirdparty/ThirdPartyConfigController.java
vendored
Normal file
64
src/main/java/com/lyxh/api/docking/controller/thirdparty/ThirdPartyConfigController.java
vendored
Normal file
|
|
@ -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<ThirdPartyConfig> create(@Valid @RequestBody ThirdPartyConfig config) {
|
||||
thirdPartyConfigServiceImpl.save(config);
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
|
||||
/** 更新配置 */
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ThirdPartyConfig> update(@RequestBody ThirdPartyConfig config) {
|
||||
thirdPartyConfigServiceImpl.updateById(config);
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
|
||||
/** 删除配置 */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
thirdPartyConfigServiceImpl.removeById(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/** 根据ID查询配置 */
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ThirdPartyConfig> getById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(thirdPartyConfigServiceImpl.getById(id));
|
||||
}
|
||||
|
||||
/** 分页查询配置列表 */
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ThirdPartyConfig>> 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<ThirdPartyConfig> 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleGeneral(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse("INTERNAL_ERROR", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ApiCacheData> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ApiInvokeLog> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.lt(ApiInvokeLog::getCreateTime, today);
|
||||
boolean removed = invokeLogService.remove(wrapper);
|
||||
log.info("Log cleanup executed: removed records before {}, result={}", today, removed);
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<ApiInvokeLog> {
|
||||
}
|
||||
|
|
@ -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<ThirdPartyConfig> {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<String, Object> data;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
||||
/** 根据缓存键查询(不判断过期) */
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<ApiInvokeLog> {
|
||||
}
|
||||
|
|
@ -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<String, String> dsOrderNoExtractor,
|
||||
Predicate<String> 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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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<String, Object> resultMap = objectMapper.convertValue(result, Map.class);
|
||||
Map<String, Object> filtered = new LinkedHashMap<>();
|
||||
for (String field : FEATURE_FIELDS) {
|
||||
if (resultMap.containsKey(field)) {
|
||||
filtered.put(field, resultMap.get(field));
|
||||
}
|
||||
}
|
||||
response.setData(filtered);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
||||
/** 根据分组+三方标识+接口标识查询启用的配置 */
|
||||
ThirdPartyConfig getEnabledConfig(String configGroup, String thirdPartyCode, String apiCode);
|
||||
}
|
||||
|
|
@ -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<ApiCacheDataMapper, ApiCacheData>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ApiInvokeLogMapper, ApiInvokeLog>
|
||||
implements ApiInvokeLogService {
|
||||
}
|
||||
|
|
@ -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<ThirdPartyConfigMapper, ThirdPartyConfig>
|
||||
implements ThirdPartyConfigService {
|
||||
|
||||
@Override
|
||||
public ThirdPartyConfig getEnabledConfig(String configGroup, String thirdPartyCode, String apiCode) {
|
||||
LambdaQueryWrapper<ThirdPartyConfig> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(ThirdPartyConfig::getConfigGroup, configGroup)
|
||||
.eq(ThirdPartyConfig::getThirdPartyCode, thirdPartyCode)
|
||||
.eq(ThirdPartyConfig::getApiCode, apiCode)
|
||||
.eq(ThirdPartyConfig::getStatus, 1);
|
||||
return getOne(wrapper);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 "<plaintext_json>" "<secret_key>"
|
||||
* 示例: 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 <plaintext> <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 * * * ?}
|
||||
|
|
@ -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 > NOW()
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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结果缓存表';
|
||||
|
|
@ -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"
|
||||
}'
|
||||
```
|
||||
Loading…
Reference in New Issue