把客户信息补充到数据库

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("centerLocation", search.getCenterLocation());
result.put("totalCount", search.getTotalCount()); result.put("totalCount", search.getTotalCount());
result.put("data", search.getMerchants()); result.put("data", search.getMerchants());
result.put("detailedReport", search.getDetailedReport());
result.put("customerInsertedCount", search.getCustomerInsertedCount());
} }
return ResponseEntity.status(search.getHttpStatus()).body(result); return ResponseEntity.status(search.getHttpStatus()).body(result);
} }

View File

@@ -4,8 +4,10 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List;
/** /**
* 高德周边 POI 中的一条商家记录(与 {@code AllShopCollectorV5} 解析字段一致)。 * 高德周边 POI 中的一条商家记录(与 {@code AllShopCollectorV5} 核心字段一致,并补充 distance 等 POI 字段)。
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@@ -18,4 +20,53 @@ public class NearbyMerchantItem {
private String type; private String type;
/** 经纬度,格式 "lng,lat" */ /** 经纬度,格式 "lng,lat" */
private String location; 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 List<NearbyMerchantItem> merchants;
/** 高德返回的本次检索总条数(字符串),可能大于已拉取页数之和 */ /** 高德返回的本次检索总条数(字符串),可能大于已拉取页数之和 */
private final String totalCount; 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, 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.httpStatus = httpStatus;
this.success = success; this.success = success;
this.message = message; this.message = message;
this.centerLocation = centerLocation; this.centerLocation = centerLocation;
this.merchants = merchants == null ? Collections.emptyList() : merchants; this.merchants = merchants == null ? Collections.emptyList() : merchants;
this.totalCount = totalCount; this.totalCount = totalCount;
this.detailedReport = detailedReport;
this.customerInsertedCount = customerInsertedCount;
} }
public static NearbyMerchantSearchResult ok(String centerLocation, List<NearbyMerchantItem> merchants, String totalCount) { public static NearbyMerchantSearchResult ok(String centerLocation, List<NearbyMerchantItem> merchants,
return new NearbyMerchantSearchResult(200, true, "查询成功", centerLocation, merchants, totalCount); String totalCount, String detailedReport, int customerInsertedCount) {
return new NearbyMerchantSearchResult(200, true, "查询成功", centerLocation, merchants, totalCount,
detailedReport, customerInsertedCount);
} }
public static NearbyMerchantSearchResult badRequest(String message) { 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) { 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; package com.rj.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.rj.config.AmapProperties; import com.rj.config.AmapProperties;
import com.rj.dto.NearbyMerchantItem; import com.rj.dto.NearbyMerchantItem;
import com.rj.dto.NearbyMerchantSearchResult; import com.rj.dto.NearbyMerchantSearchResult;
import com.rj.entity.CustomerManagement;
import com.rj.service.ICustomerManagementService;
import com.rj.service.INearbyMerchantSearchService; import com.rj.service.INearbyMerchantSearchService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -14,11 +17,13 @@ import org.springframework.web.client.RestTemplate;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* 逻辑参考 {@link com.rj.gaode.AllShopCollectorV5}:地理编码 + place/around 全行业周边搜索。 * 逻辑参考 {@link com.rj.gaode AllShopCollectorV5}:地理编码 + place/around 全行业周边搜索。
*/ */
@Slf4j @Slf4j
@Service @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 String AROUND_URL = "https://restapi.amap.com/v3/place/around";
private static final int AMAP_OFFSET_MAX = 50; private static final int AMAP_OFFSET_MAX = 50;
private static final int AMAP_PAGE_DELAY_MS = 800; 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 RestTemplate restTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final AmapProperties amapProperties; private final AmapProperties amapProperties;
private final ICustomerManagementService customerManagementService;
public NearbyMerchantSearchServiceImpl(RestTemplate restTemplate, public NearbyMerchantSearchServiceImpl(RestTemplate restTemplate,
ObjectMapper objectMapper, ObjectMapper objectMapper,
AmapProperties amapProperties) { AmapProperties amapProperties,
ICustomerManagementService customerManagementService) {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.amapProperties = amapProperties; this.amapProperties = amapProperties;
this.customerManagementService = customerManagementService;
} }
@Override @Override
@@ -76,12 +94,21 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
JsonNode pois = root.path("pois"); JsonNode pois = root.path("pois");
if (pois.isArray()) { if (pois.isArray()) {
for (JsonNode p : pois) { for (JsonNode p : pois) {
String tel = text(p, "tel").trim();
if (!StringUtils.hasText(tel)) {
continue;
}
all.add(new NearbyMerchantItem( all.add(new NearbyMerchantItem(
text(p, "name"), text(p, "name"),
text(p, "address"), text(p, "address"),
text(p, "tel"), tel,
text(p, "type"), 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) { } catch (IllegalArgumentException e) {
return NearbyMerchantSearchResult.badRequest(e.getMessage()); return NearbyMerchantSearchResult.badRequest(e.getMessage());
} catch (Exception e) { } 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)} 处理中文查询参数。 * 使用 {@link URLEncoder#encode(String, java.nio.charset.Charset)} 处理中文查询参数。
* Spring {@code UriComponentsBuilder} 的编码规则与 {@code URLEncoder} 不一致,易导致高德返回 ENGINE_RESPONSE_DATA_ERROR如 infocode=30001 * Spring {@code UriComponentsBuilder} 的编码规则与 {@code URLEncoder} 不一致,易导致高德返回 ENGINE_RESPONSE_DATA_ERROR如 infocode=30001
* 首次失败后不传 {@code city},仅用合并后的完整地址再请求一次。 * 首次失败后不传 {@code city},仅用合并后的完整地址再请求一次。
@@ -185,6 +220,7 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
+ "&radius=" + radius + "&radius=" + radius
+ "&offset=" + offset + "&offset=" + offset
+ "&page=" + page + "&page=" + page
+ "&extensions=all"
+ "&output=json"; + "&output=json";
String body = restTemplate.getForObject(URI.create(url), String.class); String body = restTemplate.getForObject(URI.create(url), String.class);
if (body == null) { if (body == null) {
@@ -199,4 +235,158 @@ public class NearbyMerchantSearchServiceImpl implements INearbyMerchantSearchSer
} }
return node.get(field).asText(""); 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();
}
} }