yumeng 2 tygodni temu
rodzic
commit
4e5a2c9fdb
39 zmienionych plików z 2157 dodań i 0 usunięć
  1. 141 0
      src/main/java/com/adx/tencent/baiduclue/client/BaiduClueClient.java
  2. 100 0
      src/main/java/com/adx/tencent/baiduclue/config/BaiduClueProperties.java
  3. 58 0
      src/main/java/com/adx/tencent/baiduclue/config/BaiduClueStoreConfiguration.java
  4. 46 0
      src/main/java/com/adx/tencent/baiduclue/controller/BaiduClueController.java
  5. 95 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduClueApiCallResult.java
  6. 41 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduClueDisclosureEntry.java
  7. 168 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduClueItem.java
  8. 23 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduConsultItem.java
  9. 32 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduFormDetailItem.java
  10. 37 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduMediaInfo.java
  11. 50 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduRequestEnvelope.java
  12. 49 0
      src/main/java/com/adx/tencent/baiduclue/model/BaiduTradeInfoItem.java
  13. 23 0
      src/main/java/com/adx/tencent/baiduclue/model/BaseResponse.java
  14. 27 0
      src/main/java/com/adx/tencent/baiduclue/model/ClueInfoQuery.java
  15. 17 0
      src/main/java/com/adx/tencent/baiduclue/model/ClueInfoResponse.java
  16. 160 0
      src/main/java/com/adx/tencent/baiduclue/model/InternalLeadClue.java
  17. 26 0
      src/main/java/com/adx/tencent/baiduclue/model/PrefetchResponse.java
  18. 17 0
      src/main/java/com/adx/tencent/baiduclue/model/SubmitResponse.java
  19. 176 0
      src/main/java/com/adx/tencent/baiduclue/service/BaiduCluePersistenceService.java
  20. 75 0
      src/main/java/com/adx/tencent/baiduclue/service/BaiduClueService.java
  21. 40 0
      src/main/java/com/adx/tencent/baiduclue/service/ClueDisclosureParser.java
  22. 47 0
      src/main/java/com/adx/tencent/baiduclue/service/CluePayloadBuilder.java
  23. 76 0
      src/main/java/com/adx/tencent/baiduclue/service/ClueValidationService.java
  24. 165 0
      src/main/java/com/adx/tencent/baiduclue/storage/BaiduClueColdStore.java
  25. 27 0
      src/main/java/com/adx/tencent/baiduclue/storage/mapper/BaiduClueApiLogMapper.java
  26. 17 0
      src/main/java/com/adx/tencent/baiduclue/storage/mapper/BaiduClueDisclosureDetailMapper.java
  27. 40 0
      src/main/java/com/adx/tencent/baiduclue/storage/mapper/BaiduClueLeadMapper.java
  28. 59 0
      src/main/java/com/adx/tencent/baiduclue/storage/model/BaiduClueApiLogRecord.java
  29. 29 0
      src/main/java/com/adx/tencent/baiduclue/storage/model/BaiduClueDisclosureDetailRecord.java
  30. 98 0
      src/main/java/com/adx/tencent/baiduclue/storage/model/BaiduClueLeadRecord.java
  31. 21 0
      src/main/java/com/adx/tencent/baiduclue/util/Md5Signer.java
  32. 53 0
      src/main/java/com/adx/tencent/baiduclue/util/RsaEncryptor.java
  33. 12 0
      src/main/resources/application-dev.yml
  34. 12 0
      src/main/resources/application-prod.yml
  35. 12 0
      src/main/resources/application-test.yml
  36. 13 0
      src/main/resources/application.yml
  37. 16 0
      src/main/resources/mapper/BaiduClueApiLogMapper.xml
  38. 12 0
      src/main/resources/mapper/BaiduClueDisclosureDetailMapper.xml
  39. 47 0
      src/main/resources/mapper/BaiduClueLeadMapper.xml

+ 141 - 0
src/main/java/com/adx/tencent/baiduclue/client/BaiduClueClient.java

@@ -0,0 +1,141 @@
+package com.adx.tencent.baiduclue.client;
+
+import com.adx.tencent.baiduclue.config.BaiduClueProperties;
+import com.adx.tencent.baiduclue.model.BaiduRequestEnvelope;
+import com.adx.tencent.baiduclue.model.BaiduClueApiCallResult;
+import com.adx.tencent.baiduclue.model.ClueInfoQuery;
+import com.adx.tencent.baiduclue.model.ClueInfoResponse;
+import com.adx.tencent.baiduclue.model.PrefetchResponse;
+import com.adx.tencent.baiduclue.model.SubmitResponse;
+import com.adx.tencent.baiduclue.util.Md5Signer;
+import com.adx.tencent.baiduclue.util.RsaEncryptor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+@Component
+public class BaiduClueClient {
+
+    private static final Logger log = LoggerFactory.getLogger(BaiduClueClient.class);
+    private static final int ACCESS_TYPE = 4;
+
+    private final BaiduClueProperties properties;
+    private final ObjectMapper objectMapper;
+    private final HttpClient httpClient;
+
+    public BaiduClueClient(BaiduClueProperties properties, ObjectMapper objectMapper) {
+        this.properties = properties;
+        this.objectMapper = objectMapper;
+        this.httpClient = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofSeconds(properties.getConnectTimeoutSeconds()))
+                .build();
+    }
+
+    public BaiduClueApiCallResult<SubmitResponse> submitClue(Object payload) throws IOException, InterruptedException {
+        String timestamp = String.valueOf(Instant.now().getEpochSecond());
+        return post("/data/clue/post", payload, timestamp, SubmitResponse.class);
+    }
+
+    public BaiduClueApiCallResult<PrefetchResponse> prefetchClue(Object payload) throws IOException, InterruptedException {
+        String timestamp = String.valueOf(System.currentTimeMillis());
+        return post("/data/clue/prefetch", payload, timestamp, PrefetchResponse.class);
+    }
+
+    public BaiduClueApiCallResult<ClueInfoResponse> queryClueInfo(ClueInfoQuery query) throws IOException, InterruptedException {
+        String timestamp = String.valueOf(Instant.now().getEpochSecond());
+        return post("/data/clue/clueinfo", List.of(query), timestamp, ClueInfoResponse.class);
+    }
+
+    private <T> BaiduClueApiCallResult<T> post(String path, Object payload, String timestamp, Class<T> responseType)
+            throws IOException, InterruptedException {
+        validateConfig();
+
+        String plainData = objectMapper.writeValueAsString(payload);
+        String encryptedData = RsaEncryptor.encrypt(plainData, resolvePublicKey());
+        String signature = Md5Signer.sign(timestamp, properties.getToken(), encryptedData);
+
+        BaiduRequestEnvelope body = new BaiduRequestEnvelope();
+        body.setAppsid(properties.getAppsid());
+        body.setTimestamp(timestamp);
+        body.setAccessType(ACCESS_TYPE);
+        body.setData(encryptedData);
+        body.setSignature(signature);
+
+        byte[] bodyBytes = objectMapper.writeValueAsBytes(body);
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(normalizeBaseUrl() + path))
+                .header("Content-Type", "application/json")
+                .timeout(Duration.ofSeconds(properties.getReadTimeoutSeconds()))
+                .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes))
+                .build();
+
+        HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+        String responseBody = new String(response.body());
+        log.info("[baidu-clue] path={} status={} body={}", path, response.statusCode(), responseBody);
+        if (response.statusCode() < 200 || response.statusCode() >= 300) {
+            throw new IOException("baidu clue http status: " + response.statusCode());
+        }
+        BaiduClueApiCallResult<T> result = new BaiduClueApiCallResult<>();
+        result.setApiType(apiTypeFromPath(path));
+        result.setRequestUrl(normalizeBaseUrl() + path);
+        result.setRequestTimestamp(timestamp);
+        result.setAccessType(ACCESS_TYPE);
+        result.setEncryptedData(encryptedData);
+        result.setSignature(signature);
+        result.setRequestBody(new String(bodyBytes));
+        result.setHttpStatus(response.statusCode());
+        result.setResponseBody(responseBody);
+        result.setResponse(objectMapper.readValue(response.body(), responseType));
+        return result;
+    }
+
+    private void validateConfig() {
+        require(properties.getBaseUrl(), "baidu.clue.base-url is required");
+        require(properties.getAppsid(), "baidu.clue.appsid is required");
+        require(properties.getToken(), "baidu.clue.token is required");
+        if ((properties.getPublicKey() == null || properties.getPublicKey().isBlank())
+                && (properties.getPublicKeyPath() == null || properties.getPublicKeyPath().isBlank())) {
+            throw new IllegalStateException("baidu.clue.public-key or baidu.clue.public-key-path is required");
+        }
+    }
+
+    private String normalizeBaseUrl() {
+        return properties.getBaseUrl().replaceAll("/+$", "");
+    }
+
+    private String resolvePublicKey() {
+        if (properties.getPublicKey() != null && !properties.getPublicKey().isBlank()) {
+            return properties.getPublicKey();
+        }
+        try {
+            return Files.readString(Path.of(properties.getPublicKeyPath()));
+        } catch (IOException e) {
+            throw new IllegalStateException("failed to read baidu clue public key file: " + properties.getPublicKeyPath(), e);
+        }
+    }
+
+    private static void require(String value, String message) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalStateException(message);
+        }
+    }
+
+    private static String apiTypeFromPath(String path) {
+        if (path.endsWith("/post")) return "submit";
+        if (path.endsWith("/prefetch")) return "prefetch";
+        if (path.endsWith("/clueinfo")) return "clueinfo";
+        return path;
+    }
+}

+ 100 - 0
src/main/java/com/adx/tencent/baiduclue/config/BaiduClueProperties.java

