| | |
| | | import cn.hutool.core.bean.BeanUtil; |
| | | import cn.hutool.core.collection.CollUtil; |
| | | import cn.hutool.core.util.IdUtil; |
| | | import cn.hutool.json.JSONUtil; |
| | | import com.alibaba.fastjson2.JSONArray; |
| | | import com.alibaba.fastjson2.JSON; |
| | | import com.alibaba.fastjson2.JSONObject; |
| | |
| | | } |
| | | |
| | | @Override |
| | | public XpPointInfoVo getUserQuickCapsule(String uid) { |
| | | public XpPointInfoVo getUserQuickCapsule(String uid, Long startPointId) { |
| | | try { |
| | | // 使用Redis的sorted set(排行榜)获取第一个元素 |
| | | // 获取分数最高的元素(默认按分数从高到低排序) |
| | | Set<Object> result = redisService.redisTemplate.opsForZSet().reverseRange("history_order_" + uid, 0, 0); |
| | | if (result != null && !result.isEmpty()) { |
| | | // 排行榜中存储的是JSON字符串 |
| | | String jsonString = result.iterator().next().toString(); |
| | | // 使用Hutool的JSON工具解析为XpPointInfoVo对象 |
| | | return JSONUtil.toBean(jsonString, XpPointInfoVo.class); |
| | | Long userId = Long.valueOf(uid); |
| | | // 新用户或历史样本过少时直接隐藏胶囊,避免偶发订单干扰推荐。 |
| | | Long validHistoryCount = this.baseMapper.countUserValidHistoryOrders(userId); |
| | | if (validHistoryCount == null || validHistoryCount < 3) { |
| | | return null; |
| | | } |
| | | |
| | | XpPoint startPoint = getPointById(startPointId); |
| | | if (startPoint == null) { |
| | | log.warn("获取用户快捷胶囊失败,起点不存在,uid:{}, startPointId:{}", uid, startPointId); |
| | | return null; |
| | | } |
| | | |
| | | Integer minOrderDistance = getMinOrderDistanceSafely(uid, startPointId); |
| | | if (minOrderDistance == null) { |
| | | return null; |
| | | } |
| | | |
| | | LocalDateTime now = LocalDateTime.now(); |
| | | List<XpOrder> recentOrders = this.baseMapper.listUserRecentValidOrders(userId, now.minusDays(30)); |
| | | if (CollectionUtils.isEmpty(recentOrders)) { |
| | | return null; |
| | | } |
| | | |
| | | // 先走“同日别 + 当前时间前后1小时”的场景推荐,命不中再退化到30天高频。 |
| | | RecommendationCandidate sceneCandidate = buildSceneCandidate(recentOrders, now, startPoint, minOrderDistance); |
| | | if (sceneCandidate != null) { |
| | | return sceneCandidate.getPointInfo(); |
| | | } |
| | | |
| | | RecommendationCandidate fallbackCandidate = buildHighFrequencyCandidate(recentOrders, startPoint, minOrderDistance); |
| | | if (fallbackCandidate != null) { |
| | | return fallbackCandidate.getPointInfo(); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("获取用户快捷胶囊失败: {}", e.getMessage(), e); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private RecommendationCandidate buildSceneCandidate(List<XpOrder> recentOrders, LocalDateTime now, XpPoint startPoint, Integer minOrderDistance) { |
| | | DaySceneType currentSceneType = resolveDaySceneType(now.toLocalDate()); |
| | | Set<Integer> targetHours = buildTargetHours(now.getHour()); |
| | | List<XpOrder> sceneOrders = recentOrders.stream() |
| | | .filter(order -> isSameScene(order, currentSceneType, targetHours)) |
| | | .collect(Collectors.toList()); |
| | | List<RecommendationCandidate> candidates = buildCandidates(sceneOrders, startPoint, minOrderDistance); |
| | | if (candidates.isEmpty()) { |
| | | return null; |
| | | } |
| | | RecommendationCandidate top1 = candidates.get(0); |
| | | int secondCount = candidates.size() > 1 ? candidates.get(1).getCount() : 0; |
| | | // 只有 top1 至少命中2次且严格领先第二名,才认为“场景规律”足够明显。 |
| | | if (top1.getCount() >= 2 && top1.getCount() > secondCount) { |
| | | return top1; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private RecommendationCandidate buildHighFrequencyCandidate(List<XpOrder> recentOrders, XpPoint startPoint, Integer minOrderDistance) { |
| | | List<RecommendationCandidate> candidates = buildCandidates(recentOrders, startPoint, minOrderDistance); |
| | | if (candidates.isEmpty()) { |
| | | return null; |
| | | } |
| | | return candidates.get(0); |
| | | } |
| | | |
| | | private List<RecommendationCandidate> buildCandidates(List<XpOrder> orders, XpPoint startPoint, Integer minOrderDistance) { |
| | | if (CollectionUtils.isEmpty(orders)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | Map<Long, RecommendationCandidate> candidateMap = new HashMap<>(); |
| | | for (XpOrder order : orders) { |
| | | Long endPointId = order.getEndPointId(); |
| | | if (endPointId == null || endPointId.equals(startPoint.getId())) { |
| | | continue; |
| | | } |
| | | // 候选点位在创建阶段就完成“点位存在 + 距离阈值”过滤,后续只累加频次。 |
| | | RecommendationCandidate candidate = candidateMap.computeIfAbsent(endPointId, id -> createCandidate(id, startPoint, minOrderDistance)); |
| | | if (candidate == null || !candidate.isAvailable()) { |
| | | continue; |
| | | } |
| | | candidate.increment(order.getCreateTime()); |
| | | } |
| | | return candidateMap.values().stream() |
| | | .filter(Objects::nonNull) |
| | | .filter(RecommendationCandidate::isAvailable) |
| | | .filter(candidate -> candidate.getCount() > 0) |
| | | .sorted(Comparator.comparingInt(RecommendationCandidate::getCount).reversed() |
| | | .thenComparing(RecommendationCandidate::getLatestCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))) |
| | | .collect(Collectors.toList()); |
| | | } |
| | | |
| | | private RecommendationCandidate createCandidate(Long pointId, XpPoint startPoint, Integer minOrderDistance) { |
| | | XpPoint point = getPointById(pointId); |
| | | if (point == null) { |
| | | return null; |
| | | } |
| | | Double distance = getStraightDistance(startPoint, point); |
| | | if (distance == null || distance <= minOrderDistance) { |
| | | return RecommendationCandidate.unavailable(); |
| | | } |
| | | XpPointInfoVo pointInfoVo = new XpPointInfoVo(); |
| | | pointInfoVo.setId(point.getId()); |
| | | pointInfoVo.setPointName(point.getPointName()); |
| | | pointInfoVo.setPointLon(point.getPointLon()); |
| | | pointInfoVo.setPointLat(point.getPointLat()); |
| | | return RecommendationCandidate.available(pointInfoVo); |
| | | } |
| | | |
| | | private Integer getMinOrderDistanceSafely(String uid, Long startPointId) { |
| | | R<Integer> result = xpSystemSettingClient.getMinOrderDistance(); |
| | | if (result == null || result.getCode() != R.SUCCESS || result.getData() == null) { |
| | | log.error("获取用户快捷胶囊失败,最小下单距离配置异常,uid:{}, startPointId:{}, result:{}", uid, startPointId, JSON.toJSONString(result)); |
| | | return null; |
| | | } |
| | | return result.getData(); |
| | | } |
| | | |
| | | private XpPoint getPointById(Long pointId) { |
| | | if (pointId == null) { |
| | | return null; |
| | | } |
| | | R<XpPoint> pointResult = xpPointClient.queryById(pointId); |
| | | if (pointResult == null || pointResult.getCode() != R.SUCCESS) { |
| | | return null; |
| | | } |
| | | return pointResult.getData(); |
| | | } |
| | | |
| | | private Double getStraightDistance(XpPoint startPoint, XpPoint endPoint) { |
| | | if (startPoint == null || endPoint == null || startPoint.getPointLon() == null || startPoint.getPointLat() == null |
| | | || endPoint.getPointLon() == null || endPoint.getPointLat() == null) { |
| | | return null; |
| | | } |
| | | Map<String, Double> distanceMap = GeodesyUtil.getDistance(startPoint.getPointLon() + "," + startPoint.getPointLat(), |
| | | endPoint.getPointLon() + "," + endPoint.getPointLat()); |
| | | if (distanceMap == null) { |
| | | return null; |
| | | } |
| | | return distanceMap.get("WGS84"); |
| | | } |
| | | |
| | | private boolean isSameScene(XpOrder order, DaySceneType currentSceneType, Set<Integer> targetHours) { |
| | | if (order.getCreateTime() == null || order.getTimeSlot() == null) { |
| | | return false; |
| | | } |
| | | // 场景命中必须同时满足“同类日期”和“当前小时前后1小时窗口”。 |
| | | if (resolveDaySceneType(order.getCreateTime().toLocalDate()) != currentSceneType) { |
| | | return false; |
| | | } |
| | | Integer slotHour = parseTimeSlotStartHour(order.getTimeSlot()); |
| | | return slotHour != null && targetHours.contains(slotHour); |
| | | } |
| | | |
| | | private Integer parseTimeSlotStartHour(String timeSlot) { |
| | | if (StringUtils.isBlank(timeSlot) || !timeSlot.contains("-")) { |
| | | return null; |
| | | } |
| | | try { |
| | | return Integer.valueOf(timeSlot.split("-")[0]); |
| | | } catch (Exception ex) { |
| | | log.warn("解析时段失败,timeSlot:{}", timeSlot); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private Set<Integer> buildTargetHours(int currentHour) { |
| | | Set<Integer> targetHours = new HashSet<>(); |
| | | targetHours.add((currentHour + 23) % 24); |
| | | targetHours.add(currentHour); |
| | | targetHours.add((currentHour + 1) % 24); |
| | | return targetHours; |
| | | } |
| | | |
| | | private DaySceneType resolveDaySceneType(LocalDate date) { |
| | | if (holidayUtil.isHoliday(date)) { |
| | | return DaySceneType.HOLIDAY; |
| | | } |
| | | DayOfWeek dayOfWeek = date.getDayOfWeek(); |
| | | if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { |
| | | return DaySceneType.NON_WORKDAY; |
| | | } |
| | | return DaySceneType.WORKDAY; |
| | | } |
| | | |
| | | private enum DaySceneType { |
| | | WORKDAY, |
| | | NON_WORKDAY, |
| | | HOLIDAY |
| | | } |
| | | |
| | | private static class RecommendationCandidate { |
| | | private final XpPointInfoVo pointInfo; |
| | | private final boolean available; |
| | | private int count; |
| | | private LocalDateTime latestCreateTime; |
| | | |
| | | private RecommendationCandidate(XpPointInfoVo pointInfo, boolean available) { |
| | | this.pointInfo = pointInfo; |
| | | this.available = available; |
| | | } |
| | | |
| | | public static RecommendationCandidate available(XpPointInfoVo pointInfo) { |
| | | return new RecommendationCandidate(pointInfo, true); |
| | | } |
| | | |
| | | public static RecommendationCandidate unavailable() { |
| | | return new RecommendationCandidate(null, false); |
| | | } |
| | | |
| | | public void increment(LocalDateTime createTime) { |
| | | this.count++; |
| | | if (createTime != null && (this.latestCreateTime == null || createTime.isAfter(this.latestCreateTime))) { |
| | | this.latestCreateTime = createTime; |
| | | } |
| | | } |
| | | |
| | | public XpPointInfoVo getPointInfo() { |
| | | return pointInfo; |
| | | } |
| | | |
| | | public boolean isAvailable() { |
| | | return available; |
| | | } |
| | | |
| | | public int getCount() { |
| | | return count; |
| | | } |
| | | |
| | | public LocalDateTime getLatestCreateTime() { |
| | | return latestCreateTime; |
| | | } |
| | | } |
| | | |
| | | @Override |
| | |
| | | // 获取点位信息 |
| | | List<Long> startPointsIds = xpOrders.stream().map(XpOrder::getStartPointId).collect(Collectors.toList()); |
| | | |
| | | R<List<XpHistoryStartPointsVo>> r = xpPointClient.getHistoryStartPoints(new HistoryStartPointsDto(startPointsIds, lon, lat)); |
| | | R<List<XpHistoryStartPointsVo>> r = xpPointClient.getHistoryStartPoints(new HistoryStartPointsDto(startPointsIds, lon, lat,Long.valueOf(uid))); |
| | | |
| | | |
| | | List<XpHistoryStartPointsVo> data = r.getData(); |
| | |
| | | // 获取点位信息 |
| | | List<Long> endPointsIds = xpOrders.stream().map(XpOrder::getEndPointId).collect(Collectors.toList()); |
| | | |
| | | R<List<XpHistoryEndPointsVo>> r = xpPointClient.getHistoryEndPoints(new HistoryEndPointsDto(endPointsIds, lon, lat)); |
| | | R<List<XpHistoryEndPointsVo>> r = xpPointClient.getHistoryEndPoints(new HistoryEndPointsDto(endPointsIds, lon, lat,Long.valueOf(uid))); |
| | | |
| | | List<XpHistoryEndPointsVo> data = r.getData(); |
| | | |
| | |
| | | } |
| | | log.info("订单修改价格推送消息完毕"); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * 获取待支付订单数量 |
| | | * @param uid |
| | | * @return |
| | | */ |
| | | @Override |
| | | public Integer getWaitPayOrderNum(String uid) { |
| | | Long num = this.baseMapper.selectCount( |
| | | new LambdaQueryWrapper<XpOrder>() |
| | | .eq(XpOrder::getAppUserId, uid) |
| | | .eq(XpOrder::getStatus, 8) |
| | | ); |
| | | return num.intValue(); |
| | | } |
| | | } |