Bladeren bron

feat(customer): add tianyancha company enrichment

malk 3 maanden geleden
bovenliggende
commit
38b846cfdb
23 gewijzigde bestanden met toevoegingen van 1526 en 0 verwijderingen
  1. 7 0
      mjava-akdsbeisen/src/main/java/com/malk/BeisenApplication.java
  2. 62 0
      mjava-akdsbeisen/src/main/java/com/malk/controller/CustomerEnrichController.java
  3. 39 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerConf.java
  4. 18 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerDetailRequest.java
  5. 15 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerEnrichMeta.java
  6. 17 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerEnrichRequest.java
  7. 24 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerEnrichResult.java
  8. 18 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerSearchItem.java
  9. 56 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerSearchRequest.java
  10. 19 0
      mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerSearchResult.java
  11. 31 0
      mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycCompany.java
  12. 29 0
      mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycConf.java
  13. 16 0
      mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycSearchItem.java
  14. 17 0
      mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycSearchResponse.java
  15. 38 0
      mjava-akdsbeisen/src/main/java/com/malk/service/customer/CustomerEnrichService.java
  16. 326 0
      mjava-akdsbeisen/src/main/java/com/malk/service/customer/impl/CustomerEnrichServiceImpl.java
  17. 49 0
      mjava-akdsbeisen/src/main/java/com/malk/service/tianyancha/TycClient.java
  18. 242 0
      mjava-akdsbeisen/src/main/java/com/malk/service/tianyancha/impl/TycClientImpl.java
  19. 36 0
      mjava-akdsbeisen/src/main/resources/application-dev.yml
  20. 35 0
      mjava-akdsbeisen/src/main/resources/application-prod.yml
  21. 188 0
      mjava-akdsbeisen/src/test/java/com/malk/service/customer/impl/CustomerEnrichServiceImplTest.java
  22. 212 0
      mjava-akdsbeisen/src/test/java/com/malk/service/tianyancha/impl/TycClientImplTest.java
  23. 32 0
      mjava/src/main/java/com/malk/utils/UtilHttp.java

+ 7 - 0
mjava-akdsbeisen/src/main/java/com/malk/BeisenApplication.java

@@ -5,7 +5,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import com.malk.filter.CatchException;
 import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Import;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
 @SpringBootApplication(exclude = {
@@ -15,17 +17,22 @@ import org.springframework.scheduling.annotation.EnableScheduling;
 })
 @EnableScheduling
 @EnableConfigurationProperties
+@Import(CatchException.class)
 @ComponentScan(basePackages = {
     "com.malk.service.beisen",
     "com.malk.service.aliwork",
     "com.malk.service.dingtalk",
     "com.malk.service.workhours",
     "com.malk.service.personnel",
+    "com.malk.service.tianyancha",
+    "com.malk.service.customer",
     "com.malk.server.beisen",
     "com.malk.server.aliwork",
     "com.malk.server.dingtalk",
     "com.malk.server.workhours",
     "com.malk.server.personnel",
+    "com.malk.server.tianyancha",
+    "com.malk.server.customer",
     "com.malk.server.common",
     "com.malk.controller",
     "com.malk.delegate",

+ 62 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/CustomerEnrichController.java

@@ -0,0 +1,62 @@
+package com.malk.controller;
+
+import com.malk.server.common.McR;
+import com.malk.server.customer.CustomerDetailRequest;
+import com.malk.server.customer.CustomerEnrichRequest;
+import com.malk.server.customer.CustomerEnrichResult;
+import com.malk.server.customer.CustomerSearchRequest;
+import com.malk.server.customer.CustomerSearchResult;
+import com.malk.service.customer.CustomerEnrichService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+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.RestController;
+
+/**
+ * 客户档案工商信息查询代理。
+ */
+@Validated
+@RestController
+@RequestMapping("/customer")
+public class CustomerEnrichController {
+
+    @Autowired
+    private CustomerEnrichService enrichService;
+
+    /**
+     * 搜索企业候选,仅调用天眼查 816,不自动选择候选。
+     *
+     * @param request 搜索请求
+     * @return 标准候选搜索响应
+     */
+    @PostMapping("/search")
+    public McR<CustomerSearchResult> search(@Validated @RequestBody CustomerSearchRequest request) {
+        CustomerSearchResult result = enrichService.search(
+                request.resolveKeyword(), request.getPageNum(), request.getPageSize());
+        return McR.success(result);
+    }
+
+    /**
+     * 按企业 ID 查询 1001 工商详情并生成回填计划。
+     *
+     * @param request 详情请求
+     * @return 标准工商详情响应
+     */
+    @PostMapping("/detail")
+    public McR<CustomerEnrichResult> detail(@Validated @RequestBody CustomerDetailRequest request) {
+        return McR.success(enrichService.detail(request.getCompanyId(), request.getExisting()));
+    }
+
+    /**
+     * 兼容旧工商回填入口。
+     *
+     * @param request 回填请求
+     * @return 标准工商详情响应
+     */
+    @PostMapping("/enrich")
+    public McR<CustomerEnrichResult> enrich(@RequestBody CustomerEnrichRequest request) {
+        return McR.success(enrichService.enrich(request));
+    }
+}

+ 39 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerConf.java

@@ -0,0 +1,39 @@
+package com.malk.server.customer;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Map;
+
+/**
+ * 客户档案表 - 字段映射配置
+ * 只承载"取回工商信息后要回填哪些 fieldId"; 不承载业务凭据
+ * (宜搭写入交给调用方: 云璞连接器 / openyida 脚本)
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "customer")
+public class CustomerConf {
+
+    /**
+     * 宜搭应用 appType (仅用于打印/日志; 后端不写宜搭)
+     */
+    private String yidaAppType;
+
+    /**
+     * 宜搭表单 formUuid (同上)
+     */
+    private String yidaFormUuid;
+
+    /**
+     * 天眼查企业主页 URL 模板 (拼装 "工商信息网址" 字段)
+     * 例: https://www.tianyancha.com/company/{id}
+     */
+    private String tycCompanyUrl;
+
+    /**
+     * 22 字段的 fieldId 映射, key 为业务名 (customerName / creditCode / ...)
+     */
+    private Map<String, String> fields;
+}

+ 18 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerDetailRequest.java

@@ -0,0 +1,18 @@
+package com.malk.server.customer;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.util.Map;
+
+/**
+ * 客户工商详情请求。
+ */
+@Data
+public class CustomerDetailRequest {
+
+    @NotNull(message = "companyId 不能为空")
+    private Long companyId;
+
+    private Map<String, Object> existing;
+}

+ 15 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerEnrichMeta.java

@@ -0,0 +1,15 @@
+package com.malk.server.customer;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 客户工商回填元信息。
+ */
+@Data
+@Builder
+public class CustomerEnrichMeta {
+
+    private String source;
+    private Long latencyMs;
+}

+ 17 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerEnrichRequest.java

@@ -0,0 +1,17 @@
+package com.malk.server.customer;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 兼容客户工商回填请求。
+ */
+@Data
+public class CustomerEnrichRequest {
+
+    private Long companyId;
+    private String creditCode;
+    private String customerName;
+    private Map<String, Object> existing;
+}

+ 24 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerEnrichResult.java

@@ -0,0 +1,24 @@
+package com.malk.server.customer;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户工商详情及回填结果。
+ */
+@Data
+@Builder
+public class CustomerEnrichResult {
+
+    private boolean hit;
+    private Long companyId;
+    private String companyName;
+    private String creditCode;
+    private Map<String, Object> filled;
+    private List<String> skipped;
+    private List<String> unavailable;
+    private CustomerEnrichMeta meta;
+}

+ 18 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerSearchItem.java

@@ -0,0 +1,18 @@
+package com.malk.server.customer;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 客户工商候选项。
+ */
+@Data
+@Builder
+public class CustomerSearchItem {
+
+    private Long companyId;
+    private String companyName;
+    private String creditCode;
+    private String legalPersonName;
+    private String regStatus;
+}

+ 56 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerSearchRequest.java

@@ -0,0 +1,56 @@
+package com.malk.server.customer;
+
+import lombok.Data;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+
+/**
+ * 客户工商候选搜索请求。
+ */
+@Data
+public class CustomerSearchRequest {
+
+    /**
+     * 新版连接器搜索参数。
+     */
+    private String keyword;
+
+    /**
+     * 兼容已存在的调用方,后续新调用统一传 keyword。
+     */
+    private String customerName;
+
+    @Min(value = 1, message = "pageNum 不能小于 1")
+    private Integer pageNum = 1;
+
+    @Min(value = 1, message = "pageSize 不能小于 1")
+    @Max(value = 100, message = "pageSize 不能大于 100")
+    private Integer pageSize = 20;
+
+    /**
+     * 校验新旧搜索参数至少存在一个非空值。
+     *
+     * @return 是否包含有效搜索关键词
+     */
+    @AssertTrue(message = "keyword 或 customerName 不能为空")
+    public boolean isQueryPresent() {
+        return resolveKeyword() != null;
+    }
+
+    /**
+     * 优先读取 keyword,并兼容旧 customerName 参数。
+     *
+     * @return 去除首尾空格后的搜索关键词,无有效值时返回 null
+     */
+    public String resolveKeyword() {
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            return keyword.trim();
+        }
+        if (customerName != null && !customerName.trim().isEmpty()) {
+            return customerName.trim();
+        }
+        return null;
+    }
+}