@@ -0,0 +1,100 @@
+package com.adx.tencent.baiduclue.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "baidu.clue")
+public class BaiduClueProperties {
+
+    private String baseUrl = "https://cpu-bside.baidu.com";
+    private String appsid;
+    private String token;
+    private String publicKey;
+    private String publicKeyPath;
+    private Integer mediaType = 4;
+    private String mediaUrl;
+    private String mediaWebsite;
+    private int connectTimeoutSeconds = 10;
+    private int readTimeoutSeconds = 30;
+
+    public String getBaseUrl() {
+        return baseUrl;
+    }
+
+    public void setBaseUrl(String baseUrl) {
+        this.baseUrl = baseUrl;
+    }
+
+    public String getAppsid() {
+        return appsid;
+    }
+
+    public void setAppsid(String appsid) {
+        this.appsid = appsid;
+    }
+
+    public String getToken() {
+        return token;
+    }
+
+    public void setToken(String token) {
+        this.token = token;
+    }
+
+    public String getPublicKey() {
+        return publicKey;
+    }
+
+    public void setPublicKey(String publicKey) {
+        this.publicKey = publicKey;
+    }
+
+    public String getPublicKeyPath() {
+        return publicKeyPath;
+    }
+
+    public void setPublicKeyPath(String publicKeyPath) {
+        this.publicKeyPath = publicKeyPath;
+    }
+
+    public Integer getMediaType() {
+        return mediaType;
+    }
+
+    public void setMediaType(Integer mediaType) {
+        this.mediaType = mediaType;
+    }
+
+    public String getMediaUrl() {
+        return mediaUrl;
+    }
+
+    public void setMediaUrl(String mediaUrl) {
+        this.mediaUrl = mediaUrl;
+    }
+
+    public String getMediaWebsite() {
+        return mediaWebsite;
+    }
+
+    public void setMediaWebsite(String mediaWebsite) {
+        this.mediaWebsite = mediaWebsite;
+    }
+
+    public int getConnectTimeoutSeconds() {
+        return connectTimeoutSeconds;
+    }
+
+    public void setConnectTimeoutSeconds(int connectTimeoutSeconds) {
+        this.connectTimeoutSeconds = connectTimeoutSeconds;
+    }
+
+    public int getReadTimeoutSeconds() {
+        return readTimeoutSeconds;
+    }
+
+    public void setReadTimeoutSeconds(int readTimeoutSeconds) {
+        this.readTimeoutSeconds = readTimeoutSeconds;
+    }
+}

+ 58 - 0
src/main/java/com/adx/tencent/baiduclue/config/BaiduClueStoreConfiguration.java

