Bladeren bron

Merge remote-tracking branch 'origin/dev' into dev

# Conflicts:
#	nightFragrance-massage/src/main/java/com/ylx/home/hot/service/impl/HomeHotRecommendServiceImpl.java
#	nightFragrance-massage/src/main/java/com/ylx/project/mapper/ProjectMapper.java
jinshihui 2 dagen geleden
bovenliggende
commit
30c2c1ae01

+ 11 - 0
nightFragrance-massage/src/main/java/com/ylx/home/hot/domain/vo/ProjectSalesVO.java

@@ -0,0 +1,11 @@
+package com.ylx.home.hot.domain.vo;
+
+import lombok.Data;
+
+@Data
+public class ProjectSalesVO {
+
+    private Integer id;
+
+    private Long salesCount;
+}

+ 133 - 60
nightFragrance-massage/src/main/java/com/ylx/home/hot/service/impl/HomeHotRecommendServiceImpl.java

@@ -3,31 +3,33 @@ package com.ylx.home.hot.service.impl;
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.ylx.common.core.domain.R;
+import com.google.common.collect.Lists;
 import com.ylx.common.core.domain.entity.SysDictData;
 import com.ylx.common.utils.DictUtils;
 import com.ylx.common.utils.DistanceUtil;
 import com.ylx.home.hot.domain.dto.HotRecommendDTO;
 import com.ylx.home.hot.domain.vo.HotRecommendVO;
-//import com.ylx.home.hot.domain.vo.ProjectSalesVO;
+import com.ylx.home.hot.domain.vo.ProjectSalesVO;
 import com.ylx.home.hot.service.HomeHotRecommendService;
 import com.ylx.massage.domain.MaTechnician;
-import com.ylx.massage.mapper.MaTechnicianMapper;
 import com.ylx.massage.service.IMaTechnicianService;
 import com.ylx.merchant.domain.vo.MerchantWithAddressVO;
 import com.ylx.project.domain.Project;
-import com.ylx.project.mapper.ProjectMapper;
 import com.ylx.project.service.ProjectService;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.data.redis.connection.ReturnType;
+import org.springframework.data.redis.core.DefaultTypedTuple;
+import org.springframework.data.redis.core.RedisCallback;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Set;
+import java.time.Duration;
+import java.util.*;
 import java.util.stream.Collectors;
 
 import static com.ylx.order.constant.OrderConstant.DICT_UNIT_TYPE;
@@ -42,23 +44,31 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
     private ProjectService projectService;
     @Resource
     private IMaTechnicianService maTechnicianService;
-    @Resource
-    private MaTechnicianMapper techMapper;
-    @Resource
-    private ProjectMapper projectMapper;
 
     private static final String HOT_MERCHANT_RANK_KEY = "hot:merchant:rank";
     private static final String HOT_PROJECT_RANK_KEY = "hot:project:rank";
 
