|
|
@@ -3,6 +3,7 @@ package shop.alien.store.controller;
|
|
|
import com.alibaba.fastjson2.JSON;
|
|
|
import com.alibaba.fastjson2.JSONArray;
|
|
|
import com.alibaba.fastjson2.JSONObject;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
|
import io.swagger.annotations.Api;
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
@@ -20,18 +21,29 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
import org.springframework.web.bind.annotation.RestController;
|
|
|
import org.springframework.web.client.RestTemplate;
|
|
|
import shop.alien.entity.result.R;
|
|
|
+import shop.alien.entity.store.CommonRating;
|
|
|
import shop.alien.entity.store.LifeBlacklist;
|
|
|
import shop.alien.entity.store.StoreImg;
|
|
|
import shop.alien.entity.store.StoreUser;
|
|
|
+import shop.alien.entity.store.vo.StoreBannerVo;
|
|
|
+import shop.alien.entity.store.vo.StoreBusinessStatusVo;
|
|
|
import shop.alien.entity.store.vo.StoreInfoVo;
|
|
|
+import shop.alien.mapper.CommonRatingMapper;
|
|
|
import shop.alien.mapper.LifeBlacklistMapper;
|
|
|
import shop.alien.mapper.StoreImgMapper;
|
|
|
import shop.alien.mapper.StoreUserMapper;
|
|
|
import shop.alien.store.annotation.TrackEvent;
|
|
|
import shop.alien.store.service.CommonRatingService;
|
|
|
+import shop.alien.store.service.StoreBannerService;
|
|
|
import shop.alien.store.service.StoreImgService;
|
|
|
+import shop.alien.store.service.StoreInfoService;
|
|
|
+import shop.alien.store.util.ai.AiAuthTokenUtil;
|
|
|
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
import java.util.*;
|
|
|
+import java.util.concurrent.CompletableFuture;
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
|
|
|
|
@@ -55,9 +67,13 @@ public class AiSearchController {
|
|
|
|
|
|
private final RestTemplate restTemplate;
|
|
|
private final StoreImgService storeImgService;
|
|
|
+ private final StoreBannerService storeBannerService;
|
|
|
private final CommonRatingService commonRatingService;
|
|
|
+ private final CommonRatingMapper commonRatingMapper;
|
|
|
+ private final StoreInfoService storeInfoService;
|
|
|
|
|
|
private final LifeBlacklistMapper lifeBlacklistMapper;
|
|
|
+ private final AiAuthTokenUtil aiAuthTokenUtil;
|
|
|
|
|
|
@TrackEvent(
|
|
|
eventType = "SEARCH",
|
|
|
@@ -72,23 +88,25 @@ public class AiSearchController {
|
|
|
// 初始化请求体Map
|
|
|
Map<String, Object> requestBody = new HashMap<>();
|
|
|
requestBody.put("query", map.get("storeName"));
|
|
|
- requestBody.put("limit", map.get("pageSize"));
|
|
|
+ requestBody.put("page_size", map.get("pageSize"));
|
|
|
requestBody.put("user_lat", map.get("lat"));
|
|
|
requestBody.put("user_lng", map.get("lon"));
|
|
|
requestBody.put("category", map.get("category"));
|
|
|
requestBody.put("page", map.get("pageNum"));
|
|
|
requestBody.put("sort_by", map.get("sortBy"));
|
|
|
+ requestBody.put("sort_order", "desc");
|
|
|
HttpHeaders aiHeaders = new HttpHeaders();
|
|
|
+ String accessToken = aiAuthTokenUtil.getAccessToken();
|
|
|
aiHeaders.setContentType(MediaType.APPLICATION_JSON);
|
|
|
-// aiHeaders.set("Authorization", "Bearer " + accessToken);
|
|
|
+ aiHeaders.set("Authorization", "Bearer " + accessToken);
|
|
|
// aiHeaders.set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1cHN0b3JlQGFkbWluLmNvbSIsImlkIjo2LCJ0aW1lIjoxNzYyOTI1NDAzLjY1MTY5MjZ9.07lz8Ox2cGC28UCmqcKCt5R6Rfwtgs-Eiu0ttgWRxws");
|
|
|
|
|
|
- HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, null);
|
|
|
+ HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, aiHeaders);
|
|
|
try {
|
|
|
-
|
|
|
+ log.info("调用AI检索店铺列表最上面根据店铺名查询 接口入参------{}", requestBody);
|
|
|
ResponseEntity<String> stringResponseEntity = restTemplate.postForEntity(aiSearchExactUrl, request, String.class);
|
|
|
String body = stringResponseEntity.getBody();
|
|
|
- log.info("调用AI检索 处理前v 接口返回------{}", body);
|
|
|
+ log.info("调用AI检索店铺列表最上面根据店铺名查询 接口返回------{}", body);
|
|
|
JSONObject jsonObject = JSONObject.parseObject(body);
|
|
|
JSONObject jsonObject1 = new JSONObject();
|
|
|
// 生活服务类别:转换为StoreInfoVo,确保返回的字段名按照StoreInfoVo定义
|
|
|
@@ -96,9 +114,14 @@ public class AiSearchController {
|
|
|
List<StoreInfoVo> relatedResult = convertToStoreInfoList(jsonObject.getJSONArray("related_results"),map.get("userId"));
|
|
|
List<StoreInfoVo> matchedResult = convertToStoreInfoList(jsonObject.getJSONArray("matched_results"),map.get("userId"));
|
|
|
|
|
|
- // 查找图片并设置到result中(图片类型1-入口图)
|
|
|
- fillStoreImages(relatedResult, 1);
|
|
|
- fillStoreImages(matchedResult, 1);
|
|
|
+ // 并发处理图片和营业时间,提升性能
|
|
|
+ CompletableFuture<Void> relatedImageFuture = CompletableFuture.runAsync(() -> fillStoreImages(relatedResult, 1));
|
|
|
+ CompletableFuture<Void> matchedImageFuture = CompletableFuture.runAsync(() -> fillStoreImages(matchedResult, 1));
|
|
|
+ CompletableFuture<Void> relatedBusinessHoursFuture = CompletableFuture.runAsync(() -> fillBusinessHours(relatedResult));
|
|
|
+ CompletableFuture<Void> matchedBusinessHoursFuture = CompletableFuture.runAsync(() -> fillBusinessHours(matchedResult));
|
|
|
+
|
|
|
+ // 等待所有任务完成
|
|
|
+ CompletableFuture.allOf(relatedImageFuture, matchedImageFuture, relatedBusinessHoursFuture, matchedBusinessHoursFuture).join();
|
|
|
|
|
|
// 合并两个列表
|
|
|
// List<StoreInfoVo> relatedResults = new ArrayList<>();
|
|
|
@@ -107,11 +130,19 @@ public class AiSearchController {
|
|
|
// relatedResults.addAll(relatedResult);
|
|
|
jsonObject1.put("matchedRecords", matchedResult);
|
|
|
jsonObject1.put("relatedRecords", relatedResult);
|
|
|
+ // 根据matchedResult中的business_section_name去插入到storeBanners中
|
|
|
+ List<String> sectionNames = matchedResult.stream()
|
|
|
+ .map(StoreInfoVo::getBusinessSectionName)
|
|
|
+ .filter(StringUtils::isNotBlank)
|
|
|
+ .distinct()
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<StoreBannerVo> storeBanners = storeBannerService.getBannerByAISearch(sectionNames);
|
|
|
+ jsonObject1.put("storeBanners", storeBanners);
|
|
|
|
|
|
|
|
|
jsonObject1.put("total", jsonObject.get("total"));
|
|
|
jsonObject1.put("size", map.get("pageSize"));
|
|
|
- log.info("调用AI搜索接口 接口返回------{}", body);
|
|
|
+ log.info("调用AI搜索店铺列表最上面根据店铺名查询 后端处理后数据接口返回------{}", jsonObject1);
|
|
|
return R.data(jsonObject1);
|
|
|
} catch (Exception e) {
|
|
|
log.error("调用AI搜索接口 接口异常------", e);
|
|
|
@@ -124,41 +155,54 @@ public class AiSearchController {
|
|
|
// 初始化请求体Map
|
|
|
Map<String, Object> requestBody = new HashMap<>();
|
|
|
requestBody.put("query", map.get("storeName"));
|
|
|
- requestBody.put("limit", map.get("pageSize"));
|
|
|
+ requestBody.put("page_size", map.get("pageSize"));
|
|
|
requestBody.put("user_lat", map.get("lat"));
|
|
|
requestBody.put("user_lng", map.get("lon"));
|
|
|
requestBody.put("category", map.get("category"));
|
|
|
requestBody.put("page", map.get("pageNum"));
|
|
|
requestBody.put("sort_by", map.get("sortBy"));
|
|
|
+ requestBody.put("sort_order", "desc");
|
|
|
HttpHeaders aiHeaders = new HttpHeaders();
|
|
|
+ String accessToken = aiAuthTokenUtil.getAccessToken();
|
|
|
aiHeaders.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ aiHeaders.set("Authorization", "Bearer " + accessToken);
|
|
|
|
|
|
- HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, null);
|
|
|
+ HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, aiHeaders);
|
|
|
try {
|
|
|
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault());
|
|
|
+ log.info("调用AI首页店铺列表搜索接口 请求参数------{} AI开始时间: {}", requestBody, formatter.format(Instant.now()));
|
|
|
ResponseEntity<String> stringResponseEntity = restTemplate.postForEntity(aiSearchFuzzyUrl, request, String.class);
|
|
|
String body = stringResponseEntity.getBody();
|
|
|
- log.info("调用AI列表接口 处理前v 接口返回------{}", body);
|
|
|
+ log.info("调用AI首页店铺列表搜索接口 AI接口返回------{} AI结束时间: {}", body, formatter.format(Instant.now()));
|
|
|
JSONObject jsonObject = JSONObject.parseObject(body);
|
|
|
JSONObject jsonObject1 = new JSONObject();
|
|
|
- // 模糊搜索:从related_results和matched_results字段获取数据
|
|
|
- List<StoreInfoVo> result = convertToStoreInfoList(jsonObject.getJSONArray("results"),map.get("userId"));
|
|
|
-
|
|
|
- // 查找图片并设置到result中(图片类型1-入口图)
|
|
|
- fillStoreImages(result, 1);
|
|
|
+ // 模糊搜索:从 results 字段获取数据(AI 服务返回格式)
|
|
|
+ List<StoreInfoVo> result = convertToStoreInfoList(jsonObject.getJSONArray("results"), map.get("userId"));
|
|
|
|
|
|
- // 填充评论总数
|
|
|
- fillRatingCount(result);
|
|
|
+ // 并发处理图片、评论数据和营业时间,提升性能
|
|
|
+ CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> fillStoreImages(result, 1));
|
|
|
+ CompletableFuture<Void> ratingFuture = CompletableFuture.runAsync(() -> fillRatingCountBatch(result));
|
|
|
+ CompletableFuture<Void> businessHoursFuture = CompletableFuture.runAsync(() -> fillBusinessHours(result));
|
|
|
+
|
|
|
+ CompletableFuture.allOf(imageFuture, ratingFuture, businessHoursFuture).join();
|
|
|
|
|
|
jsonObject1.put("records", result);
|
|
|
-
|
|
|
jsonObject1.put("total", jsonObject.get("total"));
|
|
|
jsonObject1.put("size", map.get("pageSize"));
|
|
|
- log.info("调用AI模糊搜索接口 接口返回------{}", body);
|
|
|
+ log.info("调用AI首页店铺列表搜索 Java数据处理后接口返回------{} 最终时间: {}", jsonObject1, formatter.format(Instant.now()));
|
|
|
return R.data(jsonObject1);
|
|
|
+ } catch (org.springframework.web.client.HttpServerErrorException e) {
|
|
|
+ String responseBody = e.getResponseBodyAsString();
|
|
|
+ log.error("调用AI模糊搜索接口 上游返回5xx------ status={}, body={}", e.getStatusCode(), responseBody, e);
|
|
|
+ // Meilisearch 报 store_application_status is not filterable 时,需在 AI 搜索服务或 Meilisearch 索引中将 store_application_status 加入 filterableAttributes,或去掉该过滤条件
|
|
|
+ if (responseBody != null && responseBody.contains("store_application_status") && responseBody.contains("not filterable")) {
|
|
|
+ log.error("Meilisearch 不可用 store_application_status 筛选:请在第三方 AI 搜索服务或 Meilisearch 索引配置中将 store_application_status 加入 filterableAttributes,或移除该过滤条件。参见 docs/ai-search-meilisearch-filterable.md");
|
|
|
+ }
|
|
|
+ return R.fail("搜索服务暂时不可用,请稍后重试");
|
|
|
} catch (Exception e) {
|
|
|
log.error("调用AI模糊搜索接口 接口异常------", e);
|
|
|
}
|
|
|
- return R.fail("请求失败");
|
|
|
+ return R.fail("请求失败");
|
|
|
}
|
|
|
|
|
|
private List<StoreInfoVo> convertToStoreInfoList(JSONArray results, String userId) {
|
|
|
@@ -203,24 +247,7 @@ public class AiSearchController {
|
|
|
if(collect.contains(storeInfo.getId())){
|
|
|
continue;
|
|
|
}
|
|
|
- Integer totalCount = 0;
|
|
|
- double storeScore;
|
|
|
- Object ratingObj = commonRatingService.getRatingCount(storeInfo.getId(), 1);
|
|
|
- if (ratingObj != null) {
|
|
|
- Map<String, Object> ratingMap = (Map<String, Object>) ratingObj;
|
|
|
- Object totalCountObj = ratingMap.get("totalCount");
|
|
|
- if (totalCountObj != null) {
|
|
|
- // 安全转换为整数
|
|
|
- try {
|
|
|
- totalCount = Integer.parseInt(totalCountObj.toString().trim());
|
|
|
- } catch (NumberFormatException e) {
|
|
|
- totalCount = 0; // 转换失败时默认值
|
|
|
- }
|
|
|
- } else {
|
|
|
- totalCount = 0;
|
|
|
- }
|
|
|
- }
|
|
|
- storeInfo.setTotalNum(totalCount.toString());
|
|
|
+ // 移除这里的getRatingCount调用,统一在fillRatingCountBatch中批量处理
|
|
|
storeInfoList.add(storeInfo);
|
|
|
}
|
|
|
}
|
|
|
@@ -282,34 +309,117 @@ public class AiSearchController {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 填充评论总数到StoreInfoVo列表中
|
|
|
+ * 填充评论总数到StoreInfoVo列表中(批量查询优化版本)
|
|
|
+ * 使用批量查询替代N+1查询,大幅提升性能
|
|
|
*
|
|
|
* @param result StoreInfoVo列表
|
|
|
*/
|
|
|
- private void fillRatingCount(List<StoreInfoVo> result) {
|
|
|
+ private void fillRatingCountBatch(List<StoreInfoVo> result) {
|
|
|
if (result == null || result.isEmpty()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ // 提取所有storeId
|
|
|
+ List<Integer> storeIdList = result.stream()
|
|
|
+ .map(StoreInfoVo::getId)
|
|
|
+ .filter(id -> id != null)
|
|
|
+ .distinct()
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ if (storeIdList.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 批量查询所有store的评价记录(只查询审核通过且展示的)
|
|
|
+ LambdaQueryWrapper<CommonRating> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.in(CommonRating::getBusinessId, storeIdList)
|
|
|
+ .eq(CommonRating::getBusinessType, 1)
|
|
|
+ .eq(CommonRating::getAuditStatus, 1)
|
|
|
+ .eq(CommonRating::getIsShow, 1);
|
|
|
+ List<CommonRating> allRatings = commonRatingMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ // 按businessId分组统计评论总数
|
|
|
+ Map<Integer, Long> ratingCountMap = allRatings.stream()
|
|
|
+ .collect(Collectors.groupingBy(
|
|
|
+ CommonRating::getBusinessId,
|
|
|
+ Collectors.counting()
|
|
|
+ ));
|
|
|
+
|
|
|
+ // 设置评论总数到对应的StoreInfoVo
|
|
|
+ for (StoreInfoVo storeInfo : result) {
|
|
|
+ if (storeInfo.getId() != null) {
|
|
|
+ Long count = ratingCountMap.get(storeInfo.getId());
|
|
|
+ storeInfo.setTotalNum(count != null ? String.valueOf(count) : "0");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("批量获取评论总数失败", e);
|
|
|
+ // 如果批量查询失败,回退到单个查询(兜底策略)
|
|
|
+ for (StoreInfoVo storeInfo : result) {
|
|
|
+ if (storeInfo.getId() != null) {
|
|
|
+ try {
|
|
|
+ Object ratingCountObj = commonRatingService.getRatingCount(storeInfo.getId(), 1);
|
|
|
+ if (ratingCountObj instanceof Map) {
|
|
|
+ Map<String, Object> ratingCountMap = (Map<String, Object>) ratingCountObj;
|
|
|
+ Object totalCount = ratingCountMap.get("totalCount");
|
|
|
+ if (totalCount != null) {
|
|
|
+ storeInfo.setTotalNum(String.valueOf(totalCount));
|
|
|
+ } else {
|
|
|
+ storeInfo.setTotalNum("0");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ storeInfo.setTotalNum("0");
|
|
|
+ }
|
|
|
+ } catch (Exception ex) {
|
|
|
+ log.warn("获取评论总数失败,storeId: {}", storeInfo.getId(), ex);
|
|
|
+ storeInfo.setTotalNum("0");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量填充营业时间到StoreInfoVo列表中
|
|
|
+ * 通过调用storeInfoService.getStoreBusinessStatus方法获取营业时间
|
|
|
+ *
|
|
|
+ * @param result StoreInfoVo列表
|
|
|
+ */
|
|
|
+ private void fillBusinessHours(List<StoreInfoVo> result) {
|
|
|
+ if (result == null || result.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 遍历result集合,为每个门店调用getStoreBusinessStatus方法
|
|
|
for (StoreInfoVo storeInfo : result) {
|
|
|
if (storeInfo.getId() != null) {
|
|
|
try {
|
|
|
- // 调用评论服务获取评论总数,businessId传id,businessType传1
|
|
|
- Object ratingCountObj = commonRatingService.getRatingCount(storeInfo.getId(), 1);
|
|
|
-
|
|
|
- // 将返回的Object转换为Map
|
|
|
- if (ratingCountObj instanceof Map) {
|
|
|
- Map<String, Object> ratingCountMap = (Map<String, Object>) ratingCountObj;
|
|
|
- // 从map中取出totalCount字段
|
|
|
- Object totalCount = ratingCountMap.get("totalCount");
|
|
|
- if (totalCount != null) {
|
|
|
- // 赋值给totalNum字段(转为String类型)
|
|
|
- storeInfo.setTotalNum(String.valueOf(totalCount));
|
|
|
+ // 调用getStoreBusinessStatus方法获取营业时间
|
|
|
+ StoreBusinessStatusVo businessStatus = storeInfoService.getStoreBusinessStatus(String.valueOf(storeInfo.getId()));
|
|
|
+ if (businessStatus != null) {
|
|
|
+ // 设置营业时间信息到StoreInfoVo中
|
|
|
+ if (businessStatus.getStoreBusinessInfos() != null && !businessStatus.getStoreBusinessInfos().isEmpty()) {
|
|
|
+ storeInfo.setStoreBusinessInfos(businessStatus.getStoreBusinessInfos());
|
|
|
+ // 同时设置到openTime字段(格式化为字符串列表)
|
|
|
+ List<String> openTimeList = businessStatus.getStoreBusinessInfos().stream()
|
|
|
+ .map(info -> {
|
|
|
+ if (info.getBusinessDate() != null && info.getStartTime() != null && info.getEndTime() != null) {
|
|
|
+ return info.getBusinessDate() + " " + info.getStartTime() + "-" + info.getEndTime();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ })
|
|
|
+ .filter(time -> time != null)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ storeInfo.setOpenTime(openTimeList);
|
|
|
+ }
|
|
|
+ // 设置营业状态
|
|
|
+ if (businessStatus.getYyFlag() != null) {
|
|
|
+ storeInfo.setYyFlag(businessStatus.getYyFlag());
|
|
|
}
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
- log.warn("获取评论总数失败,storeId: {}", storeInfo.getId(), e);
|
|
|
- // 如果获取失败,继续处理下一个,不影响其他数据
|
|
|
+ log.error("获取门店营业时间失败,storeId: {}", storeInfo.getId(), e);
|
|
|
}
|
|
|
}
|
|
|
}
|