@@ -0,0 +1,58 @@
+package com.adx.tencent.baiduclue.config;
+
+import com.adx.tencent.baiduclue.storage.BaiduClueColdStore;
+import com.adx.tencent.config.AppProperties;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.lang.Nullable;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+
+import javax.sql.DataSource;
+
+@Configuration
+public class BaiduClueStoreConfiguration {
+
+    private static final Logger log = LoggerFactory.getLogger(BaiduClueStoreConfiguration.class);
+
+    @Bean
+    public BaiduClueColdStore baiduClueColdStore(AppProperties props) {
+        String url = props.getTidbUrl();
+        if (url == null || url.isBlank()) {
+            log.info("TiDB URL is empty; baidu clue cold storage disabled");
+            return null;
+        }
+        try {
+            DataSource dataSource = createDataSource(url, props.getTidbUsername(), props.getTidbPassword());
+            SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(dataSource);
+            BaiduClueColdStore store = new BaiduClueColdStore(sqlSessionFactory);
+            store.migrate();
+            log.info("Baidu clue cold store initialized and migrated");
+            return store;
+        } catch (Exception e) {
+            log.warn("Failed to open Baidu clue cold store: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    private SqlSessionFactory createSqlSessionFactory(DataSource dataSource) throws Exception {
+        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
+        factoryBean.setDataSource(dataSource);
+        factoryBean.setMapperLocations(
+                new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
+        return factoryBean.getObject();
+    }
+
+    private DataSource createDataSource(String jdbcUrl, String username, String password) {
+        DriverManagerDataSource ds = new DriverManagerDataSource();
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+        ds.setUrl(jdbcUrl);
+        if (username != null && !username.isBlank()) ds.setUsername(username);
+        if (password != null && !password.isBlank()) ds.setPassword(password);
+        return ds;
+    }
+}

+ 46 - 0
src/main/java/com/adx/tencent/baiduclue/controller/BaiduClueController.java

@@ -0,0 +1,46 @@
+package com.adx.tencent.baiduclue.controller;
+
+import com.adx.tencent.baiduclue.model.ClueInfoResponse;
+import com.adx.tencent.baiduclue.model.InternalLeadClue;
+import com.adx.tencent.baiduclue.model.PrefetchResponse;
+import com.adx.tencent.baiduclue.model.SubmitResponse;
+import com.adx.tencent.baiduclue.service.BaiduClueService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.util.List;
+
+@RestController
+@RequestMapping("/internal/baidu-clue")
+public class BaiduClueController {
+
+    private final BaiduClueService baiduClueService;
+
+    public BaiduClueController(BaiduClueService baiduClueService) {
+        this.baiduClueService = baiduClueService;
+    }
+
+    @PostMapping("/submit")
+    public SubmitResponse submit(@RequestBody List<InternalLeadClue> clues)
+            throws IOException, InterruptedException {
+        return baiduClueService.submit(clues);
+    }
+
+    @PostMapping("/prefetch")
+    public PrefetchResponse prefetch(@RequestBody List<InternalLeadClue> clues)
+            throws IOException, InterruptedException {
+        return baiduClueService.prefetch(clues);
+    }
+
+    @GetMapping("/clueinfo")
+    public ClueInfoResponse clueInfo(@RequestParam("beginTime") Long beginTime,
+                                     @RequestParam("endTime") Long endTime)
+            throws IOException, InterruptedException {
+        return baiduClueService.queryClueInfo(beginTime, endTime);
+    }
+}

+ 95 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduClueApiCallResult.java

@@ -0,0 +1,95 @@
+package com.adx.tencent.baiduclue.model;
+
+public class BaiduClueApiCallResult<T> {
+
+    private String apiType;
+    private String requestUrl;
+    private String requestTimestamp;
+    private Integer accessType;
+    private String encryptedData;
+    private String signature;
+    private String requestBody;
+    private Integer httpStatus;
+    private String responseBody;
+    private T response;
+
+    public String getApiType() {
+        return apiType;
+    }
+
+    public void setApiType(String apiType) {
+        this.apiType = apiType;
+    }
+
+    public String getRequestUrl() {
+        return requestUrl;
+    }
+
+    public void setRequestUrl(String requestUrl) {
+        this.requestUrl = requestUrl;
+    }
+
+    public String getRequestTimestamp() {
+        return requestTimestamp;
+    }
+
+    public void setRequestTimestamp(String requestTimestamp) {
+        this.requestTimestamp = requestTimestamp;
+    }
+
+    public Integer getAccessType() {
+        return accessType;
+    }
+
+    public void setAccessType(Integer accessType) {
+        this.accessType = accessType;
+    }
+
+    public String getEncryptedData() {
+        return encryptedData;
+    }
+
+    public void setEncryptedData(String encryptedData) {
+        this.encryptedData = encryptedData;
+    }
+
+    public String getSignature() {
+        return signature;
+    }
+
+    public void setSignature(String signature) {
+        this.signature = signature;
+    }
+
+    public String getRequestBody() {
+        return requestBody;
+    }
+
+    public void setRequestBody(String requestBody) {
+        this.requestBody = requestBody;
+    }
+
+    public Integer getHttpStatus() {
+        return httpStatus;
+    }
+
+    public void setHttpStatus(Integer httpStatus) {
+        this.httpStatus = httpStatus;
+    }
+
+    public String getResponseBody() {
+        return responseBody;
+    }
+
+    public void setResponseBody(String responseBody) {
+        this.responseBody = responseBody;
+    }
+
+    public T getResponse() {
+        return response;
+    }
+
+    public void setResponse(T response) {
+        this.response = response;
+    }
+}

+ 41 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduClueDisclosureEntry.java

@@ -0,0 +1,41 @@
+package com.adx.tencent.baiduclue.model;
+
+public class BaiduClueDisclosureEntry {
+
+    private String clueTime;
+    private String clueId;
+    private Integer status;
+    private String rawMessage;
+
+    public String getClueTime() {
+        return clueTime;
+    }
+
+    public void setClueTime(String clueTime) {
+        this.clueTime = clueTime;
+    }
+
+    public String getClueId() {
+        return clueId;
+    }
+
+    public void setClueId(String clueId) {
+        this.clueId = clueId;
+    }
+
+    public Integer getStatus() {
+        return status;
+    }
+
+    public void setStatus(Integer status) {
+        this.status = status;
+    }
+
+    public String getRawMessage() {
+        return rawMessage;
+    }
+
+    public void setRawMessage(String rawMessage) {
+        this.rawMessage = rawMessage;
+    }
+}

+ 168 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduClueItem.java

@@ -0,0 +1,168 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BaiduClueItem {
+
+    private String clueId;
+    private String tuId;
+    private Long postTime;
+    private List<String> query;
+    private Integer tradeLevel1;
+    private Integer tradeLevel2;
+    private Integer pid;
+    private Integer cid;
+    private String infoLinker;
+    private String infoPhone;
+    private String ip;
+    @JsonProperty("user_agent")
+    private String userAgent;
+    private Integer tradeType;
+    private List<BaiduTradeInfoItem> tradeInfo;
+    @JsonProperty("form_detail")
+    private List<BaiduFormDetailItem> formDetail;
+    @JsonProperty("media_info")
+    private BaiduMediaInfo mediaInfo;
+    @JsonProperty("consult_str")
+    private List<BaiduConsultItem> consultStr;
+
+    public String getClueId() {
+        return clueId;
+    }
+
+    public void setClueId(String clueId) {
+        this.clueId = clueId;
+    }
+
+    public String getTuId() {
+        return tuId;
+    }
+
+    public void setTuId(String tuId) {
+        this.tuId = tuId;
+    }
+
+    public Long getPostTime() {
+        return postTime;
+    }
+
+    public void setPostTime(Long postTime) {
+        this.postTime = postTime;
+    }
+
+    public List<String> getQuery() {
+        return query;
+    }
+
+    public void setQuery(List<String> query) {
+        this.query = query;
+    }
+
+    public Integer getTradeLevel1() {
+        return tradeLevel1;
+    }
+
+    public void setTradeLevel1(Integer tradeLevel1) {
+        this.tradeLevel1 = tradeLevel1;
+    }
+
+    public Integer getTradeLevel2() {
+        return tradeLevel2;
+    }
+
+    public void setTradeLevel2(Integer tradeLevel2) {
+        this.tradeLevel2 = tradeLevel2;
+    }
+
+    public Integer getPid() {
+        return pid;
+    }
+
+    public void setPid(Integer pid) {
+        this.pid = pid;
+    }
+
+    public Integer getCid() {
+        return cid;
+    }
+
+    public void setCid(Integer cid) {
+        this.cid = cid;
+    }
+
+    public String getInfoLinker() {
+        return infoLinker;
+    }
+
+    public void setInfoLinker(String infoLinker) {
+        this.infoLinker = infoLinker;
+    }
+
+    public String getInfoPhone() {
+        return infoPhone;
+    }
+
+    public void setInfoPhone(String infoPhone) {
+        this.infoPhone = infoPhone;
+    }
+
+    public String getIp() {
+        return ip;
+    }
+
+    public void setIp(String ip) {
+        this.ip = ip;
+    }
+
+    public String getUserAgent() {
+        return userAgent;
+    }
+
+    public void setUserAgent(String userAgent) {
+        this.userAgent = userAgent;
+    }
+
+    public Integer getTradeType() {
+        return tradeType;
+    }
+
+    public void setTradeType(Integer tradeType) {
+        this.tradeType = tradeType;
+    }
+
+    public List<BaiduTradeInfoItem> getTradeInfo() {
+        return tradeInfo;
+    }
+
+    public void setTradeInfo(List<BaiduTradeInfoItem> tradeInfo) {
+        this.tradeInfo = tradeInfo;
+    }
+
+    public List<BaiduFormDetailItem> getFormDetail() {
+        return formDetail;
+    }
+
+    public void setFormDetail(List<BaiduFormDetailItem> formDetail) {
+        this.formDetail = formDetail;
+    }
+
+    public BaiduMediaInfo getMediaInfo() {
+        return mediaInfo;
+    }
+
+    public void setMediaInfo(BaiduMediaInfo mediaInfo) {
+        this.mediaInfo = mediaInfo;
+    }
+
+    public List<BaiduConsultItem> getConsultStr() {
+        return consultStr;
+    }
+
+    public void setConsultStr(List<BaiduConsultItem> consultStr) {
+        this.consultStr = consultStr;
+    }
+}

+ 23 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduConsultItem.java

@@ -0,0 +1,23 @@
+package com.adx.tencent.baiduclue.model;
+
+public class BaiduConsultItem {
+
+    private String word;
+    private String type;
+
+    public String getWord() {
+        return word;
+    }
+
+    public void setWord(String word) {
+        this.word = word;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+}

+ 32 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduFormDetailItem.java

@@ -0,0 +1,32 @@
+package com.adx.tencent.baiduclue.model;
+
+public class BaiduFormDetailItem {
+
+    private String type;
+    private String name;
+    private String value;
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+}

+ 37 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduMediaInfo.java

@@ -0,0 +1,37 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class BaiduMediaInfo {
+
+    @JsonProperty("media_type")
+    private Integer mediaType;
+    @JsonProperty("media_url")
+    private String mediaUrl;
+    @JsonProperty("media_website")
+    private String mediaWebsite;
+
+    public Integer getMediaType() {
+        return mediaType;
+    }
+
+    public void setMediaType(Integer mediaType) {
+        this.mediaType = mediaType;
+    }
+
+    public String getMediaUrl() {
+        return mediaUrl;
+    }
+
+    public void setMediaUrl(String mediaUrl) {
+        this.mediaUrl = mediaUrl;
+    }
+
+    public String getMediaWebsite() {
+        return mediaWebsite;
+    }
+
+    public void setMediaWebsite(String mediaWebsite) {
+        this.mediaWebsite = mediaWebsite;
+    }
+}

+ 50 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduRequestEnvelope.java

@@ -0,0 +1,50 @@
+package com.adx.tencent.baiduclue.model;
+
+public class BaiduRequestEnvelope {
+
+    private String appsid;
+    private String timestamp;
+    private Integer accessType;
+    private String data;
+    private String signature;
+
+    public String getAppsid() {
+        return appsid;
+    }
+
+    public void setAppsid(String appsid) {
+        this.appsid = appsid;
+    }
+
+    public String getTimestamp() {
+        return timestamp;
+    }
+
+    public void setTimestamp(String timestamp) {
+        this.timestamp = timestamp;
+    }
+
+    public Integer getAccessType() {
+        return accessType;
+    }
+
+    public void setAccessType(Integer accessType) {
+        this.accessType = accessType;
+    }
+
+    public String getData() {
+        return data;
+    }
+
+    public void setData(String data) {
+        this.data = data;
+    }
+
+    public String getSignature() {
+        return signature;
+    }
+
+    public void setSignature(String signature) {
+        this.signature = signature;
+    }
+}

+ 49 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaiduTradeInfoItem.java

@@ -0,0 +1,49 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BaiduTradeInfoItem {
+
+    @JsonProperty("feature_level")
+    private Integer featureLevel;
+    @JsonProperty("feature_name")
+    private String featureName;
+    @JsonProperty("feature_type")
+    private String featureType;
+    @JsonProperty("original_featureid")
+    private Integer originalFeatureId;
+
+    public Integer getFeatureLevel() {
+        return featureLevel;
+    }
+
+    public void setFeatureLevel(Integer featureLevel) {
+        this.featureLevel = featureLevel;
+    }
+
+    public String getFeatureName() {
+        return featureName;
+    }
+
+    public void setFeatureName(String featureName) {
+        this.featureName = featureName;
+    }
+
+    public String getFeatureType() {
+        return featureType;
+    }
+
+    public void setFeatureType(String featureType) {
+        this.featureType = featureType;
+    }
+
+    public Integer getOriginalFeatureId() {
+        return originalFeatureId;
+    }
+
+    public void setOriginalFeatureId(Integer originalFeatureId) {
+        this.originalFeatureId = originalFeatureId;
+    }
+}

+ 23 - 0
src/main/java/com/adx/tencent/baiduclue/model/BaseResponse.java

@@ -0,0 +1,23 @@
+package com.adx.tencent.baiduclue.model;
+
+public class BaseResponse {
+
+    private Integer code;
+    private String msg;
+
+    public Integer getCode() {
+        return code;
+    }
+
+    public void setCode(Integer code) {
+        this.code = code;
+    }
+
+    public String getMsg() {
+        return msg;
+    }
+
+    public void setMsg(String msg) {
+        this.msg = msg;
+    }
+}

+ 27 - 0
src/main/java/com/adx/tencent/baiduclue/model/ClueInfoQuery.java

@@ -0,0 +1,27 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class ClueInfoQuery {
+
+    @JsonProperty("begin_time")
+    private Long beginTime;
+    @JsonProperty("end_time")
+    private Long endTime;
+
+    public Long getBeginTime() {
+        return beginTime;
+    }
+
+    public void setBeginTime(Long beginTime) {
+        this.beginTime = beginTime;
+    }
+
+    public Long getEndTime() {
+        return endTime;
+    }
+
+    public void setEndTime(Long endTime) {
+        this.endTime = endTime;
+    }
+}

+ 17 - 0
src/main/java/com/adx/tencent/baiduclue/model/ClueInfoResponse.java

@@ -0,0 +1,17 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class ClueInfoResponse {
+
+    @JsonProperty("clueinfoResponse")
+    private BaseResponse clueinfoResponse;
+
+    public BaseResponse getClueinfoResponse() {
+        return clueinfoResponse;
+    }
+
+    public void setClueinfoResponse(BaseResponse clueinfoResponse) {
+        this.clueinfoResponse = clueinfoResponse;
+    }
+}

+ 160 - 0
src/main/java/com/adx/tencent/baiduclue/model/InternalLeadClue.java

@@ -0,0 +1,160 @@
+package com.adx.tencent.baiduclue.model;
+
+import java.util.List;
+
+public class InternalLeadClue {
+
+    private String clueId;
+    private String tuId;
+    private Long postTime;
+    private List<String> query;
+    private Integer tradeLevel1;
+    private Integer tradeLevel2;
+    private Integer pid;
+    private Integer cid;
+    private String infoLinker;
+    private String infoPhone;
+    private String ip;
+    private String userAgent;
+    private Integer tradeType;
+    private List<BaiduTradeInfoItem> tradeInfo;
+    private List<BaiduFormDetailItem> formDetail;
+    private List<BaiduConsultItem> consultStr;
+    private BaiduMediaInfo mediaInfo;
+
+    public String getClueId() {
+        return clueId;
+    }
+
+    public void setClueId(String clueId) {
+        this.clueId = clueId;
+    }
+
+    public String getTuId() {
+        return tuId;
+    }
+
+    public void setTuId(String tuId) {
+        this.tuId = tuId;
+    }
+
+    public Long getPostTime() {
+        return postTime;
+    }
+
+    public void setPostTime(Long postTime) {
+        this.postTime = postTime;
+    }
+
+    public List<String> getQuery() {
+        return query;
+    }
+
+    public void setQuery(List<String> query) {
+        this.query = query;
+    }
+
+    public Integer getTradeLevel1() {
+        return tradeLevel1;
+    }
+
+    public void setTradeLevel1(Integer tradeLevel1) {
+        this.tradeLevel1 = tradeLevel1;
+    }
+
+    public Integer getTradeLevel2() {
+        return tradeLevel2;
+    }
+
+    public void setTradeLevel2(Integer tradeLevel2) {
+        this.tradeLevel2 = tradeLevel2;
+    }
+
+    public Integer getPid() {
+        return pid;
+    }
+
+    public void setPid(Integer pid) {
+        this.pid = pid;
+    }
+
+    public Integer getCid() {
+        return cid;
+    }
+
+    public void setCid(Integer cid) {
+        this.cid = cid;
+    }
+
+    public String getInfoLinker() {
+        return infoLinker;
+    }
+
+    public void setInfoLinker(String infoLinker) {
+        this.infoLinker = infoLinker;
+    }
+
+    public String getInfoPhone() {
+        return infoPhone;
+    }
+
+    public void setInfoPhone(String infoPhone) {
+        this.infoPhone = infoPhone;
+    }
+
+    public String getIp() {
+        return ip;
+    }
+
+    public void setIp(String ip) {
+        this.ip = ip;
+    }
+
+    public String getUserAgent() {
+        return userAgent;
+    }
+
+    public void setUserAgent(String userAgent) {
+        this.userAgent = userAgent;
+    }
+
+    public Integer getTradeType() {
+        return tradeType;
+    }
+
+    public void setTradeType(Integer tradeType) {
+        this.tradeType = tradeType;
+    }
+
+    public List<BaiduTradeInfoItem> getTradeInfo() {
+        return tradeInfo;
+    }
+
+    public void setTradeInfo(List<BaiduTradeInfoItem> tradeInfo) {
+        this.tradeInfo = tradeInfo;
+    }
+
+    public List<BaiduFormDetailItem> getFormDetail() {
+        return formDetail;
+    }
+
+    public void setFormDetail(List<BaiduFormDetailItem> formDetail) {
+        this.formDetail = formDetail;
+    }
+
+    public List<BaiduConsultItem> getConsultStr() {
+        return consultStr;
+    }
+
+    public void setConsultStr(List<BaiduConsultItem> consultStr) {
+        this.consultStr = consultStr;
+    }
+
+    public BaiduMediaInfo getMediaInfo() {
+        return mediaInfo;
+    }
+
+    public void setMediaInfo(BaiduMediaInfo mediaInfo) {
+        this.mediaInfo = mediaInfo;
+    }
+}

+ 26 - 0
src/main/java/com/adx/tencent/baiduclue/model/PrefetchResponse.java

@@ -0,0 +1,26 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class PrefetchResponse {
+
+    @JsonProperty("baseResponse")
+    private BaseResponse baseResponse;
+    private Integer bizCode;
+
+    public BaseResponse getBaseResponse() {
+        return baseResponse;
+    }
+
+    public void setBaseResponse(BaseResponse baseResponse) {
+        this.baseResponse = baseResponse;
+    }
+
+    public Integer getBizCode() {
+        return bizCode;
+    }
+
+    public void setBizCode(Integer bizCode) {
+        this.bizCode = bizCode;
+    }
+}

+ 17 - 0
src/main/java/com/adx/tencent/baiduclue/model/SubmitResponse.java

@@ -0,0 +1,17 @@
+package com.adx.tencent.baiduclue.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class SubmitResponse {
+
+    @JsonProperty("baseResponse")
+    private BaseResponse baseResponse;
+
+    public BaseResponse getBaseResponse() {
+        return baseResponse;
+    }
+
+    public void setBaseResponse(BaseResponse baseResponse) {
+        this.baseResponse = baseResponse;
+    }
+}

+ 176 - 0
src/main/java/com/adx/tencent/baiduclue/service/BaiduCluePersistenceService.java

@@ -0,0 +1,176 @@
+package com.adx.tencent.baiduclue.service;
+
+import com.adx.tencent.baiduclue.config.BaiduClueProperties;
+import com.adx.tencent.baiduclue.model.*;
+import com.adx.tencent.baiduclue.storage.BaiduClueColdStore;
+import com.adx.tencent.baiduclue.storage.model.BaiduClueApiLogRecord;
+import com.adx.tencent.baiduclue.storage.model.BaiduClueDisclosureDetailRecord;
+import com.adx.tencent.baiduclue.storage.model.BaiduClueLeadRecord;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.List;
+
+@Service
+public class BaiduCluePersistenceService {
+
+    private final BaiduClueProperties properties;
+    private final ObjectMapper objectMapper;
+    private final BaiduClueColdStore coldStore;
+
+    public BaiduCluePersistenceService(BaiduClueProperties properties,
+                                       ObjectMapper objectMapper,
+                                       @Nullable BaiduClueColdStore coldStore) {
+        this.properties = properties;
+        this.objectMapper = objectMapper;
+        this.coldStore = coldStore;
+    }
+
+    public void saveSubmit(List<InternalLeadClue> clues, BaiduClueApiCallResult<SubmitResponse> result) {
+        if (coldStore == null) return;
+        Instant now = Instant.now();
+        SubmitResponse response = result.getResponse();
+        Integer code = response != null && response.getBaseResponse() != null ? response.getBaseResponse().getCode() : null;
+        String msg = response != null && response.getBaseResponse() != null ? response.getBaseResponse().getMsg() : null;
+
+        for (InternalLeadClue clue : clues) {
+            BaiduClueLeadRecord lead = baseLeadRecord(clue, now);
+            lead.setSubmitCode(code);
+            lead.setSubmitMsg(msg);
+            lead.setLastSubmitAt(now);
+            coldStore.saveLead(lead);
+
+            BaiduClueApiLogRecord log = baseLogRecord(clue.getClueId(), result, now);
+            log.setResponseCode(code);
+            log.setResponseMsg(msg);
+            log.setSuccess(code != null && code == 200);
+            coldStore.saveApiLog(log);
+        }
+    }
+
+    public void savePrefetch(List<InternalLeadClue> clues, BaiduClueApiCallResult<PrefetchResponse> result) {
+        if (coldStore == null) return;
+        Instant now = Instant.now();
+        PrefetchResponse response = result.getResponse();
+        Integer code = response != null && response.getBaseResponse() != null ? response.getBaseResponse().getCode() : null;
+        String msg = response != null && response.getBaseResponse() != null ? response.getBaseResponse().getMsg() : null;
+        Integer bizCode = response != null ? response.getBizCode() : null;
+
+        for (InternalLeadClue clue : clues) {
+            BaiduClueLeadRecord lead = baseLeadRecord(clue, now);
+            lead.setPrefetchBizCode(bizCode);
+            lead.setPrefetchCode(code);
+            lead.setPrefetchMsg(msg);
+            lead.setLastPrefetchAt(now);
+            coldStore.saveLead(lead);
+
+            BaiduClueApiLogRecord log = baseLogRecord(clue.getClueId(), result, now);
+            log.setResponseCode(code);
+            log.setResponseMsg(msg);
+            log.setBizCode(bizCode);
+            log.setSuccess(code != null && code == 200);
+            coldStore.saveApiLog(log);
+        }
+    }
+
+    public void saveClueInfo(Long beginTime,
+                             Long endTime,
+                             BaiduClueApiCallResult<ClueInfoResponse> result,
+                             List<BaiduClueDisclosureEntry> entries) {
+        if (coldStore == null) return;
+        Instant now = Instant.now();
+        ClueInfoResponse response = result.getResponse();
+        Integer code = response != null && response.getClueinfoResponse() != null ? response.getClueinfoResponse().getCode() : null;
+        String msg = response != null && response.getClueinfoResponse() != null ? response.getClueinfoResponse().getMsg() : null;
+
+        BaiduClueApiLogRecord log = baseLogRecord(null, result, now);
+        log.setResponseCode(code);
+        log.setResponseMsg(msg);
+        log.setSuccess(code != null && code == 200);
+        coldStore.saveApiLog(log);
+
+        for (BaiduClueDisclosureEntry entry : entries) {
+            BaiduClueDisclosureDetailRecord detail = new BaiduClueDisclosureDetailRecord();
+            detail.setQueryBeginTime(beginTime);
+            detail.setQueryEndTime(endTime);
+            detail.setClueTime(entry.getClueTime());
+            detail.setClueId(entry.getClueId());
+            detail.setStatus(entry.getStatus());
+            detail.setRawMessage(entry.getRawMessage());
+            detail.setCreatedAt(now);
+            coldStore.saveDisclosureDetail(detail);
+
+            BaiduClueLeadRecord lead = new BaiduClueLeadRecord();
+            lead.setClueId(entry.getClueId());
+            lead.setAppsid(properties.getAppsid());
+            lead.setPostTime(0L);
+            lead.setQueryWords("[]");
+            lead.setTradeLevel1(0);
+            lead.setTradeLevel2(0);
+            lead.setPid(0);
+            lead.setCid(0);
+            lead.setInfoLinker("");
+            lead.setInfoPhone("");
+            lead.setIp("");
+            lead.setDisclosureStatus(entry.getStatus());
+            lead.setDisclosureTime(entry.getClueTime());
+            lead.setLastDisclosureAt(now);
+            lead.setCreatedAt(now);
+            lead.setUpdatedAt(now);
+            coldStore.saveLead(lead);
+        }
+    }
+
+    private BaiduClueLeadRecord baseLeadRecord(InternalLeadClue clue, Instant now) {
+        BaiduClueLeadRecord lead = new BaiduClueLeadRecord();
+        lead.setClueId(clue.getClueId());
+        lead.setAppsid(properties.getAppsid());
+        lead.setTuId(clue.getTuId());
+        lead.setPostTime(clue.getPostTime());
+        lead.setQueryWords(toJson(clue.getQuery()));
+        lead.setTradeLevel1(clue.getTradeLevel1());
+        lead.setTradeLevel2(clue.getTradeLevel2());
+        lead.setTradeType(clue.getTradeType());
+        lead.setTradeInfo(toJson(clue.getTradeInfo()));
+        lead.setFormDetail(toJson(clue.getFormDetail()));
+        lead.setConsultStr(toJson(clue.getConsultStr()));
+        lead.setMediaInfo(toJson(clue.getMediaInfo()));
+        lead.setPid(clue.getPid());
+        lead.setCid(clue.getCid());
+        lead.setInfoLinker(clue.getInfoLinker());
+        lead.setInfoPhone(clue.getInfoPhone());
+        lead.setIp(clue.getIp());
+        lead.setUserAgent(clue.getUserAgent());
+        lead.setCreatedAt(now);
+        lead.setUpdatedAt(now);
+        return lead;
+    }
+
+    private BaiduClueApiLogRecord baseLogRecord(String clueId, BaiduClueApiCallResult<?> result, Instant now) {
+        BaiduClueApiLogRecord log = new BaiduClueApiLogRecord();
+        log.setApiType(result.getApiType());
+        log.setClueId(clueId);
+        log.setAppsid(properties.getAppsid());
+        log.setRequestUrl(result.getRequestUrl());
+        log.setRequestTimestamp(result.getRequestTimestamp());
+        log.setAccessType(result.getAccessType());
+        log.setEncryptedData(result.getEncryptedData());
+        log.setSignature(result.getSignature());
+        log.setRequestBody(result.getRequestBody());
+        log.setHttpStatus(result.getHttpStatus());
+        log.setResponseBody(result.getResponseBody());
+        log.setCreatedAt(now);
+        return log;
+    }
+
+    private String toJson(Object value) {
+        if (value == null) return null;
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            throw new IllegalStateException("failed to serialize baidu clue record", e);
+        }
+    }
+}

+ 75 - 0
src/main/java/com/adx/tencent/baiduclue/service/BaiduClueService.java

@@ -0,0 +1,75 @@
+package com.adx.tencent.baiduclue.service;
+
+import com.adx.tencent.baiduclue.client.BaiduClueClient;
+import com.adx.tencent.baiduclue.model.BaiduClueApiCallResult;
+import com.adx.tencent.baiduclue.model.BaiduClueDisclosureEntry;
+import com.adx.tencent.baiduclue.model.BaiduClueItem;
+import com.adx.tencent.baiduclue.model.ClueInfoQuery;
+import com.adx.tencent.baiduclue.model.ClueInfoResponse;
+import com.adx.tencent.baiduclue.model.InternalLeadClue;
+import com.adx.tencent.baiduclue.model.PrefetchResponse;
+import com.adx.tencent.baiduclue.model.SubmitResponse;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+public class BaiduClueService {
+
+    private final BaiduClueClient client;
+    private final CluePayloadBuilder payloadBuilder;
+    private final ClueValidationService validationService;
+    private final BaiduCluePersistenceService persistenceService;
+    private final ClueDisclosureParser disclosureParser;
+
+    public BaiduClueService(BaiduClueClient client,
+                            CluePayloadBuilder payloadBuilder,
+                            ClueValidationService validationService,
+                            BaiduCluePersistenceService persistenceService,
+                            ClueDisclosureParser disclosureParser) {
+        this.client = client;
+        this.payloadBuilder = payloadBuilder;
+        this.validationService = validationService;
+        this.persistenceService = persistenceService;
+        this.disclosureParser = disclosureParser;
+    }
+
+    public SubmitResponse submit(List<InternalLeadClue> clues) throws IOException, InterruptedException {
+        validationService.validateClues(clues);
+        BaiduClueApiCallResult<SubmitResponse> result = client.submitClue(toPayload(clues));
+        persistenceService.saveSubmit(clues, result);
+        return result.getResponse();
+    }
+
+    public PrefetchResponse prefetch(List<InternalLeadClue> clues) throws IOException, InterruptedException {
+        validationService.validateClues(clues);
+        BaiduClueApiCallResult<PrefetchResponse> result = client.prefetchClue(toPayload(clues));
+        persistenceService.savePrefetch(clues, result);
+        return result.getResponse();
+    }
+
+    public ClueInfoResponse queryClueInfo(Long beginTime, Long endTime) throws IOException, InterruptedException {
+        validationService.validateClueInfoQuery(beginTime, endTime);
+        ClueInfoQuery query = new ClueInfoQuery();
+        query.setBeginTime(beginTime);
+        query.setEndTime(endTime);
+        BaiduClueApiCallResult<ClueInfoResponse> result = client.queryClueInfo(query);
+        List<BaiduClueDisclosureEntry> entries = disclosureParser.parse(
+                result.getResponse() != null && result.getResponse().getClueinfoResponse() != null
+                        ? result.getResponse().getClueinfoResponse().getMsg()
+                        : null
+        );
+        persistenceService.saveClueInfo(beginTime, endTime, result, entries);
+        return result.getResponse();
+    }
+
+    private List<BaiduClueItem> toPayload(List<InternalLeadClue> clues) {
+        List<BaiduClueItem> payload = new ArrayList<>(clues.size());
+        for (InternalLeadClue clue : clues) {
+            payload.add(payloadBuilder.build(clue));
+        }
+        return payload;
+    }
+}

+ 40 - 0
src/main/java/com/adx/tencent/baiduclue/service/ClueDisclosureParser.java

@@ -0,0 +1,40 @@
+package com.adx.tencent.baiduclue.service;
+
+import com.adx.tencent.baiduclue.model.BaiduClueDisclosureEntry;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Component
+public class ClueDisclosureParser {
+
+    private static final Pattern LINE_PATTERN =
+            Pattern.compile("time:(\\S+)\\s+clue_id:(\\S+)\\s+status:(\\d+)");
+
+    public List<BaiduClueDisclosureEntry> parse(String message) {
+        List<BaiduClueDisclosureEntry> entries = new ArrayList<>();
+        if (message == null || message.isBlank()) {
+            return entries;
+        }
+
+        String[] lines = message.split("\\R+");
+        for (String line : lines) {
+            String trimmed = line.trim();
+            if (trimmed.isEmpty()) continue;
+
+            Matcher matcher = LINE_PATTERN.matcher(trimmed);
+            if (!matcher.find()) continue;
+
+            BaiduClueDisclosureEntry entry = new BaiduClueDisclosureEntry();
+            entry.setClueTime(matcher.group(1));
+            entry.setClueId(matcher.group(2));
+            entry.setStatus(Integer.parseInt(matcher.group(3)));
+            entry.setRawMessage(trimmed);
+            entries.add(entry);
+        }
+        return entries;
+    }
+}

+ 47 - 0
src/main/java/com/adx/tencent/baiduclue/service/CluePayloadBuilder.java

@@ -0,0 +1,47 @@
+package com.adx.tencent.baiduclue.service;
+
+import com.adx.tencent.baiduclue.config.BaiduClueProperties;
+import com.adx.tencent.baiduclue.model.BaiduClueItem;
+import com.adx.tencent.baiduclue.model.BaiduMediaInfo;
+import com.adx.tencent.baiduclue.model.InternalLeadClue;
+import org.springframework.stereotype.Component;
+
+@Component
+public class CluePayloadBuilder {
+
+    private final BaiduClueProperties properties;
+
+    public CluePayloadBuilder(BaiduClueProperties properties) {
+        this.properties = properties;
+    }
+
+    public BaiduClueItem build(InternalLeadClue clue) {
+        BaiduClueItem item = new BaiduClueItem();
+        item.setClueId(clue.getClueId());
+        item.setTuId(clue.getTuId());
+        item.setPostTime(clue.getPostTime());
+        item.setQuery(clue.getQuery());
+        item.setTradeLevel1(clue.getTradeLevel1());
+        item.setTradeLevel2(clue.getTradeLevel2());
+        item.setPid(clue.getPid());
+        item.setCid(clue.getCid());
+        item.setInfoLinker(clue.getInfoLinker());
+        item.setInfoPhone(clue.getInfoPhone());
+        item.setIp(clue.getIp());
+        item.setUserAgent(clue.getUserAgent());
+        item.setTradeType(clue.getTradeType());
+        item.setTradeInfo(clue.getTradeInfo());
+        item.setFormDetail(clue.getFormDetail());
+        item.setConsultStr(clue.getConsultStr());
+        item.setMediaInfo(clue.getMediaInfo() != null ? clue.getMediaInfo() : defaultMediaInfo());
+        return item;
+    }
+
+    private BaiduMediaInfo defaultMediaInfo() {
+        BaiduMediaInfo mediaInfo = new BaiduMediaInfo();
+        mediaInfo.setMediaType(properties.getMediaType());
+        mediaInfo.setMediaUrl(properties.getMediaUrl());
+        mediaInfo.setMediaWebsite(properties.getMediaWebsite());
+        return mediaInfo;
+    }
+}

+ 76 - 0
src/main/java/com/adx/tencent/baiduclue/service/ClueValidationService.java

@@ -0,0 +1,76 @@
+package com.adx.tencent.baiduclue.service;
+
+import com.adx.tencent.baiduclue.model.InternalLeadClue;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.List;
+
+@Service
+public class ClueValidationService {
+
+    public void validateClue(InternalLeadClue clue) {
+        requireNotBlank(clue.getClueId(), "clueId is required");
+        requireNotNull(clue.getPostTime(), "postTime is required");
+        requireNotEmpty(clue.getQuery(), "query is required");
+        requireNotNull(clue.getTradeLevel1(), "tradeLevel1 is required");
+        requireNotNull(clue.getTradeLevel2(), "tradeLevel2 is required");
+        requireNotNull(clue.getPid(), "pid is required");
+        requireNotNull(clue.getCid(), "cid is required");
+        requireNotBlank(clue.getInfoLinker(), "infoLinker is required");
+        requireNotBlank(clue.getInfoPhone(), "infoPhone is required");
+        requireNotBlank(clue.getIp(), "ip is required");
+
+        if (!clue.getInfoPhone().matches("^1\\d{10}$")) {
+            throw new IllegalArgumentException("infoPhone format is invalid");
+        }
+        if (clue.getQuery().size() > 10) {
+            throw new IllegalArgumentException("query can contain at most 10 items");
+        }
+        if (clue.getTradeType() != null && (clue.getTradeInfo() == null || clue.getTradeInfo().isEmpty())) {
+            throw new IllegalArgumentException("tradeInfo is required when tradeType is present");
+        }
+        long now = Instant.now().getEpochSecond();
+        if (clue.getPostTime() < now - 24 * 3600L) {
+            throw new IllegalArgumentException("postTime must be within 24 hours");
+        }
+    }
+
+    public void validateClues(List<InternalLeadClue> clues) {
+        if (clues == null || clues.isEmpty()) {
+            throw new IllegalArgumentException("clues are required");
+        }
+        if (clues.size() > 100) {
+            throw new IllegalArgumentException("a request can contain at most 100 clues");
+        }
+        for (InternalLeadClue clue : clues) {
+            validateClue(clue);
+        }
+    }
+
+    public void validateClueInfoQuery(Long beginTime, Long endTime) {
+        requireNotNull(beginTime, "beginTime is required");
+        requireNotNull(endTime, "endTime is required");
+        if (beginTime > endTime) {
+            throw new IllegalArgumentException("beginTime must be <= endTime");
+        }
+    }
+
+    private static void requireNotBlank(String value, String message) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(message);
+        }
+    }
+
+    private static void requireNotNull(Object value, String message) {
+        if (value == null) {
+            throw new IllegalArgumentException(message);
+        }
+    }
+
+    private static void requireNotEmpty(List<?> value, String message) {
+        if (value == null || value.isEmpty()) {
+            throw new IllegalArgumentException(message);
+        }
+    }
+}

+ 165 - 0
src/main/java/com/adx/tencent/baiduclue/storage/BaiduClueColdStore.java

@@ -0,0 +1,165 @@
+package com.adx.tencent.baiduclue.storage;
+
+import com.adx.tencent.baiduclue.storage.mapper.BaiduClueApiLogMapper;
+import com.adx.tencent.baiduclue.storage.mapper.BaiduClueDisclosureDetailMapper;
+import com.adx.tencent.baiduclue.storage.mapper.BaiduClueLeadMapper;
+import com.adx.tencent.baiduclue.storage.model.BaiduClueApiLogRecord;
+import com.adx.tencent.baiduclue.storage.model.BaiduClueDisclosureDetailRecord;
+import com.adx.tencent.baiduclue.storage.model.BaiduClueLeadRecord;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
+
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.time.Instant;
+
+public class BaiduClueColdStore {
+
+    private final SqlSessionFactory sqlSessionFactory;
+
+    public BaiduClueColdStore(SqlSessionFactory sqlSessionFactory) {
+        this.sqlSessionFactory = sqlSessionFactory;
+    }
+
+    public void migrate() {
+        String[] ddls = {
+                """
+                CREATE TABLE IF NOT EXISTS baidu_clue_leads (
+                    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                    clue_id VARCHAR(64) NOT NULL,
+                    appsid VARCHAR(64) NOT NULL,
+                    tu_id VARCHAR(64),
+                    post_time BIGINT NOT NULL,
+                    query_words JSON NOT NULL,
+                    trade_level1 INT NOT NULL,
+                    trade_level2 INT NOT NULL,
+                    trade_type INT,
+                    trade_info JSON,
+                    form_detail JSON,
+                    consult_str JSON,
+                    media_info JSON,
+                    pid INT NOT NULL,
+                    cid INT NOT NULL,
+                    info_linker VARCHAR(64) NOT NULL,
+                    info_phone VARCHAR(32) NOT NULL,
+                    ip VARCHAR(64) NOT NULL,
+                    user_agent TEXT,
+                    prefetch_biz_code INT,
+                    prefetch_code INT,
+                    prefetch_msg VARCHAR(255),
+                    submit_code INT,
+                    submit_msg VARCHAR(255),
+                    disclosure_status INT,
+                    disclosure_time VARCHAR(32),
+                    last_submit_at TIMESTAMP(3) NULL,
+                    last_prefetch_at TIMESTAMP(3) NULL,
+                    last_disclosure_at TIMESTAMP(3) NULL,
+                    created_at TIMESTAMP(3) NOT NULL,
+                    updated_at TIMESTAMP(3) NOT NULL,
+                    UNIQUE KEY uk_baidu_clue_leads_clue_id (clue_id),
+                    KEY idx_baidu_clue_leads_appsid_post_time (appsid, post_time),
+                    KEY idx_baidu_clue_leads_phone_post_time (info_phone, post_time),
+                    KEY idx_baidu_clue_leads_status_updated_at (submit_code, disclosure_status, updated_at)
+                )
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS baidu_clue_api_logs (
+                    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                    api_type VARCHAR(32) NOT NULL,
+                    clue_id VARCHAR(64),
+                    appsid VARCHAR(64) NOT NULL,
+                    request_url VARCHAR(255) NOT NULL,
+                    request_timestamp VARCHAR(32) NOT NULL,
+                    access_type INT NOT NULL DEFAULT 4,
+                    encrypted_data MEDIUMTEXT,
+                    signature VARCHAR(64),
+                    request_body MEDIUMTEXT,
+                    http_status INT,
+                    response_body MEDIUMTEXT,
+                    response_code INT,
+                    response_msg VARCHAR(255),
+                    biz_code INT,
+                    success TINYINT(1) NOT NULL DEFAULT 0,
+                    error_message TEXT,
+                    created_at TIMESTAMP(3) NOT NULL,
+                    KEY idx_baidu_clue_api_logs_api_type_created_at (api_type, created_at),
+                    KEY idx_baidu_clue_api_logs_clue_id_api_type (clue_id, api_type),
+                    KEY idx_baidu_clue_api_logs_response_code_created_at (response_code, created_at)
+                )
+                """,
+                """
+                CREATE TABLE IF NOT EXISTS baidu_clue_disclosure_details (
+                    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                    query_begin_time BIGINT NOT NULL,
+                    query_end_time BIGINT NOT NULL,
+                    clue_time VARCHAR(32),
+                    clue_id VARCHAR(64) NOT NULL,
+                    status INT NOT NULL,
+                    raw_message TEXT,
+                    created_at TIMESTAMP(3) NOT NULL,
+                    KEY idx_baidu_clue_disclosure_details_clue_id_created_at (clue_id, created_at),
+                    KEY idx_baidu_clue_disclosure_details_status_created_at (status, created_at),
+                    KEY idx_baidu_clue_disclosure_details_query_window (query_begin_time, query_end_time)
+                )
+                """
+        };
+
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            Statement stmt = session.getConnection().createStatement();
+            for (String ddl : ddls) {
+                stmt.execute(ddl);
+            }
+            stmt.close();
+        } catch (Exception e) {
+            throw new RuntimeException("baidu clue migrate failed", e);
+        }
+    }
+
+    public void saveLead(BaiduClueLeadRecord record) {
+        Instant now = Instant.now();
+        if (record.getCreatedAt() == null) record.setCreatedAt(now);
+        if (record.getUpdatedAt() == null) record.setUpdatedAt(now);
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            BaiduClueLeadMapper mapper = session.getMapper(BaiduClueLeadMapper.class);
+            mapper.insertOrUpdate(
+                    record.getClueId(), record.getAppsid(), record.getTuId(), record.getPostTime(),
+                    record.getQueryWords(), record.getTradeLevel1(), record.getTradeLevel2(), record.getTradeType(),
+                    record.getTradeInfo(), record.getFormDetail(), record.getConsultStr(), record.getMediaInfo(),
+                    record.getPid(), record.getCid(), record.getInfoLinker(), record.getInfoPhone(), record.getIp(), record.getUserAgent(),
+                    record.getPrefetchBizCode(), record.getPrefetchCode(), record.getPrefetchMsg(),
+                    record.getSubmitCode(), record.getSubmitMsg(), record.getDisclosureStatus(), record.getDisclosureTime(),
+                    toTs(record.getLastSubmitAt()), toTs(record.getLastPrefetchAt()), toTs(record.getLastDisclosureAt()),
+                    toTs(record.getCreatedAt()), toTs(record.getUpdatedAt())
+            );
+        }
+    }
+
+    public void saveApiLog(BaiduClueApiLogRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            BaiduClueApiLogMapper mapper = session.getMapper(BaiduClueApiLogMapper.class);
+            mapper.insert(
+                    record.getApiType(), record.getClueId(), record.getAppsid(), record.getRequestUrl(),
+                    record.getRequestTimestamp(), record.getAccessType(), record.getEncryptedData(), record.getSignature(),
+                    record.getRequestBody(), record.getHttpStatus(), record.getResponseBody(), record.getResponseCode(),
+                    record.getResponseMsg(), record.getBizCode(), record.isSuccess() ? 1 : 0,
+                    record.getErrorMessage(), toTs(record.getCreatedAt())
+            );
+        }
+    }
+
+    public void saveDisclosureDetail(BaiduClueDisclosureDetailRecord record) {
+        if (record.getCreatedAt() == null) record.setCreatedAt(Instant.now());
+        try (SqlSession session = sqlSessionFactory.openSession(true)) {
+            BaiduClueDisclosureDetailMapper mapper = session.getMapper(BaiduClueDisclosureDetailMapper.class);
+            mapper.insert(
+                    record.getQueryBeginTime(), record.getQueryEndTime(), record.getClueTime(),
+                    record.getClueId(), record.getStatus(), record.getRawMessage(), toTs(record.getCreatedAt())
+            );
+        }
+    }
+
+    private static Timestamp toTs(Instant instant) {
+        return instant == null ? null : Timestamp.from(instant);
+    }
+}