+    private static final String SYNC_LOCK_KEY = "lock:hot_rank_sync";
+    private static final int BATCH_SIZE = 200;
+
+    private static final Map<String, String> unitDictCache = new HashMap<>();
+
+    static {
+        List<SysDictData> dictList = DictUtils.getSortedDictCache(DICT_UNIT_TYPE);
+        if (CollUtil.isNotEmpty(dictList)) {
+            dictList.forEach(dict -> unitDictCache.put(dict.getDictValue(), dict.getDictLabel()));
+        } else {
+            log.warn("项目计量单位字典未加载,请检查字典配置:{}", DICT_UNIT_TYPE);
+        }
+    }
+
     @Override
     public Page<HotRecommendVO> getHotRecommendPage(HotRecommendDTO dto) {
-
         long pageSize = dto.getSize();
         long pageNum = dto.getCurrent();
         BigDecimal userLng = dto.getLongitude();
         BigDecimal userLat = dto.getLatitude();
 
-
         long offset = (pageNum - 1) * pageSize;
         // 优化:多预取一倍ID,解决交替翻页断层
         long fetchCount = pageSize * 2;
@@ -71,7 +81,7 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
             Set<String> techIdStrSet = redisTemplate.opsForZSet().reverseRange(HOT_MERCHANT_RANK_KEY, offset, end);
             Set<String> projectIdStrSet = redisTemplate.opsForZSet().reverseRange(HOT_PROJECT_RANK_KEY, offset, end);
 
-            // 转Long ID集合
+            // 转ID集合
             List<Integer> techIds = techIdStrSet.stream().map(Integer::valueOf).collect(Collectors.toList());
             List<Integer> projectIds = projectIdStrSet.stream().map(Integer::valueOf).collect(Collectors.toList());
 
@@ -80,9 +90,7 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
             projectList = this.projectService.listByIds(projectIds);
 
             // 3. 内存二次过滤脏数据(缓存不一致兜底)
-            techWithAddrList = techWithAddrList.stream()
-                    .filter(t -> t.getNStar() != null)
-                    .collect(Collectors.toList());
+            techWithAddrList = techWithAddrList.stream().filter(t -> t.getNStar() != null).collect(Collectors.toList());
             projectList = projectList.stream().filter(p -> p.getIsDelete() == 0 && p.getStatus() == 0).collect(Collectors.toList());
         } catch (Exception e) {
             // Redis异常降级,切回纯MySQL分页方案
@@ -98,38 +106,38 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
         // 5. 分页总数
         Long totalTech, totalProject;
         if (!redisDown) {
-            totalTech = redisTemplate.opsForZSet().zCard(HOT_MERCHANT_RANK_KEY);
-            totalProject = redisTemplate.opsForZSet().zCard(HOT_PROJECT_RANK_KEY);
+            totalTech = ObjectUtil.defaultIfNull(redisTemplate.opsForZSet().zCard(HOT_MERCHANT_RANK_KEY), 0L);
+            totalProject = ObjectUtil.defaultIfNull(redisTemplate.opsForZSet().zCard(HOT_PROJECT_RANK_KEY), 0L);
         } else {
             totalTech = this.maTechnicianService.countValidTech();
             totalProject = this.projectService.countValidProject();
         }
         Page<HotRecommendVO> page = new Page<>(dto.getCurrent(), dto.getSize());
         page.setRecords(mixResult);
+        // 注意:混合交替分页total仅作展示,请勿用于前端分页页码计算,存在数据穿插断层
         page.setTotal(totalTech + totalProject);
         return page;
     }
 
     @Override
     public void syncAllHotRank() {
-        /*redisTemplate.delete(HOT_MERCHANT_RANK_KEY);
-        List<MaTechnician> validTechList = techMapper.selectAllValidTech();
-        for (MaTechnician tech : validTechList) {
-            double score = tech.getNNum() != null ? tech.getNNum() : 0;
-            redisTemplate.opsForZSet().add(HOT_MERCHANT_RANK_KEY, tech.getId().toString(), score);
+        long start = System.currentTimeMillis();
+        // 锁超时10分钟,适配大批量同步
+        Boolean locked = redisTemplate.opsForValue().setIfAbsent(SYNC_LOCK_KEY, "1", Duration.ofMinutes(10));
+        if (!Boolean.TRUE.equals(locked)) {
+            log.warn("热门排行同步任务正在执行中,本次跳过");
+            return;
         }
 
-        redisTemplate.delete(HOT_PROJECT_RANK_KEY);
-        List<ProjectSalesVO> allProjectSales = projectMapper.selectAllProjectSalesCount();
-        for (ProjectSalesVO vo : allProjectSales) {
-            Project project = new Project();
-            project.setId(vo.getId());
-            project.setSalesCompleted(vo.getSalesCount());
-            projectMapper.updateById(project);
-            long salesCount = vo.getSalesCount() != null ? vo.getSalesCount() : 0L;
-            redisTemplate.opsForZSet().add(HOT_PROJECT_RANK_KEY, vo.getId().toString(), salesCount);
+        try {
+            syncMerchantRank();
+            calibrateAndSyncProjectRank();
+            log.info("热门排行ZSet全量同步完成,总耗时{}ms", System.currentTimeMillis() - start);
+        } catch (Exception e) {
+            log.error("热门排行同步异常,总耗时{}ms", System.currentTimeMillis() - start, e);
+        } finally {
+            releaseLock(SYNC_LOCK_KEY, "1");
         }
-        log.info("热门排行ZSet全量同步完成,商户数量:{}, 项目数量:{}", validTechList.size(), allProjectSales.size());*/
     }
 
     /**
@@ -160,25 +168,19 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
 
     /**
      * 商户实体转VO(包含距离计算、标签拆分)
-     *
-     * @param tech    商户实体
-     * @param userLat 用户纬度
-     * @param userLng 用户经度
-     * @return 商户卡片VO
      */
     private HotRecommendVO convertTechToVO(MerchantWithAddressVO tech, BigDecimal userLat, BigDecimal userLng) {
         HotRecommendVO vo = new HotRecommendVO();
         vo.setCardType(1);
         vo.setTechId(tech.getId());
-        // 封面优先取形象照,无则头像
         vo.setCoverImg(tech.getAvatar() != null ? tech.getAvatar() : tech.getTeAvatar());
         vo.setTeNickName(tech.getTeNickName());
         vo.setNStar(tech.getNStar());
         vo.setServiceTotal(tech.getNNum());
-        // 计算距离
+        // 距离兜底
         String distance = DistanceUtil.formatDisplay(userLat, userLng, tech.getLatitude(), tech.getLongitude());
-        vo.setDistance(distance);
-        // 拆分标签字符串为集合 te_project="调理,SPA"
+        vo.setDistance(ObjectUtil.isNotEmpty(distance) ? distance : "距离未知");
+
         String tagStr = tech.getTeProject();
         if (tagStr != null && tagStr.trim().length() > 0) {
             List<String> tagList = Arrays.stream(tagStr.split(","))
@@ -192,9 +194,6 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
 
     /**
      * 项目实体转卡片VO
-     *
-     * @param project 项目实体
-     * @return 项目卡片VO
      */
     private HotRecommendVO convertProjectToVO(Project project) {
         HotRecommendVO vo = new HotRecommendVO();
@@ -205,21 +204,95 @@ public class HomeHotRecommendServiceImpl implements HomeHotRecommendService {
         vo.setHighlight(project.getHighlight());
         vo.setPriceMin(project.getPriceMin());
         vo.setSalesCount(project.getSalesCompleted());
-        // 组装时长展示文案:standard_duration + unit_type
-        String durationText;
+
         Integer unitType = project.getUnitType();
-        String unitTypeName = "未知单位";
-        List<SysDictData> dictList = DictUtils.getSortedDictCache(DICT_UNIT_TYPE);
-        if (CollUtil.isNotEmpty(dictList) && ObjectUtil.isNotNull(unitType)) {
-            unitTypeName = dictList.stream()
-                    .filter(dict -> unitType.toString().equals(dict.getDictValue()))
-                    .map(SysDictData::getDictLabel)
-                    .findFirst()
-                    .orElse("未知单位");
-        }
-        durationText = project.getStandardDuration() + unitTypeName;
+        String unitKey = ObjectUtil.isNotNull(unitType) ? unitType.toString() : "";
+        String unitTypeName = unitDictCache.getOrDefault(unitKey, "未知单位");
+        // 时长空值兜底
+        Integer duration = ObjectUtil.defaultIfNull(project.getStandardDuration(), 0);
+        String durationText = duration + unitTypeName;
         vo.setDurationText(durationText);
         return vo;
     }
 
-}
+    /**
+     * 同步商户排行ZSet
+     */
+    private void syncMerchantRank() {
+        List<MaTechnician> validTechList = maTechnicianService.selectAllValidTech();
+        Map<String, Double> scores = validTechList.stream()
+                .collect(Collectors.toMap(
+                        t -> String.valueOf(t.getId()),
+                        t -> t.getNNum() != null ? t.getNNum().doubleValue() : 0D,
+                        (a, b) -> a
+                ));
+        replaceRankZSet(HOT_MERCHANT_RANK_KEY, scores);
+        log.info("商户热门排行同步完成,数量:{}", scores.size());
+    }
+
+    /**
+     * DB校准项目销量 + 同步项目排行ZSet
+     */
+    private void calibrateAndSyncProjectRank() {
+        // 查询最新销量(事务外读取,缩短事务持有时间)
+        List<ProjectSalesVO> allSales = this.projectService.selectAllProjectSalesCount();
+        // DB批量校准(独立事务)
+        batchCalibrateProjectSales(allSales);
+
+        // Redis同步,异常隔离不影响DB数据
+        try {
+            Map<String, Double> scores = allSales.stream()
+                    .collect(Collectors.toMap(
+                            vo -> String.valueOf(vo.getId()),
+                            vo -> vo.getSalesCount() != null ? vo.getSalesCount().doubleValue() : 0D,
+                            (a, b) -> a
+                    ));
+            replaceRankZSet(HOT_PROJECT_RANK_KEY, scores);
+            log.info("项目热门排行同步完成,数量:{}", scores.size());
+        } catch (Exception e) {
+            log.error("项目热门排行Redis同步失败,DB已校准完成,等待下次定时任务修复缓存", e);
+        }
+    }
+
+    /**
+     * 独立事务:批量更新项目销量冗余字段
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void batchCalibrateProjectSales(List<ProjectSalesVO> salesList) {
+        if (CollectionUtils.isEmpty(salesList)) return;
+        Lists.partition(salesList, BATCH_SIZE).forEach(batch -> this.projectService.batchUpdateSales(batch));
+    }
+
+    /**
+     * 通用方法:原子替换排行ZSet,临时key中转避免线上空白列表
+     */
+    private void replaceRankZSet(String rankKey, Map<String, Double> scores) {
+        String tempKey = rankKey + ":temp";
+        redisTemplate.delete(tempKey);
+        if (scores.isEmpty()) {
+            redisTemplate.delete(rankKey);
+            return;
+        }
+        Set<ZSetOperations.TypedTuple<String>> tuples = scores.entrySet().stream()
+                .map(entry -> new DefaultTypedTuple<>(entry.getKey(), entry.getValue()))
+                .collect(Collectors.toSet());
+        redisTemplate.opsForZSet().add(tempKey, tuples);
+        redisTemplate.rename(tempKey, rankKey);
+    }
+
+    /**
+     * Lua脚本原子释放分布式锁,防止误删其他线程锁
+     */
+    private void releaseLock(String lockKey, String expectedValue) {
+        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
+        try {
+            redisTemplate.execute((RedisCallback<Object>) connection -> {
+                byte[] keyBytes = redisTemplate.getStringSerializer().serialize(lockKey);
+                byte[] valBytes = redisTemplate.getStringSerializer().serialize(expectedValue);
+                return connection.eval(script.getBytes(), ReturnType.INTEGER, 1, keyBytes, valBytes);
+            });
+        } catch (Exception e) {
+            log.error("释放分布式锁失败,key={}", lockKey, e);
+        }
+    }
+}

+ 2 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/service/IMaTechnicianService.java

@@ -342,4 +342,6 @@ public interface IMaTechnicianService extends IService<MaTechnician> {
     List<MerchantWithAddressVO> selectTechWithAddressByIds(List<Integer> techIds);
 
     List<MerchantWithAddressVO> listValidTechWithAddress(long offset, long fetchCount);
+
+    List<MaTechnician> selectAllValidTech();
 }

+ 5 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/service/impl/MaTechnicianServiceImpl.java

@@ -1962,6 +1962,11 @@ public class MaTechnicianServiceImpl extends ServiceImpl<MaTechnicianMapper, MaT
         return this.baseMapper.listValidTechWithAddress(offset, fetchCount);
     }
 