+ 19 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerSearchResult.java

@@ -0,0 +1,19 @@
+package com.malk.server.customer;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 客户工商候选搜索结果。
+ */
+@Data
+@Builder
+public class CustomerSearchResult {
+
+    private Integer pageNum;
+    private Integer pageSize;
+    private Long total;
+    private List<CustomerSearchItem> candidates;
+}

+ 31 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycCompany.java

@@ -0,0 +1,31 @@
+package com.malk.server.tianyancha;
+
+import lombok.Data;
+
+/**
+ * 天眼查企业工商信息 - 标准化 DTO
+ * 屏蔽底层接口 (1001 baseinfoV2) 的字段命名细节
+ */
+@Data
+public class TycCompany {
+
+    private Long id;                    // 天眼查内部 companyId
+    private String name;                // 企业名称
+    private String creditCode;          // 统一社会信用代码
+    private String legalPersonName;     // 法定代表人
+    private String companyOrgType;      // 企业类型
+    private String industry;            // 所属行业
+    private String regCapital;          // 注册资本
+    private String regStatus;           // 经营状态
+    private Long estiblishTime;         // 成立时间 (ms)
+    private Long fromTime;              // 经营期限起 (ms)
+    private Long toTime;                // 经营期限止 (ms, 0 表示长期)
+    private Long approvedTime;          // 核准日期 (ms)
+    private String regInstitute;        // 登记机关
+    private String regLocation;         // 注册地址
+    private String base;                // 省份码 (bj/sh/...)
+    private String phoneNumber;         // 联系电话
+    private String websiteList;         // 官网, 多个逗号分隔
+    private String businessScope;       // 经营范围
+    private String staffNumRange;       // 人员规模, 可能为空
+}

+ 29 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycConf.java

@@ -0,0 +1,29 @@
+package com.malk.server.tianyancha;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 天眼查开放平台配置
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "tianyancha")
+public class TycConf {
+
+    /**
+     * 天眼查 openapi baseUrl, 例如 https://open.api.tianyancha.com
+     */
+    private String baseUrl;
+
+    /**
+     * Bearer Token(个人中心 - 密钥管理); 走环境变量或 yml 传入
+     */
+    private String token;
+
+    /**
+     * 单次请求超时(ms)
+     */
+    private int timeoutMs = 8000;
+}

+ 16 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycSearchItem.java

@@ -0,0 +1,16 @@
+package com.malk.server.tianyancha;
+
+import lombok.Data;
+
+/**
+ * 天眼查 816 企业搜索候选项。
+ */
+@Data
+public class TycSearchItem {
+
+    private Long id;
+    private String name;
+    private String creditCode;
+    private String legalPersonName;
+    private String regStatus;
+}

+ 17 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/tianyancha/TycSearchResponse.java

@@ -0,0 +1,17 @@
+package com.malk.server.tianyancha;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 天眼查 816 企业搜索原子响应。
+ */
+@Data
+@Builder
+public class TycSearchResponse {
+
+    private Long total;
+    private List<TycSearchItem> items;
+}

+ 38 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/customer/CustomerEnrichService.java

@@ -0,0 +1,38 @@
+package com.malk.service.customer;
+
+import com.malk.server.customer.CustomerEnrichRequest;
+import com.malk.server.customer.CustomerEnrichResult;
+import com.malk.server.customer.CustomerSearchResult;
+
+/**
+ * 客户档案工商信息查询与字段映射服务。
+ */
+public interface CustomerEnrichService {
+
+    /**
+     * 搜索工商候选企业,不自动选择候选。
+     *
+     * @param customerName 客户名称关键词
+     * @param pageNum 页码
+     * @param pageSize 每页数量
+     * @return 候选列表
+     */
+    CustomerSearchResult search(String customerName, Integer pageNum, Integer pageSize);
+
+    /**
+     * 按天眼查企业 ID 查询详情并生成回填字段。
+     *
+     * @param companyId 天眼查企业 ID
+     * @param existingValues 宜搭现有字段值
+     * @return 工商详情及回填计划
+     */
+    CustomerEnrichResult detail(Long companyId, java.util.Map<String, Object> existingValues);
+
+    /**
+     * 兼容旧回填入口,按 ID、信用代码或严格一致的企业名称查询。
+     *
+     * @param request 回填请求
+     * @return 工商详情及回填计划
+     */
+    CustomerEnrichResult enrich(CustomerEnrichRequest request);
+}

+ 326 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/customer/impl/CustomerEnrichServiceImpl.java