+ 27 - 0
src/main/java/com/adx/tencent/baiduclue/storage/mapper/BaiduClueApiLogMapper.java

@@ -0,0 +1,27 @@
+package com.adx.tencent.baiduclue.storage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.sql.Timestamp;
+
+@Mapper
+public interface BaiduClueApiLogMapper {
+    void insert(@Param("apiType") String apiType,
+                @Param("clueId") String clueId,
+                @Param("appsid") String appsid,
+                @Param("requestUrl") String requestUrl,
+                @Param("requestTimestamp") String requestTimestamp,
+                @Param("accessType") Integer accessType,
+                @Param("encryptedData") String encryptedData,
+                @Param("signature") String signature,
+                @Param("requestBody") String requestBody,
+                @Param("httpStatus") Integer httpStatus,
+                @Param("responseBody") String responseBody,
+                @Param("responseCode") Integer responseCode,
+                @Param("responseMsg") String responseMsg,
+                @Param("bizCode") Integer bizCode,
+                @Param("success") int success,
+                @Param("errorMessage") String errorMessage,
+                @Param("createdAt") Timestamp createdAt);
+}

+ 17 - 0
src/main/java/com/adx/tencent/baiduclue/storage/mapper/BaiduClueDisclosureDetailMapper.java

@@ -0,0 +1,17 @@
+package com.adx.tencent.baiduclue.storage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.sql.Timestamp;
+
+@Mapper
+public interface BaiduClueDisclosureDetailMapper {
+    void insert(@Param("queryBeginTime") Long queryBeginTime,
+                @Param("queryEndTime") Long queryEndTime,
+                @Param("clueTime") String clueTime,
+                @Param("clueId") String clueId,
+                @Param("status") Integer status,
+                @Param("rawMessage") String rawMessage,
+                @Param("createdAt") Timestamp createdAt);
+}

