init commit

This commit is contained in:
wangxiu 2026-07-09 12:28:44 +08:00
commit 457e43fca8
50 changed files with 2177 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@ -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/

8
Dockerfile Normal file
View File

@ -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"]

81
pom.xml Normal file
View File

@ -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>

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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 "{}";
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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();
}
}

View File

@ -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"));
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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 "{}";
}
}
}

View File

@ -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;
}
}

View File

@ -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
+ "&timestamp=" + timestamp;
HmacUtils hmac = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secretKey);
return hmac.hmacHex(plaintext);
}
}

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -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 {
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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 * * * ?}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
);

View File

@ -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结果缓存表';

146
对接接口文档.md Normal file
View File

@ -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"
}'
```