@@ -0,0 +1,326 @@
+package com.malk.service.customer.impl;
+
+import com.malk.server.common.McException;
+import com.malk.server.customer.CustomerConf;
+import com.malk.server.customer.CustomerEnrichMeta;
+import com.malk.server.customer.CustomerEnrichRequest;
+import com.malk.server.customer.CustomerEnrichResult;
+import com.malk.server.customer.CustomerSearchItem;
+import com.malk.server.customer.CustomerSearchResult;
+import com.malk.server.tianyancha.TycCompany;
+import com.malk.server.tianyancha.TycSearchItem;
+import com.malk.server.tianyancha.TycSearchResponse;
+import com.malk.service.customer.CustomerEnrichService;
+import com.malk.service.tianyancha.TycClient;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 客户档案工商信息查询与字段映射服务实现。
+ */
+@Slf4j
+@Service
+public class CustomerEnrichServiceImpl implements CustomerEnrichService {
+
+    private static final String F_CREDIT_CODE = "creditCode";
+    private static final String F_LEGAL_PERSON = "legalPerson";
+    private static final String F_COMPANY_TYPE = "companyType";
+    private static final String F_INDUSTRY = "industry";
+    private static final String F_REG_CAPITAL = "regCapital";
+    private static final String F_REG_STATUS = "regStatus";
+    private static final String F_ESTABLISH_TIME = "establishTime";
+    private static final String F_TERM = "term";
+    private static final String F_APPROVED_TIME = "approvedTime";
+    private static final String F_REG_INSTITUTE = "regInstitute";
+    private static final String F_REG_LOCATION = "regLocation";
+    private static final String F_PHONE = "phone";
+    private static final String F_WEBSITE = "website";
+    private static final String F_TYC_URL = "tycUrl";
+    private static final String F_STAFF_SCALE = "staffScale";
+    private static final String F_BUSINESS_SCOPE = "businessScope";
+    private static final List<String> UNAVAILABLE_BIZ_KEYS = java.util.Arrays.asList(
+            "area", "businessAddress", "fax", "email", "postalCode");
+    private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)");
+
+    @Autowired
+    private TycClient tycClient;
+
+    @Autowired
+    private CustomerConf conf;
+
+    /**
+     * 搜索工商候选企业,不自动选择候选。
+     *
+     * @param customerName 客户名称关键词
+     * @param pageNum 页码
+     * @param pageSize 每页数量
+     * @return 候选列表
+     */
+    @Override
+    public CustomerSearchResult search(String customerName, Integer pageNum, Integer pageSize) {
+        TycSearchResponse response = tycClient.searchCompanies(customerName, pageNum, pageSize);
+        List<CustomerSearchItem> candidates = new ArrayList<>();
+        if (response.getItems() != null) {
+            for (TycSearchItem item : response.getItems()) {
+                candidates.add(CustomerSearchItem.builder()
+                        .companyId(item.getId())
+                        .companyName(item.getName())
+                        .creditCode(item.getCreditCode())
+                        .legalPersonName(item.getLegalPersonName())
+                        .regStatus(item.getRegStatus())
+                        .build());
+            }
+        }
+        return CustomerSearchResult.builder()
+                .pageNum(pageNum)
+                .pageSize(pageSize)
+                .total(response.getTotal())
+                .candidates(candidates)
+                .build();
+    }
+
+    /**
+     * 按企业 ID 查询工商详情并生成回填字段。
+     *
+     * @param companyId 天眼查企业 ID
+     * @param existingValues 宜搭现有字段值
+     * @return 工商详情及回填计划
+     */
+    @Override
+    public CustomerEnrichResult detail(Long companyId, Map<String, Object> existingValues) {
+        long startedAt = System.currentTimeMillis();
+        return buildResult(tycClient.getCompanyById(companyId), existingValues, "tianyancha:1001:id", startedAt);
+    }
+
+    /**
+     * 兼容旧入口,按 ID、信用代码或严格一致企业名称查询。
+     *
+     * @param request 回填请求
+     * @return 工商详情及回填计划
+     */
+    @Override
+    public CustomerEnrichResult enrich(CustomerEnrichRequest request) {
+        if (request == null) {
+            throw new McException("CUSTOMER_QUERY_REQUIRED", "请求体不能为空");
+        }
+        if (request.getCompanyId() != null) {
+            return detail(request.getCompanyId(), request.getExisting());
+        }
+        long startedAt = System.currentTimeMillis();
+        if (StringUtils.isNotBlank(request.getCreditCode())) {
+            TycCompany company = tycClient.getCompanyByName(request.getCreditCode().trim());
+            return buildResult(company, request.getExisting(), "tianyancha:1001:creditCode", startedAt);
+        }
+        if (StringUtils.isNotBlank(request.getCustomerName())) {
+            TycCompany company = tycClient.getCompanyByName(request.getCustomerName().trim());
+            if (company != null && !namesExactlyMatch(request.getCustomerName(), company.getName())) {
+                throw new McException("CUSTOMER_COMPANY_SELECTION_REQUIRED", "工商名称不完全一致,请先搜索并选择企业");
+            }
+            return buildResult(company, request.getExisting(), "tianyancha:1001:name", startedAt);
+        }
+        throw new McException("CUSTOMER_QUERY_REQUIRED", "companyId、creditCode、customerName 至少填写一个");
+    }
+
+    CustomerEnrichResult buildResult(TycCompany company, Map<String, Object> existingValues,
+                                     String source, long startedAt) {
+        Map<String, Object> filled = new LinkedHashMap<>();
+        List<String> skipped = new ArrayList<>();
+        List<String> unavailable = unavailableFieldIds();
+        if (company == null) {
+            return CustomerEnrichResult.builder()
+                    .hit(false)
+                    .filled(filled)
+                    .skipped(skipped)
+                    .unavailable(unavailable)
+                    .meta(metaOf(source, startedAt))
+                    .build();
+        }
+        Map<String, Object> existing = existingValues == null ? Collections.emptyMap() : existingValues;
+        putIfEmpty(filled, skipped, existing, F_CREDIT_CODE, company.getCreditCode());
+        putIfEmpty(filled, skipped, existing, F_LEGAL_PERSON, company.getLegalPersonName());
+        putIfEmpty(filled, skipped, existing, F_COMPANY_TYPE, company.getCompanyOrgType());
+        putIfEmpty(filled, skipped, existing, F_INDUSTRY, company.getIndustry());
+        putIfEmpty(filled, skipped, existing, F_REG_CAPITAL, company.getRegCapital());
+        putIfEmpty(filled, skipped, existing, F_REG_STATUS, company.getRegStatus());
+        putIfEmpty(filled, skipped, existing, F_ESTABLISH_TIME, company.getEstiblishTime());
+        putIfEmpty(filled, skipped, existing, F_TERM, formatTerm(company.getFromTime(), company.getToTime()));
+        putIfEmpty(filled, skipped, existing, F_APPROVED_TIME, company.getApprovedTime());
+        putIfEmpty(filled, skipped, existing, F_REG_INSTITUTE, company.getRegInstitute());
+        putIfEmpty(filled, skipped, existing, F_REG_LOCATION, company.getRegLocation());
+        putIfEmpty(filled, skipped, existing, F_PHONE, company.getPhoneNumber());
+        putIfEmpty(filled, skipped, existing, F_WEBSITE, firstNonBlank(company.getWebsiteList()));
+        putIfEmpty(filled, skipped, existing, F_TYC_URL, buildTycUrl(company.getId()));
+        putIfEmpty(filled, skipped, existing, F_STAFF_SCALE, mapStaffScale(company.getStaffNumRange()));
+        putIfEmpty(filled, skipped, existing, F_BUSINESS_SCOPE, company.getBusinessScope());
+        return CustomerEnrichResult.builder()
+                .hit(true)
+                .companyId(company.getId())
+                .companyName(company.getName())
+                .creditCode(company.getCreditCode())
+                .filled(filled)
+                .skipped(skipped)
+                .unavailable(unavailable)
+                .meta(metaOf(source, startedAt))
+                .build();
+    }
+
+    boolean namesExactlyMatch(String requestedName, String actualName) {
+        String requested = normalizeCompanyName(requestedName);
+        String actual = normalizeCompanyName(actualName);
+        return StringUtils.isNotBlank(requested) && requested.equals(actual);
+    }
+
+    String normalizeCompanyName(String name) {
+        if (name == null) {
+            return null;
+        }
+        return name.trim()
+                .replace('(', '(')
+                .replace(')', ')')
+                .replaceAll("\\s+", "")
+                .toLowerCase(Locale.ROOT);
+    }
+
+    String mapStaffScale(String staffNumRange) {
+        if (StringUtils.isBlank(staffNumRange)) {
+            return null;
+        }
+        Matcher matcher = NUMBER_PATTERN.matcher(staffNumRange.replace(",", ""));
+        List<Integer> numbers = new ArrayList<>();
+        while (matcher.find()) {
+            try {
+                numbers.add((int) Math.floor(Double.parseDouble(matcher.group(1))));
+            } catch (NumberFormatException exception) {
+                return null;
+            }
+        }
+        if (numbers.isEmpty()) {
+            return null;
+        }
+        int representative;
+        if (numbers.size() >= 2) {
+            representative = numbers.get(1);
+        } else if (containsUpperBoundWord(staffNumRange)) {
+            representative = numbers.get(0);
+        } else if (containsLowerBoundWord(staffNumRange)) {
+            representative = numbers.get(0) + 1;
+        } else {
+            representative = numbers.get(0);
+        }
+        if (representative < 100) {
+            return "<100";
+        }
+        if (representative <= 500) {
+            return "100-500";
+        }
+        if (representative <= 1000) {
+            return "500>1000";
+        }
+        if (representative <= 5000) {
+            return "1000-5000";
+        }
+        if (representative <= 10000) {
+            return "5000-10000";
+        }
+        return ">10000";
+    }
+
+    private boolean containsUpperBoundWord(String value) {
+        return value.contains("以下") || value.contains("以内") || value.contains("少于")
+                || value.contains("小于") || value.contains("<") || value.contains("≤");
+    }
+
+    private boolean containsLowerBoundWord(String value) {
+        return value.contains("以上") || value.contains("多于") || value.contains("大于")
+                || value.contains(">") || value.contains("≥");
+    }
+
+    private void putIfEmpty(Map<String, Object> filled, List<String> skipped,
+                            Map<String, Object> existing, String bizKey, Object newValue) {
+        String fieldId = fieldIdOf(bizKey);
+        if (StringUtils.isBlank(fieldId) || isBlank(newValue)) {
+            return;
+        }
+        if (!isBlank(existing.get(fieldId))) {
+            skipped.add(fieldId);
+            return;
+        }
+        filled.put(fieldId, newValue);
+    }
+
+    private List<String> unavailableFieldIds() {
+        List<String> unavailable = new ArrayList<>();
+        for (String bizKey : UNAVAILABLE_BIZ_KEYS) {
+            String fieldId = fieldIdOf(bizKey);
+            unavailable.add(StringUtils.isBlank(fieldId) ? bizKey : fieldId);
+        }
+        return unavailable;
+    }
+
+    private String fieldIdOf(String bizKey) {
+        return conf.getFields() == null ? null : conf.getFields().get(bizKey);
+    }
+
+    private boolean isBlank(Object value) {
+        if (value == null) {
+            return true;
+        }
+        if (value instanceof String) {
+            return StringUtils.isBlank((String) value);
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue() == 0D;
+        }
+        return false;
+    }
+
+    private String formatTerm(Long from, Long to) {
+        if (from == null || from == 0L) {
+            return null;
+        }
+        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
+        String end = to == null || to == 0L ? "长期" : format.format(new Date(to));
+        return format.format(new Date(from)) + " 至 " + end;
+    }
+
+    private String firstNonBlank(String values) {
+        if (StringUtils.isBlank(values)) {
+            return null;
+        }
+        for (String value : values.split(",")) {
+            if (StringUtils.isNotBlank(value)) {
+                return value.trim();
+            }
+        }
+        return null;
+    }
+
+    private String buildTycUrl(Long companyId) {
+        if (companyId == null) {
+            return null;
+        }
+        String template = StringUtils.defaultIfBlank(
+                conf.getTycCompanyUrl(), "https://www.tianyancha.com/company/{id}");
+        return template.replace("{id}", companyId.toString());
+    }
+
+    private CustomerEnrichMeta metaOf(String source, long startedAt) {
+        return CustomerEnrichMeta.builder()
+                .source(source)
+                .latencyMs(System.currentTimeMillis() - startedAt)
+                .build();
+    }
+}