+ 40 - 0
src/main/java/com/adx/tencent/baiduclue/storage/mapper/BaiduClueLeadMapper.java

@@ -0,0 +1,40 @@
+package com.adx.tencent.baiduclue.storage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.sql.Timestamp;
+
+@Mapper
+public interface BaiduClueLeadMapper {
+    void insertOrUpdate(@Param("clueId") String clueId,
+                        @Param("appsid") String appsid,
+                        @Param("tuId") String tuId,
+                        @Param("postTime") long postTime,
+                        @Param("queryWords") String queryWords,
+                        @Param("tradeLevel1") Integer tradeLevel1,
+                        @Param("tradeLevel2") Integer tradeLevel2,
+                        @Param("tradeType") Integer tradeType,
+                        @Param("tradeInfo") String tradeInfo,
+                        @Param("formDetail") String formDetail,
+                        @Param("consultStr") String consultStr,
+                        @Param("mediaInfo") String mediaInfo,
+                        @Param("pid") Integer pid,
+                        @Param("cid") Integer cid,
+                        @Param("infoLinker") String infoLinker,
+                        @Param("infoPhone") String infoPhone,
+                        @Param("ip") String ip,
+                        @Param("userAgent") String userAgent,
+                        @Param("prefetchBizCode") Integer prefetchBizCode,
+                        @Param("prefetchCode") Integer prefetchCode,
+                        @Param("prefetchMsg") String prefetchMsg,
+                        @Param("submitCode") Integer submitCode,
+                        @Param("submitMsg") String submitMsg,
+                        @Param("disclosureStatus") Integer disclosureStatus,
+                        @Param("disclosureTime") String disclosureTime,
+                        @Param("lastSubmitAt") Timestamp lastSubmitAt,
+                        @Param("lastPrefetchAt") Timestamp lastPrefetchAt,
+                        @Param("lastDisclosureAt") Timestamp lastDisclosureAt,
+                        @Param("createdAt") Timestamp createdAt,
+                        @Param("updatedAt") Timestamp updatedAt);
+}

