把客户信息补充到数据库

This commit is contained in:
2026-05-03 13:12:58 +08:00
parent f833f373a8
commit 02453bbeb3
4 changed files with 266 additions and 12 deletions

View File

@@ -227,6 +227,8 @@ public class CustomerManagementController {
result.put("centerLocation", search.getCenterLocation());
result.put("totalCount", search.getTotalCount());
result.put("data", search.getMerchants());
result.put("detailedReport", search.getDetailedReport());
result.put("customerInsertedCount", search.getCustomerInsertedCount());
}
return ResponseEntity.status(search.getHttpStatus()).body(result);
}

View File

@@ -4,8 +4,10 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 高德周边 POI 中的一条商家记录(与 {@code AllShopCollectorV5} 解析字段一致)。
* 高德周边 POI 中的一条商家记录(与 {@code AllShopCollectorV5} 核心字段一致,并补充 distance 等 POI 字段)。
*/
@Data
@NoArgsConstructor
@@ -18,4 +20,53 @@ public class NearbyMerchantItem {
private String type;
/** 经纬度,格式 "lng,lat" */
private String location;
/** 高德 POI id */
private String id;
/** 距检索中心距离place/around 返回的 distance */
private String distance;
private String typecode;
/** 高德 business_area */
private String businessArea;
/** 高德 POI 照片链接extensions=all 时 photos[].url */
private List<String> photoUrls;
/**
* 与 {@link com.rj.gaode.AllShopCollectorV5#parseAndPrint} 单条输出格式一致(店名/地址/电话/行业/坐标),并附加扩展行。
*/
public String toDetailBlock() {
StringBuilder sb = new StringBuilder();
sb.append("店名:").append(dashIfEmpty(name));
sb.append("\n地址").append(dashIfEmpty(address));
sb.append("\n电话").append(dashIfEmpty(tel));
sb.append("\n行业").append(dashIfEmpty(type));
sb.append("\n坐标").append(dashIfEmpty(location));
if (isPresent(distance)) {
sb.append("\n距离(米)").append(distance);
}
if (isPresent(id)) {
sb.append("\nPOI ID").append(id);
}
if (isPresent(typecode)) {
sb.append("\n类型编码").append(typecode);
}
if (isPresent(businessArea)) {
sb.append("\n商圈").append(businessArea);
}
if (photoUrls != null && !photoUrls.isEmpty()) {
sb.append("\n照片URL");
for (int i = 0; i < photoUrls.size(); i++) {
sb.append("\n ").append(i + 1).append(". ").append(photoUrls.get(i));
}
}
sb.append("\n---------------------------");
return sb.toString();
}
private static boolean isPresent(String s) {
return s != null && !s.isEmpty();
}
private static String dashIfEmpty(String s) {
return isPresent(s) ? s : "";
}
}

View File