+ 49 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/tianyancha/TycClient.java

@@ -0,0 +1,49 @@
+package com.malk.service.tianyancha;
+
+import com.malk.server.tianyancha.TycCompany;
+import com.malk.server.tianyancha.TycSearchResponse;
+
+/**
+ * 天眼查开放平台客户端。
+ */
+public interface TycClient {
+
+    /**
+     * 按统一社会信用代码或企业名查询工商信息。
+     *
+     * @param creditCode 统一社会信用代码,可空
+     * @param companyName 企业名称,可空
+     * @return 命中返回企业信息,无匹配返回 null
+     * @apiNote https://open.api.tianyancha.com/services/open/ic/baseInfoV3/2.0
+     */
+    TycCompany queryCompany(String creditCode, String companyName);
+
+    /**
+     * 调用 816 企业搜索接口返回候选列表,不自动选择候选。
+     *
+     * @param customerName 企业名称关键词
+     * @param pageNum 页码,从 1 开始
+     * @param pageSize 每页数量,最大 100
+     * @return 搜索原始候选结果
+     * @apiNote https://open.api.tianyancha.com/services/open/search/2.0
+     */
+    TycSearchResponse searchCompanies(String customerName, Integer pageNum, Integer pageSize);
+
+    /**
+     * 调用 1001 工商信息接口按天眼查企业 ID 精确查询。
+     *
+     * @param companyId 天眼查企业 ID
+     * @return 命中返回企业信息,无结果返回 null
+     * @apiNote https://open.api.tianyancha.com/services/open/ic/baseInfoV3/2.0
+     */
+    TycCompany getCompanyById(Long companyId);
+
+    /**
+     * 调用 1001 工商信息接口按 name 参数查询,name 可为企业名称或统一社会信用代码。
+     *
+     * @param name 企业名称或统一社会信用代码
+     * @return 命中返回企业信息,无结果返回 null
+     * @apiNote https://open.api.tianyancha.com/services/open/ic/baseInfoV3/2.0
+     */
+    TycCompany getCompanyByName(String name);
+}