+ 59 - 0
src/main/java/com/adx/tencent/baiduclue/storage/model/BaiduClueApiLogRecord.java

@@ -0,0 +1,59 @@
+package com.adx.tencent.baiduclue.storage.model;
+
+import java.time.Instant;
+
+public class BaiduClueApiLogRecord {
+
+    private String apiType;
+    private String clueId;
+    private String appsid;
+    private String requestUrl;
+    private String requestTimestamp;
+    private Integer accessType;
+    private String encryptedData;
+    private String signature;
+    private String requestBody;
+    private Integer httpStatus;
+    private String responseBody;
+    private Integer responseCode;
+    private String responseMsg;
+    private Integer bizCode;
+    private boolean success;
+    private String errorMessage;
+    private Instant createdAt;
+
+    public String getApiType() { return apiType; }
+    public void setApiType(String apiType) { this.apiType = apiType; }
+    public String getClueId() { return clueId; }
+    public void setClueId(String clueId) { this.clueId = clueId; }
+    public String getAppsid() { return appsid; }
+    public void setAppsid(String appsid) { this.appsid = appsid; }
+    public String getRequestUrl() { return requestUrl; }
+    public void setRequestUrl(String requestUrl) { this.requestUrl = requestUrl; }
+    public String getRequestTimestamp() { return requestTimestamp; }
+    public void setRequestTimestamp(String requestTimestamp) { this.requestTimestamp = requestTimestamp; }
+    public Integer getAccessType() { return accessType; }
+    public void setAccessType(Integer accessType) { this.accessType = accessType; }
+    public String getEncryptedData() { return encryptedData; }
+    public void setEncryptedData(String encryptedData) { this.encryptedData = encryptedData; }
+    public String getSignature() { return signature; }
+    public void setSignature(String signature) { this.signature = signature; }
+    public String getRequestBody() { return requestBody; }
+    public void setRequestBody(String requestBody) { this.requestBody = requestBody; }
+    public Integer getHttpStatus() { return httpStatus; }
+    public void setHttpStatus(Integer httpStatus) { this.httpStatus = httpStatus; }
+    public String getResponseBody() { return responseBody; }
+    public void setResponseBody(String responseBody) { this.responseBody = responseBody; }
+    public Integer getResponseCode() { return responseCode; }
+    public void setResponseCode(Integer responseCode) { this.responseCode = responseCode; }
+    public String getResponseMsg() { return responseMsg; }
+    public void setResponseMsg(String responseMsg) { this.responseMsg = responseMsg; }
+    public Integer getBizCode() { return bizCode; }
+    public void setBizCode(Integer bizCode) { this.bizCode = bizCode; }
+    public boolean isSuccess() { return success; }
+    public void setSuccess(boolean success) { this.success = success; }
+    public String getErrorMessage() { return errorMessage; }
+    public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
+    public Instant getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
+}