@@ -18,26 +18,37 @@ public class NearbyMerchantSearchResult {
private final List<NearbyMerchantItem> merchants;
/** 高德返回的本次检索总条数(字符串),可能大于已拉取页数之和 */
private final String totalCount;
/**
* 与 {@link com.rj.gaode.AllShopCollectorV5#parseAndPrint} 类似的商铺明细文本(含检索参数与每家店块)。
*/
private final String detailedReport;
/** 本次成功写入 {@code customer_management} 的条数(按 contact 匹配,含新增与更新) */
private final Integer customerInsertedCount;
private NearbyMerchantSearchResult(int httpStatus, boolean success, String message,
String centerLocation, List<NearbyMerchantItem> merchants, String totalCount) {
String centerLocation, List<NearbyMerchantItem> merchants, String totalCount,
String detailedReport, Integer customerInsertedCount) {
this.httpStatus = httpStatus;
this.success = success;
this.message = message;
this.centerLocation = centerLocation;
this.merchants = merchants == null ? Collections.emptyList() : merchants;
this.totalCount = totalCount;
this.detailedReport = detailedReport;
this.customerInsertedCount = customerInsertedCount;
}
public static NearbyMerchantSearchResult ok(String centerLocation, List<NearbyMerchantItem> merchants, String totalCount) {
return new NearbyMerchantSearchResult(200, true, "查询成功", centerLocation, merchants, totalCount);
public static NearbyMerchantSearchResult ok(String centerLocation, List<NearbyMerchantItem> merchants,
String totalCount, String detailedReport, int customerInsertedCount) {
return new NearbyMerchantSearchResult(200, true, "查询成功", centerLocation, merchants, totalCount,
detailedReport, customerInsertedCount);
}
public static NearbyMerchantSearchResult badRequest(String message) {
return new NearbyMerchantSearchResult(400, false, message, null, Collections.emptyList(), null);
return new NearbyMerchantSearchResult(400, false, message, null, Collections.emptyList(), null, null, null);
}
public static NearbyMerchantSearchResult serverError(String message) {
return new NearbyMerchantSearchResult(500, false, message, null, Collections.emptyList(), null);
return new NearbyMerchantSearchResult(500, false, message, null, Collections.emptyList(), null, null, null);
}
}

View File

@@ -1,10 +1,13 @@
package com.rj.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rj.config.AmapProperties;
import com.rj.dto.NearbyMerchantItem;
import com.rj.dto.NearbyMerchantSearchResult;
import com.rj.entity.CustomerManagement;
import com.rj.service.ICustomerManagementService;
import com.rj.service.INearbyMerchantSearchService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -14,11 +17,13 @@ import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 逻辑参考 {@link com.rj.gaode.AllShopCollectorV5}:地理编码 + place/around 全行业周边搜索。
* 逻辑参考 {@link com.rj.gaode AllShopCollectorV5}:地理编码 + place/around 全行业周边搜索。
*/
@Slf4j
@Service
@@ -28,17 +33,30 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
private static final String AROUND_URL = "https://restapi.amap.com/v3/place/around";
private static final int AMAP_OFFSET_MAX = 50;
private static final int AMAP_PAGE_DELAY_MS = 800;
/** 与 customer_management 正面/侧面/生活照三字段一致 */
private static final int MAX_PHOTO_URLS = 3;
/** 高德采集写入客户时的默认门店/销售/意向(可按业务调整) */
private static final String DEFAULT_DEALERSHIP_ID = "1957424710587330562";
private static final String DEFAULT_DEALERSHIP_NAME = "北京移多旺科技有限公司";
private static final String DEFAULT_SALES_ID = "2";
private static final String DEFAULT_SALES_NAME = "15810181776";
private static final String DEFAULT_SALES_PHONE = "15810181776";
private static final String DEFAULT_INTENDED_MODEL = "线索";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final AmapProperties amapProperties;
private final ICustomerManagementService customerManagementService;
public NearbyMerchantSearchServiceImpl(RestTemplate restTemplate,
ObjectMapper objectMapper,
AmapProperties amapProperties) {
AmapProperties amapProperties,
ICustomerManagementService customerManagementService) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
this.amapProperties = amapProperties;
this.customerManagementService = customerManagementService;
}
@Override
@@ -76,12 +94,21 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
JsonNode pois = root.path("pois");
if (pois.isArray()) {
for (JsonNode p : pois) {
String tel = text(p, "tel").trim();
if (!StringUtils.hasText(tel)) {
continue;
}
all.add(new NearbyMerchantItem(
text(p, "name"),
text(p, "address"),
text(p, "tel"),
tel,
text(p, "type"),
text(p, "location")
text(p, "location"),
text(p, "id"),
text(p, "distance"),
text(p, "typecode"),
text(p, "business_area"),
extractPhotoUrls(p)
));
}
}
@@ -94,7 +121,15 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
}
}
}
return NearbyMerchantSearchResult.ok(location, all, totalCount);
String cTrim = city.trim();
String aTrim = address.trim();
String report = buildDetailedReport(cTrim, aTrim, radiusMeters, location, totalCount, all);
log.info("附近商家检索明细(与 AllShopCollectorV5 格式对齐)\n{}", report);
int customerWriteCount = persistMerchantsAsCustomers(all, cTrim, aTrim);
if (customerWriteCount > 0) {
log.info("高德采集结果已同步 customer_management成功 {} 条(按 contact 新增或更新)", customerWriteCount);
}
return NearbyMerchantSearchResult.ok(location, all, totalCount, report, customerWriteCount);
} catch (IllegalArgumentException e) {
return NearbyMerchantSearchResult.badRequest(e.getMessage());
} catch (Exception e) {
@@ -104,7 +139,7 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
}
/**
* 地理编码。URL 拼法与 {@link com.rj.gaode#} 一致:
* 地理编码。URL 拼法与 {@link com.rj.gaode AllShopCollectorV5} 一致:
* 使用 {@link URLEncoder#encode(String, java.nio.charset.Charset)} 处理中文查询参数。
* Spring {@code UriComponentsBuilder} 的编码规则与 {@code URLEncoder} 不一致,易导致高德返回 ENGINE_RESPONSE_DATA_ERROR如 infocode=30001
* 首次失败后不传 {@code city},仅用合并后的完整地址再请求一次。
@@ -185,6 +220,7 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
+ "&radius=" + radius
+ "&offset=" + offset
+ "&page=" + page
+ "&extensions=all"
+ "&output=json";
String body = restTemplate.getForObject(URI.create(url), String.class);
if (body == null) {
@@ -199,4 +235,158 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
}
return node.get(field).asText("");
}
/**
* 高德 POI {@code photos} 为数组:元素可为对象 {@code { "url": "..." }} 或个别环境下为 URL 字符串。
*/
private static List<String> extractPhotoUrls(JsonNode poi) {
List<String> urls = new ArrayList<>();
JsonNode photos = poi.path("photos");
if (!photos.isArray()) {
return urls;
}
for (JsonNode ph : photos) {
if (ph == null || ph.isNull()) {
continue;
}
if (ph.isTextual()) {
String u = ph.asText().trim();
if (StringUtils.hasText(u)) {
urls.add(u);
if (urls.size() >= MAX_PHOTO_URLS) {
break;
}
}
continue;
}
String u = ph.path("url").asText("").trim();
if (StringUtils.hasText(u)) {
urls.add(u);
}
if (urls.size() >= MAX_PHOTO_URLS) {
break;
}
}
return urls;
}
/**
* 按 {@code contact} 与 {@code customer_management} 对齐:已存在则更新,不存在则插入。
* 店名/地址/电话/三张照片/门店与销售默认值等见 {@link #applyMerchantDataToCustomer};更新时保留主键、创建时间及录音/联系次数。
*/
private int persistMerchantsAsCustomers(List<NearbyMerchantItem> items, String searchCity, String searchAddress) {
int affected = 0;
LocalDateTime now = LocalDateTime.now();
for (NearbyMerchantItem item : items) {
String contact = item.getTel();
if (!StringUtils.hasText(contact)) {
continue;
}
String contactTrim = contact.trim();
try {
LambdaQueryWrapper<CustomerManagement> w = new LambdaQueryWrapper<CustomerManagement>()
.eq(CustomerManagement::getContact, contactTrim)
.last("LIMIT 1");
CustomerManagement existing = customerManagementService.getOne(w, false);
if (existing == null) {
CustomerManagement row = new CustomerManagement();
row.setId(UUID.randomUUID().toString());
applyMerchantDataToCustomer(row, item, contactTrim, searchCity, searchAddress);
row.setRecordingCount(0);
row.setContactCount(0);
row.setCreateTime(now);
row.setUpdateTime(now);
if (customerManagementService.save(row)) {
affected++;
}
} else {
applyMerchantDataToCustomer(existing, item, contactTrim, searchCity, searchAddress);
existing.setUpdateTime(now);
if (customerManagementService.updateById(existing)) {
affected++;
}
}
} catch (Exception e) {
log.warn("写入 customer_management 失败, contact={}: {}", contactTrim, e.getMessage());
}
}
return affected;
}
private static void applyMerchantDataToCustomer(CustomerManagement c, NearbyMerchantItem item, String contactTrim,
String searchCity, String searchAddress) {
String name = item.getName() != null ? item.getName().trim() : "";
c.setCustomerName(name.isEmpty() ? "高德POI" : name);
c.setDetailedAddress(blankToNull(item.getAddress()));
c.setContact(contactTrim);
c.setRemark(remarkFromSearchParams(searchCity, searchAddress));
List<String> urls = item.getPhotoUrls();
if (urls != null && !urls.isEmpty()) {
int n = Math.min(urls.size(), MAX_PHOTO_URLS);
c.setFrontPhotoUrl(n > 0 ? urls.get(0) : null);
c.setSidePhotoUrl(n > 1 ? urls.get(1) : null);
c.setLifePhotoUrl(n > 2 ? urls.get(2) : null);
} else {
c.setFrontPhotoUrl(null);
c.setSidePhotoUrl(null);
c.setLifePhotoUrl(null);
}
c.setCustomerSource("AMAP_GaoDe");
c.setDealershipId(DEFAULT_DEALERSHIP_ID);
c.setDealershipName(DEFAULT_DEALERSHIP_NAME);
c.setSalesId(DEFAULT_SALES_ID);
c.setSalesName(DEFAULT_SALES_NAME);
c.setSalesPhone(DEFAULT_SALES_PHONE);
c.setIntendedModel(DEFAULT_INTENDED_MODEL);
}
/** 接口检索参数 CITY、ADDRESS 两段拼接写入 {@code remark} */
private static String remarkFromSearchParams(String searchCity, String searchAddress) {
String c = searchCity != null ? searchCity.trim() : "";
String a = searchAddress != null ? searchAddress.trim() : "";
if (!StringUtils.hasText(c) && !StringUtils.hasText(a)) {
return null;
}
if (!StringUtils.hasText(c)) {
return a;
}
if (!StringUtils.hasText(a)) {
return c;
}
return c + " " + a;
}
private static String blankToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
/**
* 参考 {@link com.rj.gaode AllShopCollectorV5 parseAndPrint}:每家店为「店名/地址/电话/行业/坐标… + ---------------------------」。
*/
private static String buildDetailedReport(String city, String address, int radiusMeters, String centerLocation,
String totalCount, List<NearbyMerchantItem> merchants) {
StringBuilder sb = new StringBuilder();
sb.append("===== 附近商家检索(输出格式参考 AllShopCollectorV5=====\n");
sb.append("说明:仅保留联系电话(tel)非空的 POI高德 count 为接口原始命中总数。\n");
sb.append("城市(city)").append(city).append('\n');
sb.append("地址(address)").append(address).append('\n');
sb.append("搜索半径(米)").append(radiusMeters).append('\n');
sb.append("中心经纬度(location)").append(centerLocation).append('\n');
sb.append("高德返回总条数(count)").append(totalCount != null ? totalCount : "").append('\n');
sb.append("本响应保留条数(有电话):").append(merchants.size()).append('\n');
sb.append("===== 各商铺信息 =====\n");
int n = 1;
for (NearbyMerchantItem m : merchants) {
sb.append("\n【第 ").append(n++).append(" 家】\n");
sb.append(m.toDetailBlock()).append('\n');
}
if (merchants.isEmpty()) {
sb.append("\n无 POI 结果)\n");
}
return sb.toString();
}
}