+ 242 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/tianyancha/impl/TycClientImpl.java

@@ -0,0 +1,242 @@
+package com.malk.service.tianyancha.impl;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.common.McException;
+import com.malk.server.tianyancha.TycCompany;
+import com.malk.server.tianyancha.TycConf;
+import com.malk.server.tianyancha.TycSearchItem;
+import com.malk.server.tianyancha.TycSearchResponse;
+import com.malk.service.tianyancha.TycClient;
+import com.malk.utils.UtilHttp;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 天眼查开放平台客户端实现。
+ */
+@Service
+public class TycClientImpl implements TycClient {
+
+    private static final String PATH_BASEINFO = "/services/open/cb/ic/2.0";
+    private static final String PATH_SEARCH = "/services/open/search/2.0";
+    private static final String SOURCE = "tianyancha";
+
+    @Autowired
+    private TycConf conf;
+
+    /**
+     * 按统一社会信用代码或企业名调用 1001 查询,不使用 816 自动选候选。
+     *
+     * @param creditCode 统一社会信用代码,可空
+     * @param companyName 企业名称,可空
+     * @return 命中返回企业信息,无结果返回 null
+     * @apiNote https://open.api.tianyancha.com/services/open/cb/ic/2.0
+     */
+    @Override
+    public TycCompany queryCompany(String creditCode, String companyName) {
+        if (StringUtils.isNotBlank(creditCode)) {
+            return getCompanyByName(creditCode.trim());
+        }
+        if (StringUtils.isNotBlank(companyName)) {
+            return getCompanyByName(companyName.trim());
+        }
+        return null;
+    }
+
+    /**
+     * 调用 816 企业搜索接口返回候选列表。
+     *
+     * @param customerName 企业名称关键词
+     * @param pageNum 页码,从 1 开始
+     * @param pageSize 每页数量,最大 100
+     * @return 搜索候选结果
+     * @apiNote https://open.api.tianyancha.com/services/open/search/2.0
+     */
+    @Override
+    public TycSearchResponse searchCompanies(String customerName, Integer pageNum, Integer pageSize) {
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("word", customerName);
+        params.put("pageNum", pageNum);
+        params.put("pageSize", pageSize);
+        String body = executeGet(PATH_SEARCH, params);
+        return parseSearchResponse(body);
+    }
+
+    /**
+     * 调用 1001 工商信息接口按企业 ID 精确查询。
+     *
+     * @param companyId 天眼查企业 ID
+     * @return 命中返回企业信息,无结果返回 null
+     * @apiNote https://open.api.tianyancha.com/services/open/cb/ic/2.0
+     */
+    @Override
+    public TycCompany getCompanyById(Long companyId) {
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("id", companyId);
+        String body = executeGet(PATH_BASEINFO, params);
+        return parseCompanyResponse(body);
+    }
+
+    /**
+     * 调用 1001 工商信息接口按 name 查询。
+     *
+     * @param name 企业名称或统一社会信用代码
+     * @return 命中返回企业信息,无结果返回 null
+     * @apiNote https://open.api.tianyancha.com/services/open/cb/ic/2.0
+     */
+    @Override
+    public TycCompany getCompanyByName(String name) {
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("keyword", name);
+        String body = executeGet(PATH_BASEINFO, params);
+        return parseCompanyResponse(body);
+    }
+
+    TycSearchResponse parseSearchResponse(String body) {
+        JSONObject result = successfulResult(body, "816");
+        if (result == null) {
+            return TycSearchResponse.builder().total(0L).items(Collections.emptyList()).build();
+        }
+        JSONArray array = result.getJSONArray("items");
+        if (array == null) {
+            array = result.getJSONArray("data");
+        }
+        List<TycSearchItem> items = new ArrayList<>();
+        if (array != null) {
+            for (int i = 0; i < array.size(); i++) {
+                JSONObject itemJson = array.getJSONObject(i);
+                Integer type = itemJson.getInteger("type");
+                if (type != null && type != 1) {
+                    continue;
+                }
+                TycSearchItem item = new TycSearchItem();
+                item.setId(itemJson.getLong("id"));
+                item.setName(itemJson.getString("name"));
+                item.setCreditCode(firstString(itemJson, "creditCode", "credit_code"));
+                item.setLegalPersonName(firstString(itemJson, "legalPersonName", "legalPerson"));
+                item.setRegStatus(itemJson.getString("regStatus"));
+                items.add(item);
+            }
+        }
+        Long total = result.getLong("total");
+        if (total == null) {
+            total = result.getLong("totalCount");
+        }
+        return TycSearchResponse.builder()
+                .total(total == null ? (long) items.size() : total)
+                .items(items)
+                .build();
+    }
+
+    TycCompany parseCompanyResponse(String body) {
+        JSONObject result = successfulResult(body, "1001");
+        return result == null ? null : parseCompany(result);
+    }
+
+    private JSONObject successfulResult(String body, String endpoint) {
+        if (StringUtils.isBlank(body)) {
+            throw vendorException("TYC_EMPTY_RESPONSE", "天眼查 " + endpoint + " 返回为空");
+        }
+        final JSONObject response;
+        try {
+            response = JSONObject.parseObject(body);
+        } catch (RuntimeException exception) {
+            throw vendorException("TYC_INVALID_RESPONSE", "天眼查 " + endpoint + " 返回格式不合法");
+        }
+        Integer errorCode = response.getInteger("error_code");
+        if (errorCode == null || errorCode.intValue() != 0) {
+            String code = errorCode == null ? "UNKNOWN" : errorCode.toString();
+            String reason = StringUtils.defaultIfBlank(response.getString("reason"), "供应商返回错误");
+            throw vendorException("TYC_" + code, "天眼查 " + endpoint + " 调用失败:" + reason);
+        }
+        return response.getJSONObject("result");
+    }
+
+    private TycCompany parseCompany(JSONObject result) {
+        TycCompany company = new TycCompany();
+        company.setId(result.getLong("id"));
+        company.setName(result.getString("name"));
+        company.setCreditCode(result.getString("creditCode"));
+        company.setLegalPersonName(result.getString("legalPersonName"));
+        company.setCompanyOrgType(result.getString("companyOrgType"));
+        company.setIndustry(result.getString("industry"));
+        company.setRegCapital(result.getString("regCapital"));
+        company.setRegStatus(result.getString("regStatus"));
+        company.setEstiblishTime(result.getLong("estiblishTime"));
+        company.setFromTime(result.getLong("fromTime"));
+        company.setToTime(result.getLong("toTime"));
+        company.setApprovedTime(result.getLong("approvedTime"));
+        company.setRegInstitute(result.getString("regInstitute"));
+        company.setRegLocation(result.getString("regLocation"));
+        company.setBase(result.getString("base"));
+        company.setPhoneNumber(result.getString("phoneNumber"));
+        company.setWebsiteList(result.getString("websiteList"));
+        company.setBusinessScope(result.getString("businessScope"));
+        company.setStaffNumRange(result.getString("staffNumRange"));
+        return company;
+    }
+
+    private String firstString(JSONObject json, String firstKey, String secondKey) {
+        return StringUtils.defaultIfBlank(json.getString(firstKey), json.getString(secondKey));
+    }
+
+    private String buildUrl(String path) {
+        String baseUrl = StringUtils.removeEnd(StringUtils.defaultString(conf.getBaseUrl()), "/");
+        if (StringUtils.isBlank(baseUrl)) {
+            throw new McException("TYC_CONFIG_ERROR", "天眼查 baseUrl 未配置");
+        }
+        return baseUrl + path;
+    }
+
+    private String executeGet(String path, Map<String, Object> params) {
+        String url = buildUrl(path);
+        Map<String, String> headers = authorizationHeaders();
+        try {
+            return UtilHttp.doGet(url, headers, params, conf.getTimeoutMs());
+        } catch (RuntimeException exception) {
+            // fixme 不透传 URL、查询参数或供应商响应,避免工商信息进入错误日志。
+            throw vendorException("TYC_TRANSPORT_ERROR", "天眼查网络请求失败或超时");
+        }
+    }
+
+    private Map<String, String> authorizationHeaders() {
+        String token = StringUtils.trimToNull(conf.getToken());
+        if (token == null) {
+            throw new McException("TYC_TOKEN_MISSING", "天眼查 TYC_TOKEN 未配置");
+        }
+        return new SensitiveHeaderMap(token);
+    }
+
+    private McException vendorException(String code, String message) {
+        return McException.builder()
+                .success(false)
+                .code(code)
+                .message(message)
+                .source(SOURCE)
+                .build();
+    }
+
+    /**
+     * 请求时保留真实 Authorization,日志输出时固定脱敏。
+     */
+    private static final class SensitiveHeaderMap extends LinkedHashMap<String, String> {
+        private static final long serialVersionUID = 1L;
+
+        private SensitiveHeaderMap(String token) {
+            put("Authorization", token);
+        }
+
+        @Override
+        public String toString() {
+            return "{Authorization=***}";
+        }
+    }
+}