+ 29 - 0
src/main/java/com/adx/tencent/baiduclue/storage/model/BaiduClueDisclosureDetailRecord.java

@@ -0,0 +1,29 @@
+package com.adx.tencent.baiduclue.storage.model;
+
+import java.time.Instant;
+
+public class BaiduClueDisclosureDetailRecord {
+
+    private Long queryBeginTime;
+    private Long queryEndTime;
+    private String clueTime;
+    private String clueId;
+    private Integer status;
+    private String rawMessage;
+    private Instant createdAt;
+
+    public Long getQueryBeginTime() { return queryBeginTime; }
+    public void setQueryBeginTime(Long queryBeginTime) { this.queryBeginTime = queryBeginTime; }
+    public Long getQueryEndTime() { return queryEndTime; }
+    public void setQueryEndTime(Long queryEndTime) { this.queryEndTime = queryEndTime; }
+    public String getClueTime() { return clueTime; }
+    public void setClueTime(String clueTime) { this.clueTime = clueTime; }
+    public String getClueId() { return clueId; }
+    public void setClueId(String clueId) { this.clueId = clueId; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+    public String getRawMessage() { return rawMessage; }
+    public void setRawMessage(String rawMessage) { this.rawMessage = rawMessage; }
+    public Instant getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
+}

+ 98 - 0
src/main/java/com/adx/tencent/baiduclue/storage/model/BaiduClueLeadRecord.java

@@ -0,0 +1,98 @@
+package com.adx.tencent.baiduclue.storage.model;
+
+import java.time.Instant;
+
+public class BaiduClueLeadRecord {
+
+    private String clueId;
+    private String appsid;
+    private String tuId;
+    private long postTime;
+    private String queryWords;
+    private Integer tradeLevel1;
+    private Integer tradeLevel2;
+    private Integer tradeType;
+    private String tradeInfo;
+    private String formDetail;
+    private String consultStr;
+    private String mediaInfo;
+    private Integer pid;
+    private Integer cid;
+    private String infoLinker;
+    private String infoPhone;
+    private String ip;
+    private String userAgent;
+    private Integer prefetchBizCode;
+    private Integer prefetchCode;
+    private String prefetchMsg;
+    private Integer submitCode;
+    private String submitMsg;
+    private Integer disclosureStatus;
+    private String disclosureTime;
+    private Instant lastSubmitAt;
+    private Instant lastPrefetchAt;
+    private Instant lastDisclosureAt;
+    private Instant createdAt;
+    private Instant updatedAt;
+
+    public String getClueId() { return clueId; }
+    public void setClueId(String clueId) { this.clueId = clueId; }
+    public String getAppsid() { return appsid; }
+    public void setAppsid(String appsid) { this.appsid = appsid; }
+    public String getTuId() { return tuId; }
+    public void setTuId(String tuId) { this.tuId = tuId; }
+    public long getPostTime() { return postTime; }
+    public void setPostTime(long postTime) { this.postTime = postTime; }
+    public String getQueryWords() { return queryWords; }
+    public void setQueryWords(String queryWords) { this.queryWords = queryWords; }
+    public Integer getTradeLevel1() { return tradeLevel1; }
+    public void setTradeLevel1(Integer tradeLevel1) { this.tradeLevel1 = tradeLevel1; }
+    public Integer getTradeLevel2() { return tradeLevel2; }
+    public void setTradeLevel2(Integer tradeLevel2) { this.tradeLevel2 = tradeLevel2; }
+    public Integer getTradeType() { return tradeType; }
+    public void setTradeType(Integer tradeType) { this.tradeType = tradeType; }
+    public String getTradeInfo() { return tradeInfo; }
+    public void setTradeInfo(String tradeInfo) { this.tradeInfo = tradeInfo; }
+    public String getFormDetail() { return formDetail; }
+    public void setFormDetail(String formDetail) { this.formDetail = formDetail; }
+    public String getConsultStr() { return consultStr; }
+    public void setConsultStr(String consultStr) { this.consultStr = consultStr; }
+    public String getMediaInfo() { return mediaInfo; }
+    public void setMediaInfo(String mediaInfo) { this.mediaInfo = mediaInfo; }
+    public Integer getPid() { return pid; }
+    public void setPid(Integer pid) { this.pid = pid; }
+    public Integer getCid() { return cid; }
+    public void setCid(Integer cid) { this.cid = cid; }
+    public String getInfoLinker() { return infoLinker; }
+    public void setInfoLinker(String infoLinker) { this.infoLinker = infoLinker; }
+    public String getInfoPhone() { return infoPhone; }
+    public void setInfoPhone(String infoPhone) { this.infoPhone = infoPhone; }
+    public String getIp() { return ip; }
+    public void setIp(String ip) { this.ip = ip; }
+    public String getUserAgent() { return userAgent; }
+    public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
+    public Integer getPrefetchBizCode() { return prefetchBizCode; }
+    public void setPrefetchBizCode(Integer prefetchBizCode) { this.prefetchBizCode = prefetchBizCode; }
+    public Integer getPrefetchCode() { return prefetchCode; }
+    public void setPrefetchCode(Integer prefetchCode) { this.prefetchCode = prefetchCode; }
+    public String getPrefetchMsg() { return prefetchMsg; }
+    public void setPrefetchMsg(String prefetchMsg) { this.prefetchMsg = prefetchMsg; }
+    public Integer getSubmitCode() { return submitCode; }
+    public void setSubmitCode(Integer submitCode) { this.submitCode = submitCode; }
+    public String getSubmitMsg() { return submitMsg; }
+    public void setSubmitMsg(String submitMsg) { this.submitMsg = submitMsg; }
+    public Integer getDisclosureStatus() { return disclosureStatus; }
+    public void setDisclosureStatus(Integer disclosureStatus) { this.disclosureStatus = disclosureStatus; }
+    public String getDisclosureTime() { return disclosureTime; }
+    public void setDisclosureTime(String disclosureTime) { this.disclosureTime = disclosureTime; }
+    public Instant getLastSubmitAt() { return lastSubmitAt; }
+    public void setLastSubmitAt(Instant lastSubmitAt) { this.lastSubmitAt = lastSubmitAt; }
+    public Instant getLastPrefetchAt() { return lastPrefetchAt; }
+    public void setLastPrefetchAt(Instant lastPrefetchAt) { this.lastPrefetchAt = lastPrefetchAt; }
+    public Instant getLastDisclosureAt() { return lastDisclosureAt; }
+    public void setLastDisclosureAt(Instant lastDisclosureAt) { this.lastDisclosureAt = lastDisclosureAt; }
+    public Instant getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
+    public Instant getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
+}

+ 21 - 0
src/main/java/com/adx/tencent/baiduclue/util/Md5Signer.java

@@ -0,0 +1,21 @@
+package com.adx.tencent.baiduclue.util;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+
+public final class Md5Signer {
+
+    private Md5Signer() {
+    }
+
+    public static String sign(String timestamp, String token, String data) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest((timestamp + token + data).getBytes(StandardCharsets.UTF_8));
+            return HexFormat.of().formatHex(digest);
+        } catch (Exception e) {
+            throw new IllegalStateException("failed to build md5 signature", e);
+        }
+    }
+}

+ 53 - 0
src/main/java/com/adx/tencent/baiduclue/util/RsaEncryptor.java