+    @Override
+    public List<MaTechnician> selectAllValidTech() {
+        return this.baseMapper.selectAllValidTech();
+    }
+
     @Override
     public Long countValidTech() {
         return this.baseMapper.countValidTech();

+ 4 - 1
nightFragrance-massage/src/main/java/com/ylx/project/mapper/ProjectMapper.java

@@ -2,6 +2,7 @@ package com.ylx.project.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ylx.home.hot.domain.vo.ProjectSalesVO;
 import com.ylx.massage.domain.dto.OptionDTO;
 import com.ylx.massage.domain.dto.ServiceOptionDTO;
 import com.ylx.massage.domain.vo.ProductOptionVO;
@@ -34,5 +35,7 @@ public interface ProjectMapper extends BaseMapper<Project> {
     /**
      * 查询全部上架有效项目及实时完成订单销量
      */
-    //List<ProjectSalesVO> selectAllProjectSalesCount();
+    List<ProjectSalesVO> selectAllProjectSalesCount();
+
+    void batchUpdateSales(@Param("list") List<ProjectSalesVO> list);
 }

+ 5 - 0
nightFragrance-massage/src/main/java/com/ylx/project/service/ProjectService.java

@@ -3,6 +3,7 @@ package com.ylx.project.service;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.ylx.home.hot.domain.vo.ProjectSalesVO;
 import com.ylx.massage.domain.dto.OptionDTO;
 import com.ylx.massage.domain.dto.ServiceOptionDTO;
 import com.ylx.massage.domain.vo.ProductOptionVO;
@@ -44,4 +45,8 @@ public interface ProjectService extends IService<Project> {
     List<Project> selectProjectPage(long offset, long fetchCount);
 
     Long countValidProject();
+
+    void batchUpdateSales(List<ProjectSalesVO> batch);
+
+    List<ProjectSalesVO> selectAllProjectSalesCount();
 }

+ 11 - 0
nightFragrance-massage/src/main/java/com/ylx/project/service/impl/ProjectServiceImpl.java

@@ -14,6 +14,7 @@ import com.ylx.common.core.domain.entity.SysDictData;
 import com.ylx.common.exception.ServiceException;
 import com.ylx.common.utils.DateUtils;
 import com.ylx.common.utils.SecurityUtils;
+import com.ylx.home.hot.domain.vo.ProjectSalesVO;
 import com.ylx.massage.domain.dto.OptionDTO;
 import com.ylx.massage.domain.dto.ServiceOptionDTO;
 import com.ylx.massage.domain.vo.ProductOptionVO;
@@ -261,6 +262,16 @@ public class ProjectServiceImpl extends ServiceImpl<ProjectMapper, Project> impl
         return this.baseMapper.countValidProject();
     }
 
+    @Override
+    public void batchUpdateSales(List<ProjectSalesVO> batch) {
+        this.baseMapper.batchUpdateSales(batch);
+    }
+
+    @Override
+    public List<ProjectSalesVO> selectAllProjectSalesCount() {
+        return this.baseMapper.selectAllProjectSalesCount();
+    }
+
     private ProjectPageVo convertToVo(Project entity) {
         ProjectPageVo vo = new ProjectPageVo();
         // 属性拷贝(推荐)

+ 12 - 0
nightFragrance-massage/src/main/resources/mapper/project/ProjectMapper.xml

@@ -213,5 +213,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         GROUP BY p.id
     </select>-->
 
+    <update id="batchUpdateSales">
+        UPDATE project
+        SET sales_completed = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.salesCount}
+        </foreach>
+        END
+        WHERE id IN
+        <foreach collection="list" item="item" open="(" separator="," close=")">
+            #{item.id}
+        </foreach>
+    </update>
 
 </mapper>