+ 36 - 0
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -86,3 +86,39 @@ workhours:
   otherApprovalSyncStatusField: "selectField_mq58cd5p"
   otherApprovalSyncTotalField: "numberField_mq58cd5q"
   otherApprovalSyncFailField: "numberField_mq58cd5r"
+
+# 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
+tianyancha:
+  baseUrl: "https://open.api.tianyancha.com"
+  # Bearer Token; 必须通过环境变量 TYC_TOKEN 注入
+  token: "${TYC_TOKEN:}"
+  timeoutMs: 8000
+
+# 客户档案·22 字段 fieldId 映射 (云璞租户)
+customer:
+  yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
+  yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
+  tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
+  fields:
+    customerName: "textField_mjmje451"
+    creditCode: "textField_mjmlh2y8"
+    legalPerson: "textField_mjmlh2y3"
+    companyType: "textField_mjmlh2y9"
+    industry: "textField_mjmlh2ya"
+    regCapital: "textField_mjmlh2yc"
+    regStatus: "textField_mjmlh2yf"
+    establishTime: "dateField_mjmlh2yp"
+    term: "textField_mjmlh2yq"
+    approvedTime: "dateField_mjmlh2ys"
+    regInstitute: "textField_mjmlh2yk"
+    regLocation: "textField_mjmlh2ym"
+    area: "addressField_mjmlh2z0"
+    businessAddress: "textField_mjmlh2yx"
+    phone: "textField_mjmlh2z6"
+    fax: "textField_mkjxhpsa"
+    email: "textField_mjmlh2z3"
+    postalCode: "textField_mjmlh2z7"
+    website: "textField_mjmlh2z9"
+    tycUrl: "textField_mjmlh2zc"
+    staffScale: "selectField_mjmlh30s"
+    businessScope: "textField_mjmlh2zi"

+ 35 - 0
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -70,3 +70,38 @@ workhours:
   otherApprovalSyncStatusField: "selectField_mq58cd5p"
   otherApprovalSyncTotalField: "numberField_mq58cd5q"
   otherApprovalSyncFailField: "numberField_mq58cd5r"
+
+# 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
+tianyancha:
+  baseUrl: "https://open.api.tianyancha.com"
+  # Bearer Token; 必须通过环境变量 TYC_TOKEN 注入
+  token: "${TYC_TOKEN:}"
+  timeoutMs: 8000
+
+customer:
+  yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
+  yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
+  tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
+  fields:
+    customerName: "textField_mjmje451"
+    creditCode: "textField_mjmlh2y8"
+    legalPerson: "textField_mjmlh2y3"
+    companyType: "textField_mjmlh2y9"
+    industry: "textField_mjmlh2ya"
+    regCapital: "textField_mjmlh2yc"
+    regStatus: "textField_mjmlh2yf"
+    establishTime: "dateField_mjmlh2yp"
+    term: "textField_mjmlh2yq"
+    approvedTime: "dateField_mjmlh2ys"
+    regInstitute: "textField_mjmlh2yk"
+    regLocation: "textField_mjmlh2ym"
+    area: "addressField_mjmlh2z0"
+    businessAddress: "textField_mjmlh2yx"
+    phone: "textField_mjmlh2z6"
+    fax: "textField_mkjxhpsa"
+    email: "textField_mjmlh2z3"
+    postalCode: "textField_mjmlh2z7"
+    website: "textField_mjmlh2z9"
+    tycUrl: "textField_mjmlh2zc"
+    staffScale: "selectField_mjmlh30s"
+    businessScope: "textField_mjmlh2zi"

+ 188 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/customer/impl/CustomerEnrichServiceImplTest.java