@@ -0,0 +1,53 @@
+package com.adx.tencent.baiduclue.util;
+
+import javax.crypto.Cipher;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+public final class RsaEncryptor {
+
+    private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
+    private static final int MAX_BLOCK_SIZE = 117;
+
+    private RsaEncryptor() {
+    }
+
+    public static String encrypt(String plainText, String publicKeyText) {
+        try {
+            PublicKey publicKey = parsePublicKey(publicKeyText);
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.ENCRYPT_MODE, publicKey);
+
+            byte[] input = plainText.getBytes(StandardCharsets.UTF_8);
+            byte[] output = new byte[0];
+            for (int offset = 0; offset < input.length; offset += MAX_BLOCK_SIZE) {
+                int length = Math.min(MAX_BLOCK_SIZE, input.length - offset);
+                byte[] block = cipher.doFinal(input, offset, length);
+                output = concat(output, block);
+            }
+            return Base64.getEncoder().encodeToString(output);
+        } catch (Exception e) {
+            throw new IllegalStateException("failed to rsa encrypt request data", e);
+        }
+    }
+
+    private static PublicKey parsePublicKey(String publicKeyText) throws Exception {
+        String normalized = publicKeyText
+                .replace("-----BEGIN PUBLIC KEY-----", "")
+                .replace("-----END PUBLIC KEY-----", "")
+                .replaceAll("\\s+", "");
+        byte[] keyBytes = Base64.getDecoder().decode(normalized);
+        X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
+        return KeyFactory.getInstance("RSA").generatePublic(spec);
+    }
+
+    private static byte[] concat(byte[] a, byte[] b) {
+        byte[] result = new byte[a.length + b.length];
+        System.arraycopy(a, 0, result, 0, a.length);
+        System.arraycopy(b, 0, result, a.length, b.length);
+        return result;
+    }
+}

+ 12 - 0
src/main/resources/application-dev.yml

@@ -6,6 +6,18 @@ logging:
   file:
     path: ./logs
 
+baidu:
+  clue:
+    base-url: "https://cpu-bside.baidu.com"
+    appsid: ""
+    token: ""
+    public-key-path: "src/main/resources/keys/baidu-clue-rsa-public-key.pem"
+    media-type: 4
+    media-url: ""
+    media-website: ""
+    connect-timeout-seconds: 10
+    read-timeout-seconds: 30
+
 adx:
   # --- Baidu ADX ---
   baidu-adx-endpoint: "http://adsp.baidu.com/jbssp"

+ 12 - 0
src/main/resources/application-prod.yml

@@ -6,6 +6,18 @@ logging:
   file:
     path: /data/webapp/tencent-baidu-tracking/log
 
+baidu:
+  clue:
+    base-url: "https://cpu-bside.baidu.com"
+    appsid: ""
+    token: ""
+    public-key-path: "src/main/resources/keys/baidu-clue-rsa-public-key.pem"
+    media-type: 4
+    media-url: ""
+    media-website: ""
+    connect-timeout-seconds: 10
+    read-timeout-seconds: 30
+
 adx:
   # --- Baidu ADX ---
   baidu-adx-endpoint: "http://adsp.baidu.com/jbssp"

+ 12 - 0
src/main/resources/application-test.yml

@@ -6,6 +6,18 @@ logging:
   file:
     path: /var/log/adx-tencent
 
+baidu:
+  clue:
+    base-url: "https://cpu-bside.baidu.com"
+    appsid: ""
+    token: ""
+    public-key-path: "src/main/resources/keys/baidu-clue-rsa-public-key.pem"
+    media-type: 4
+    media-url: ""
+    media-website: ""
+    connect-timeout-seconds: 10
+    read-timeout-seconds: 30
+
 adx:
   # --- Baidu ADX ---
   baidu-adx-endpoint: "http://adsp.baidu.com/jbssp"

+ 13 - 0
src/main/resources/application.yml

@@ -25,3 +25,16 @@ adx:
   baidu-android-tag-ids: jlt52,jlt53,jlt54,jlt55,jlt56,jlt57,jlt58,jlt59,jlt60,jlt61,jlt62,jlt63,jlt64,jlt65,jlt66,jlt67,jlt68,jlt69,jlt70,jlt71,jlt72,jlt73,jlt74,jlt75,jlt76,jlt77,jlt78,jlt79,jlt80,jlt81,jlt82,jlt83,jlt84,jlt85,jlt86,jlt87,jlt88,jlt89,jlt90,jlt91,jlt92,jlt93,jlt94,jlt95,jlt96,jlt97,jlt98,jlt99,jlt100,jlt101
   baidu-ios-app-id: efff0562
   baidu-ios-tag-ids: jlt01,jlt03,jlt04,jlt05,jlt06,jlt07,jlt08,jlt09,jlt10,jlt11,jlt12,jlt13,jlt14,jlt15,jlt16,jlt17,jlt18,jlt19,jlt20,jlt21,jlt22,jlt23,jlt24,jlt25,jlt26,jlt27,jlt28,jlt29,jlt30,jlt31,jlt32,jlt33,jlt34,jlt35,jlt36,jlt37,jlt38,jlt39,jlt40,jlt41,jlt42,jlt43,jlt44,jlt45,jlt46,jlt47,jlt48,jlt49,jlt50,jlt51
+
+baidu:
+  clue:
+    base-url: https://cpu-bside.baidu.com
+    appsid:
+    token:
+    public-key:
+    public-key-path: src/main/resources/keys/baidu-clue-rsa-public-key.pem
+    media-type: 4
+    media-url:
+    media-website:
+    connect-timeout-seconds: 10
+    read-timeout-seconds: 30

+ 16 - 0
src/main/resources/mapper/BaiduClueApiLogMapper.xml

@@ -0,0 +1,16 @@
+<?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.adx.tencent.baiduclue.storage.mapper.BaiduClueApiLogMapper">
+    <insert id="insert">
+        INSERT INTO baidu_clue_api_logs (
+            api_type, clue_id, appsid, request_url, request_timestamp, access_type,
+            encrypted_data, signature, request_body, http_status, response_body,
+            response_code, response_msg, biz_code, success, error_message, created_at
+        ) VALUES (
+            #{apiType}, #{clueId}, #{appsid}, #{requestUrl}, #{requestTimestamp}, #{accessType},
+            #{encryptedData}, #{signature}, #{requestBody}, #{httpStatus}, #{responseBody},
+            #{responseCode}, #{responseMsg}, #{bizCode}, #{success}, #{errorMessage}, #{createdAt}
+        )
+    </insert>
+</mapper>

+ 12 - 0
src/main/resources/mapper/BaiduClueDisclosureDetailMapper.xml

@@ -0,0 +1,12 @@
+<?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.adx.tencent.baiduclue.storage.mapper.BaiduClueDisclosureDetailMapper">
+    <insert id="insert">
+        INSERT INTO baidu_clue_disclosure_details (
+            query_begin_time, query_end_time, clue_time, clue_id, status, raw_message, created_at
+        ) VALUES (
+            #{queryBeginTime}, #{queryEndTime}, #{clueTime}, #{clueId}, #{status}, #{rawMessage}, #{createdAt}
+        )
+    </insert>
+</mapper>

+ 47 - 0
src/main/resources/mapper/BaiduClueLeadMapper.xml

@@ -0,0 +1,47 @@
+<?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.adx.tencent.baiduclue.storage.mapper.BaiduClueLeadMapper">
+    <insert id="insertOrUpdate">
+        INSERT INTO baidu_clue_leads (
+            clue_id, appsid, tu_id, post_time, query_words, trade_level1, trade_level2, trade_type,
+            trade_info, form_detail, consult_str, media_info, pid, cid, info_linker, info_phone, ip, user_agent,
+            prefetch_biz_code, prefetch_code, prefetch_msg, submit_code, submit_msg, disclosure_status, disclosure_time,
+            last_submit_at, last_prefetch_at, last_disclosure_at, created_at, updated_at
+        ) VALUES (
+            #{clueId}, #{appsid}, #{tuId}, #{postTime}, #{queryWords}, #{tradeLevel1}, #{tradeLevel2}, #{tradeType},
+            #{tradeInfo}, #{formDetail}, #{consultStr}, #{mediaInfo}, #{pid}, #{cid}, #{infoLinker}, #{infoPhone}, #{ip}, #{userAgent},
+            #{prefetchBizCode}, #{prefetchCode}, #{prefetchMsg}, #{submitCode}, #{submitMsg}, #{disclosureStatus}, #{disclosureTime},
+            #{lastSubmitAt}, #{lastPrefetchAt}, #{lastDisclosureAt}, #{createdAt}, #{updatedAt}
+        )
+        ON DUPLICATE KEY UPDATE
+            appsid = VALUES(appsid),
+            tu_id = COALESCE(VALUES(tu_id), tu_id),
+            post_time = VALUES(post_time),
+            query_words = VALUES(query_words),
+            trade_level1 = VALUES(trade_level1),
+            trade_level2 = VALUES(trade_level2),
+            trade_type = VALUES(trade_type),
+            trade_info = VALUES(trade_info),
+            form_detail = VALUES(form_detail),
+            consult_str = VALUES(consult_str),
+            media_info = VALUES(media_info),
+            pid = VALUES(pid),
+            cid = VALUES(cid),
+            info_linker = VALUES(info_linker),
+            info_phone = VALUES(info_phone),
+            ip = VALUES(ip),
+            user_agent = VALUES(user_agent),
+            prefetch_biz_code = COALESCE(VALUES(prefetch_biz_code), prefetch_biz_code),
+            prefetch_code = COALESCE(VALUES(prefetch_code), prefetch_code),
+            prefetch_msg = COALESCE(VALUES(prefetch_msg), prefetch_msg),
+            submit_code = COALESCE(VALUES(submit_code), submit_code),
+            submit_msg = COALESCE(VALUES(submit_msg), submit_msg),
+            disclosure_status = COALESCE(VALUES(disclosure_status), disclosure_status),
+            disclosure_time = COALESCE(VALUES(disclosure_time), disclosure_time),
+            last_submit_at = COALESCE(VALUES(last_submit_at), last_submit_at),
+            last_prefetch_at = COALESCE(VALUES(last_prefetch_at), last_prefetch_at),
+            last_disclosure_at = COALESCE(VALUES(last_disclosure_at), last_disclosure_at),
+            updated_at = VALUES(updated_at)
+    </insert>
+</mapper>