@@ -0,0 +1,188 @@
+package com.malk.service.customer.impl;
+
+import com.malk.server.common.McException;
+import com.malk.server.customer.CustomerConf;
+import com.malk.server.customer.CustomerEnrichRequest;
+import com.malk.server.customer.CustomerEnrichResult;
+import com.malk.server.customer.CustomerSearchRequest;
+import com.malk.server.tianyancha.TycCompany;
+import com.malk.server.tianyancha.TycSearchResponse;
+import com.malk.service.tianyancha.TycClient;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * 客户工商映射服务离线测试。
+ */
+public class CustomerEnrichServiceImplTest {
+
+    private CustomerEnrichServiceImpl service;
+    private StubTycClient client;
+
+    /**
+     * 初始化离线服务和字段映射。
+     */
+    @Before
+    public void setUp() {
+        service = new CustomerEnrichServiceImpl();
+        client = new StubTycClient();
+        CustomerConf conf = new CustomerConf();
+        conf.setTycCompanyUrl("https://www.tianyancha.com/company/{id}");
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("creditCode", "credit");
+        fields.put("legalPerson", "legal");
+        fields.put("establishTime", "establish");
+        fields.put("staffScale", "staff");
+        fields.put("area", "area");
+        fields.put("businessAddress", "businessAddress");
+        fields.put("fax", "fax");
+        fields.put("email", "email");
+        fields.put("postalCode", "postalCode");
+        conf.setFields(fields);
+        ReflectionTestUtils.setField(service, "tycClient", client);
+        ReflectionTestUtils.setField(service, "conf", conf);
+    }
+
+    /**
+     * 验证字段映射、DateField 毫秒值及不可用字段输出。
+     */
+    @Test
+    public void shouldMapCompanyFields() {
+        TycCompany company = company("上海示例科技有限公司");
+        company.setEstiblishTime(1704067200000L);
+        company.setStaffNumRange("101-500人");
+        client.company = company;
+
+        CustomerEnrichResult result = service.detail(1L, null);
+
+        assertTrue(result.isHit());
+        assertEquals("91310000TEST", result.getFilled().get("credit"));
+        assertEquals(1704067200000L, result.getFilled().get("establish"));
+        assertEquals("100-500", result.getFilled().get("staff"));
+        assertTrue(result.getUnavailable().contains("email"));
+    }
+
+    /**
+     * 验证已有非空值不被覆盖并记录 skipped。
+     */
+    @Test
+    public void shouldProtectExistingValue() {
+        client.company = company("上海示例科技有限公司");
+        Map<String, Object> existing = new LinkedHashMap<>();
+        existing.put("credit", "existing-code");
+
+        CustomerEnrichResult result = service.detail(1L, existing);
+
+        assertFalse(result.getFilled().containsKey("credit"));
+        assertTrue(result.getSkipped().contains("credit"));
+    }
+
+    /**
+     * 验证名称仅做空白、括号和大小写标准化,简称不能误命中。
+     */
+    @Test
+    public void shouldRequireSelectionWhenNormalizedNameDiffers() {
+        client.company = company("上海示例科技有限公司");
+        CustomerEnrichRequest request = new CustomerEnrichRequest();
+        request.setCustomerName("上海示例科技");
+
+        try {
+            service.enrich(request);
+            fail("应抛出企业选择异常");
+        } catch (McException exception) {
+            assertEquals("CUSTOMER_COMPANY_SELECTION_REQUIRED", exception.getCode());
+        }
+
+        request.setCustomerName(" 上海示例科技(有限公司) ");
+        client.company = company("上海示例科技(有限公司)");
+        assertTrue(service.enrich(request).isHit());
+    }
+
+    /**
+     * 验证人员规模边界和不可解析值。
+     */
+    @Test
+    public void shouldMapStaffScaleBoundaries() {
+        assertEquals("<100", service.mapStaffScale("99人以下"));
+        assertEquals("100-500", service.mapStaffScale("100人"));
+        assertEquals("100-500", service.mapStaffScale("101-500人"));
+        assertEquals("500>1000", service.mapStaffScale("501-1000人"));
+        assertEquals("1000-5000", service.mapStaffScale("1001-5000人"));
+        assertEquals("5000-10000", service.mapStaffScale("5001-10000人"));
+        assertEquals(">10000", service.mapStaffScale("10000人以上"));
+        assertNull(service.mapStaffScale("未知"));
+    }
+
+    /**
+     * 验证搜索分页参数可省略并使用安全默认值。
+     */
+    @Test
+    public void shouldDefaultSearchPaging() {
+        CustomerSearchRequest request = new CustomerSearchRequest();
+
+        assertEquals(Integer.valueOf(1), request.getPageNum());
+        assertEquals(Integer.valueOf(20), request.getPageSize());
+    }
+
+    /**
+     * 验证搜索请求优先使用 keyword,同时兼容旧 customerName 参数。
+     */
+    @Test
+    public void shouldResolveSearchKeywordAndLegacyCustomerName() {
+        CustomerSearchRequest request = new CustomerSearchRequest();
+        request.setCustomerName(" 旧客户名称 ");
+
+        assertTrue(request.isQueryPresent());
+        assertEquals("旧客户名称", request.resolveKeyword());
+
+        request.setKeyword(" 新检索词 ");
+        assertEquals("新检索词", request.resolveKeyword());
+
+        request.setKeyword(" ");
+        request.setCustomerName(null);
+        assertFalse(request.isQueryPresent());
+    }
+
+    private TycCompany company(String name) {
+        TycCompany company = new TycCompany();
+        company.setId(1L);
+        company.setName(name);
+        company.setCreditCode("91310000TEST");
+        company.setLegalPersonName("张三");
+        return company;
+    }
+
+    private static final class StubTycClient implements TycClient {
+        private TycCompany company;
+
+        @Override
+        public TycCompany queryCompany(String creditCode, String companyName) {
+            return company;
+        }
+
+        @Override
+        public TycSearchResponse searchCompanies(String customerName, Integer pageNum, Integer pageSize) {
+            return null;
+        }
+
+        @Override
+        public TycCompany getCompanyById(Long companyId) {
+            return company;
+        }
+
+        @Override
+        public TycCompany getCompanyByName(String name) {
+            return company;
+        }
+    }
+}

+ 212 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/tianyancha/impl/TycClientImplTest.java

@@ -0,0 +1,212 @@
+package com.malk.service.tianyancha.impl;
+
+import com.sun.net.httpserver.HttpServer;
+import com.malk.server.common.McException;
+import com.malk.server.tianyancha.TycCompany;
+import com.malk.server.tianyancha.TycConf;
+import com.malk.server.tianyancha.TycSearchResponse;
+import org.junit.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * 天眼查响应解析离线测试。
+ */
+public class TycClientImplTest {
+
+    /**
+     * 验证 816 仅保留企业候选并兼容 items 结构。
+     */
+    @Test
+    public void shouldParseCompanySearchItems() {
+        TycClientImpl client = new TycClientImpl();
+        String json = "{\"error_code\":0,\"reason\":\"ok\",\"result\":{"
+                + "\"total\":2,\"items\":["
+                + "{\"id\":1,\"name\":\"企业A\",\"type\":1,\"creditCode\":\"CODE\"},"
+                + "{\"id\":2,\"name\":\"人物B\",\"type\":2}]}}";
+
+        TycSearchResponse response = client.parseSearchResponse(json);
+
+        assertEquals(Long.valueOf(2L), response.getTotal());
+        assertEquals(1, response.getItems().size());
+        assertEquals("企业A", response.getItems().get(0).getName());
+    }
+
+    /**
+     * 验证 1001 工商响应解析。
+     */
+    @Test
+    public void shouldParseCompanyDetail() {
+        TycClientImpl client = new TycClientImpl();
+        TycCompany company = client.parseCompanyResponse(
+                "{\"error_code\":0,\"result\":{\"id\":9,\"name\":\"企业C\",\"staffNumRange\":\"100-499人\"}}");
+
+        assertEquals(Long.valueOf(9L), company.getId());
+        assertEquals("企业C", company.getName());
+    }
+
+    /**
+     * 验证供应商错误不被吞掉且来源为天眼查。
+     */
+    @Test
+    public void shouldThrowVendorException() {
+        TycClientImpl client = new TycClientImpl();
+        try {
+            client.parseCompanyResponse("{\"error_code\":300006,\"reason\":\"余额不足\"}");
+            fail("应抛出供应商异常");
+        } catch (McException exception) {
+            assertEquals("TYC_300006", exception.getCode());
+            assertEquals("tianyancha", exception.getSource());
+            assertTrue(exception.getMessage().contains("余额不足"));
+        }
+    }
+
+    /**
+     * 验证非 2xx 响应统一转换为天眼查传输错误。
+     *
+     * @throws IOException 本地测试服务启动失败
+     */
+    @Test
+    public void shouldTranslateHttpStatusToTransportError() throws IOException {
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/", exchange -> {
+            byte[] body = "upstream unavailable".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(502, body.length);
+            exchange.getResponseBody().write(body);
+            exchange.close();
+        });
+        server.start();
+
+        try {
+            TycClientImpl client = clientFor(server, 1000);
+            client.getCompanyByName("测试企业");
+            fail("应抛出传输错误");
+        } catch (McException exception) {
+            assertEquals("TYC_TRANSPORT_ERROR", exception.getCode());
+            assertEquals("tianyancha", exception.getSource());
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    /**
+     * 验证配置的请求超时实际生效并转换为天眼查传输错误。
+     *
+     * @throws IOException 本地测试服务启动失败
+     */
+    @Test
+    public void shouldApplyConfiguredTimeout() throws IOException {
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/", exchange -> {
+            try {
+                Thread.sleep(250);
+                byte[] body = "{\"error_code\":0,\"result\":{\"id\":1}}"
+                        .getBytes(StandardCharsets.UTF_8);
+                exchange.sendResponseHeaders(200, body.length);
+                exchange.getResponseBody().write(body);
+            } catch (InterruptedException exception) {
+                Thread.currentThread().interrupt();
+            } catch (IOException ignored) {
+                // 客户端按预期超时关闭连接。
+            } finally {
+                exchange.close();
+            }
+        });
+        server.start();
+
+        try {
+            TycClientImpl client = clientFor(server, 30);
+            client.getCompanyByName("测试企业");
+            fail("应抛出超时传输错误");
+        } catch (McException exception) {
+            assertEquals("TYC_TRANSPORT_ERROR", exception.getCode());
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    /**
+     * 验证 1001 按企业名称或信用代码查询时使用官方必填参数 keyword。
+     *
+     * @throws IOException 本地测试服务启动失败
+     */
+    @Test
+    public void shouldUseKeywordForCompanyLookup() throws IOException {
+        AtomicReference<String> rawQuery = new AtomicReference<>();
+        AtomicReference<String> rawPath = new AtomicReference<>();
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/", exchange -> {
+            rawQuery.set(exchange.getRequestURI().getRawQuery());
+            rawPath.set(exchange.getRequestURI().getRawPath());
+            byte[] body = "{\"error_code\":0,\"result\":{\"id\":1}}"
+                    .getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            exchange.getResponseBody().write(body);
+            exchange.close();
+        });
+        server.start();
+
+        try {
+            TycClientImpl client = clientFor(server, 1000);
+            client.getCompanyByName("测试企业");
+
+            assertEquals("/services/open/cb/ic/2.0", rawPath.get());
+            assertTrue(rawQuery.get().contains("keyword="));
+            assertTrue(!rawQuery.get().contains("name="));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    /**
+     * 验证 816 客户检索使用官方路径、关键词和分页参数。
+     *
+     * @throws IOException 本地测试服务启动失败
+     */
+    @Test
+    public void shouldUseSearchContract() throws IOException {
+        AtomicReference<String> rawQuery = new AtomicReference<>();
+        AtomicReference<String> rawPath = new AtomicReference<>();
+        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+        server.createContext("/", exchange -> {
+            rawQuery.set(exchange.getRequestURI().getRawQuery());
+            rawPath.set(exchange.getRequestURI().getRawPath());
+            byte[] body = "{\"error_code\":0,\"result\":{\"total\":0,\"items\":[]}}"
+                    .getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            exchange.getResponseBody().write(body);
+            exchange.close();
+        });
+        server.start();
+
+        try {
+            TycClientImpl client = clientFor(server, 1000);
+            client.searchCompanies("测试企业", 2, 10);
+
+            assertEquals("/services/open/search/2.0", rawPath.get());
+            assertTrue(rawQuery.get().contains("word="));
+            assertTrue(rawQuery.get().contains("pageNum=2"));
+            assertTrue(rawQuery.get().contains("pageSize=10"));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private TycClientImpl clientFor(HttpServer server, int timeoutMs) {
+        TycConf conf = new TycConf();
+        conf.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
+        conf.setToken("test-token");
+        conf.setTimeoutMs(timeoutMs);
+        TycClientImpl client = new TycClientImpl();
+        ReflectionTestUtils.setField(client, "conf", conf);
+        return client;
+    }
+}

+ 32 - 0
mjava/src/main/java/com/malk/utils/UtilHttp.java

@@ -134,6 +134,38 @@ public abstract class UtilHttp {
         return doRequest(METHOD.GET, url, header, param, null);
     }
 
+    /**
+     * 执行带超时的安全 GET 请求,只记录状态与响应长度,不记录响应正文。
+     *
+     * @param url 请求地址
+     * @param header 请求头
+     * @param param 查询参数
+     * @param timeoutMs 超时时间(毫秒)
+     * @return 响应正文
+     * @throws IllegalStateException HTTP 状态不是 2xx
+     */
+    public static String doGet(String url, Map header, Map param, int timeoutMs) {
+        String path = HttpUtil.urlWithForm(url, param, CharsetUtil.CHARSET_UTF_8, true);
+        long startedAt = System.currentTimeMillis();
+        HttpResponse out = HttpRequest.get(path)
+                .addHeaders(header)
+                .timeout(timeoutMs)
+                .execute();
+        try {
+            String body = out.body();
+            int status = out.getStatus();
+            // fixme 天眼查响应含联系方式等工商信息,禁止将正文写入日志。
+            log.debug("安全 GET 响应, status = {}, responseLength = {}, latencyMs = {}",
+                    status, body == null ? 0 : body.length(), System.currentTimeMillis() - startedAt);
+            if (status < 200 || status >= 300) {
+                throw new IllegalStateException("HTTP_STATUS_" + status);
+            }
+            return body;
+        } finally {
+            out.close();
+        }
+    }
+
     public static VenR doGet(String url, Map header, Map param, Class rClass) {
         String rsp = doGet(url, header, param);
         VenR r = (VenR) JSON.parseObject(rsp, rClass);