xw 3 дней назад
Родитель
Сommit
2a21508e0e
16 измененных файлов с 919 добавлено и 0 удалено
  1. 94 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreUserEndCategoryScrmController.java
  2. 23 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductUserEndCategory.java
  3. 43 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreUserEndCategoryScrm.java
  4. 18 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStoreProductSortItemDTO.java
  5. 3 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductScrmMapper.java
  6. 27 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductTagRelationScrmMapper.java
  7. 47 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductUserEndCategoryMapper.java
  8. 30 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreUserEndCategoryScrmMapper.java
  9. 43 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreUserEndCategoryScrmService.java
  10. 295 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreUserEndCategoryScrmServiceImpl.java
  11. 19 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductTagNameVO.java
  12. 41 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreUserEndCategoryProductVO.java
  13. 7 0
      fs-service/src/main/resources/mapper/hisStore/FsStoreProductScrmMapper.xml
  14. 36 0
      fs-service/src/main/resources/mapper/hisStore/FsStoreProductTagRelationScrmMapper.xml
  15. 118 0
      fs-service/src/main/resources/mapper/hisStore/FsStoreProductUserEndCategoryMapper.xml
  16. 75 0
      fs-service/src/main/resources/mapper/hisStore/FsStoreUserEndCategoryScrmMapper.xml

+ 94 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreUserEndCategoryScrmController.java

@@ -0,0 +1,94 @@
+package com.fs.hisStore.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import com.fs.hisStore.dto.FsStoreProductSortItemDTO;
+import com.fs.hisStore.service.IFsStoreUserEndCategoryScrmService;
+import com.fs.hisStore.vo.FsStoreUserEndCategoryProductVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 商品用户分端类管理(金刚区/瀑布流)
+ * 支持:分类名称模糊搜索、新增/编辑/删除、排序(用户端最多展示8个,超出不展示)、金刚区需icon/瀑布流不需、状态显示隐藏、一页10条分页、排序相同按创建时间倒序
+ */
+@RestController
+@RequestMapping("/store/store/userEndCategory")
+public class FsStoreUserEndCategoryScrmController extends BaseController {
+
+    @Autowired
+    private IFsStoreUserEndCategoryScrmService userEndCategoryService;
+
+    /** 分页列表,一页10条,排序值相同按创建时间倒序 */
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreUserEndCategoryScrm query) {
+        startPage();
+        List<FsStoreUserEndCategoryScrm> list = userEndCategoryService.selectListPage(query);
+        return getDataTable(list);
+    }
+
+    /** 添加/编辑商品时拉取用户端分类(金刚区、瀑布流各最多8条由前端截取) */
+    @GetMapping("/listForProduct")
+    public R listForProduct(@RequestParam(required = false) Long storeId) {
+        List<FsStoreUserEndCategoryScrm> list = userEndCategoryService.selectListForProduct(storeId);
+        return R.ok().put("data", list);
+    }
+
+    /** 按用户端分类ID分页查询关联商品(去重商品ID分页;返回商品ID、名称、售价、原价、销量及产品标签列表) */
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:query')")
+    @GetMapping("/products")
+    public TableDataInfo listProductsByCategoryId(
+            @RequestParam Long id,
+            @RequestParam(value = "keyword", required = false) String keyword,
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize) {
+        startPage();
+        List<FsStoreUserEndCategoryProductVO> list = userEndCategoryService.listProductsByCategoryId(id,keyword);
+        return getDataTable(list);
+    }
+
+    /** 保存关联商品在当前页的排序(写入 fs_store_product_user_end_category.sort) */
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:edit')")
+    @Log(title = "用户分端类关联商品排序", businessType = BusinessType.UPDATE)
+    @PutMapping("/products/sort")
+    public AjaxResult saveCategoryProductsSort(@RequestParam Long id,
+                                               @RequestBody List<FsStoreProductSortItemDTO> items) {
+        return toAjax(userEndCategoryService.saveProductsSort(id, items));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(userEndCategoryService.selectById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:add')")
+    @Log(title = "用户分端类", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreUserEndCategoryScrm entity) {
+        return toAjax(userEndCategoryService.insert(entity));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:edit')")
+    @Log(title = "用户分端类", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreUserEndCategoryScrm entity) {
+        return toAjax(userEndCategoryService.update(entity));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:remove')")
+    @Log(title = "用户分端类", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(userEndCategoryService.deleteByIds(ids));
+    }
+}

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductUserEndCategory.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 商品与用户分端类关联表 fs_store_product_user_end_category
+ */
+@Data
+public class FsStoreProductUserEndCategory implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 商品ID */
+    private Long productId;
+
+    /** 用户分端类ID */
+    private Long userEndCategoryId;
+
+    /** 在该用户分端类下的排序(仅关联表,非商品表 sort) */
+    private Long sort;
+}

+ 43 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreUserEndCategoryScrm.java

@@ -0,0 +1,43 @@
+package com.fs.hisStore.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 商品用户分端类(金刚区/瀑布流)对象 fs_store_user_end_category
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class FsStoreUserEndCategoryScrm extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 店铺ID */
+    @Excel(name = "店铺ID")
+    private Long storeId;
+
+    /** 分类名称 */
+    @Excel(name = "分类名称")
+    private String categoryName;
+
+    /** 分类位置:1-金刚区 2-瀑布流 */
+    @Excel(name = "分类位置", readConverterExp = "1=金刚区,2=瀑布流")
+    private Integer position;
+
+    /** 分类icon(金刚区必填,瀑布流不填) */
+    @Excel(name = "分类icon")
+    private String icon;
+
+    /** 状态:1-显示 0-隐藏 */
+    @Excel(name = "状态", readConverterExp = "1=显示,0=隐藏")
+    private Integer status;
+
+    /** 排序值,越小越靠前;用户端各位置最多展示前8个 */
+    @Excel(name = "排序")
+    private Long sort;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/hisStore/dto/FsStoreProductSortItemDTO.java

@@ -0,0 +1,18 @@
+package com.fs.hisStore.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 批量更新商品排序项(用户端分类关联商品列表用)
+ */
+@Data
+public class FsStoreProductSortItemDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    /** 排序值,0~9999 */
+    private Long sort;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductScrmMapper.java

@@ -417,6 +417,9 @@ public interface FsStoreProductScrmMapper
 
     List<FsStoreProductScrm> getStoreProductInProductIds(List<Long> productIds);
 
+    /** 按商品ID列表查询,仅返回上线且未删除的商品(is_del=0, is_show=1),用于 App 列表 */
+    List<FsStoreProductScrm> getStoreProductInProductIdsForApp(@Param("productIds") List<Long> productIds);
+
     @Select({"<script> " +
             "SELECT distinct fsp.* FROM fs_store_product_scrm fsp " +
             " left join fs_store_product_attr_value_scrm fspav on fsp.product_id = fspav.product_id  " +

+ 27 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductTagRelationScrmMapper.java

@@ -0,0 +1,27 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.vo.FsStoreProductTagNameVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品-标签关联 Mapper(一个商品最多3个标签)
+ */
+public interface FsStoreProductTagRelationScrmMapper {
+
+    int insertBatch(@Param("productId") Long productId, @Param("tagIds") List<Long> tagIds);
+
+    int deleteByProductId(Long productId);
+
+    List<Long> selectTagIdsByProductId(Long productId);
+
+    /** 统计某标签关联的商品数量 */
+    int countByTagId(Long tagId);
+
+    /** 按标签ID删除关联(删除标签时调用) */
+    int deleteByTagIds(@Param("tagIds") Long[] tagIds);
+
+    /** 按商品ID列表批量查询商品对应的标签名称(关联 fs_store_product_tag_scrm) */
+    List<FsStoreProductTagNameVO> selectProductTagNamesByProductIds(@Param("productIds") List<Long> productIds);
+}

+ 47 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductUserEndCategoryMapper.java

@@ -0,0 +1,47 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreProductUserEndCategory;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品-用户分端类关联 Mapper
+ */
+public interface FsStoreProductUserEndCategoryMapper {
+
+    int insertBatch(@Param("productId") Long productId, @Param("categoryIds") List<Long> categoryIds);
+
+    int deleteByProductId(Long productId);
+
+    List<Long> selectCategoryIdsByProductId(Long productId);
+
+    /** 按用户端分类ID删除关联(删除分类时调用) */
+    int deleteByCategoryIds(@Param("categoryIds") Long[] categoryIds);
+
+    /** 按用户端分类ID查询去重后的商品ID列表(用于分页,配合 PageHelper) */
+    List<Long> selectDistinctProductIdsByCategoryId(@Param("categoryId") Long categoryId, @Param("keyword") String keyword);
+
+    /** 关联表内全部去重商品ID(不按分类,配合 PageHelper 用于「全部」) */
+    List<Long> selectDistinctProductIds(@Param("keyword") String keyword);
+
+    /** 按区域位置查询商品ID:1=金刚区 2=瀑布区,配合 keyword 筛选,支持 storeId */
+    List<Long> selectDistinctProductIdsByPosition(@Param("id") Long id,@Param("storeId") Long storeId, @Param("position") Integer position, @Param("keyword") String keyword);
+
+    /** 更新指定分类下某商品的关联排序 */
+    int updateRelSortByCategoryAndProduct(@Param("userEndCategoryId") Long userEndCategoryId,
+                                          @Param("productId") Long productId,
+                                          @Param("sort") long sort);
+
+    /** 指定分类下,批量查询商品与关联 sort(管理端/App 指定分类列表用) */
+    List<FsStoreProductUserEndCategory> selectSortByCategoryAndProductIds(@Param("userEndCategoryId") Long userEndCategoryId,
+                                                                          @Param("productIds") List<Long> productIds);
+
+    /** 按金刚区/瀑布区聚合:同一商品多条关联时取最大 sort(App 未指定分类时列表展示用) */
+    List<FsStoreProductUserEndCategory> selectAggSortByPositionAndProductIds(@Param("storeId") Long storeId,
+                                                                              @Param("position") Integer position,
+                                                                              @Param("productIds") List<Long> productIds);
+
+    /** 全部分类关联聚合:同一商品多条关联时取最大 sort(App「全部」列表展示用) */
+    List<FsStoreProductUserEndCategory> selectAggSortGlobalAndProductIds(@Param("productIds") List<Long> productIds);
+}

+ 30 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreUserEndCategoryScrmMapper.java

@@ -0,0 +1,30 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品用户分端类 Mapper
+ */
+public interface FsStoreUserEndCategoryScrmMapper {
+
+    FsStoreUserEndCategoryScrm selectById(Long id);
+
+    List<FsStoreUserEndCategoryScrm> selectList(FsStoreUserEndCategoryScrm query);
+
+    int insert(FsStoreUserEndCategoryScrm entity);
+
+    int update(FsStoreUserEndCategoryScrm entity);
+
+    int deleteById(Long id);
+
+    int deleteByIds(Long[] ids);
+
+    /** 用于添加商品时选择:按 position 分组,各取前8条(status=1),排序:sort asc, create_time desc */
+    List<FsStoreUserEndCategoryScrm> selectListForProduct(@Param("storeId") Long storeId);
+
+    /** 用户端展示:按位置取前8条,status=1,排序 sort asc, create_time desc */
+    List<FsStoreUserEndCategoryScrm> selectTop8ByPosition(@Param("storeId") Long storeId, @Param("position") Integer position);
+}

+ 43 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreUserEndCategoryScrmService.java

@@ -0,0 +1,43 @@
+package com.fs.hisStore.service;
+
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import com.fs.hisStore.dto.FsStoreProductSortItemDTO;
+import com.fs.hisStore.vo.FsStoreUserEndCategoryProductVO;
+
+import java.util.List;
+
+/**
+ * 商品用户分端类 Service 接口
+ */
+public interface IFsStoreUserEndCategoryScrmService {
+
+    FsStoreUserEndCategoryScrm selectById(Long id);
+
+    List<FsStoreUserEndCategoryScrm> selectList(FsStoreUserEndCategoryScrm query);
+
+    /** 分页列表:一页10条,排序值相同按创建时间倒序 */
+    List<FsStoreUserEndCategoryScrm> selectListPage(FsStoreUserEndCategoryScrm query);
+
+    /** 添加商品时选择:按金刚区/瀑布流分组,各最多8条(仅展示用由前端截取) */
+    List<FsStoreUserEndCategoryScrm> selectListForProduct(Long storeId);
+
+    /** 用户端首页:按位置(1金刚区 2瀑布流)取前8条,status=1,排序 sort asc, create_time desc */
+    List<FsStoreUserEndCategoryScrm> selectTop8ByPosition(Long storeId, Integer position);
+
+    /** 按用户端分类ID分页查询关联商品(去重商品ID分页,再查商品简表+标签并组装) */
+    List<FsStoreUserEndCategoryProductVO> listProductsByCategoryId(Long categoryId, String keyword);
+
+    /** 批量更新关联排序(写入 fs_store_product_user_end_category.sort,需指定用户分端类 id) */
+    int saveProductsSort(Long userEndCategoryId, List<FsStoreProductSortItemDTO> items);
+
+    /** 首页商品列表:id 为空查全部(分页商品ID后查简表+标签),id 不为空按用户端分类查;position=1金刚区/2瀑布区 时按区域查全部;返回 list+total */
+    java.util.Map<String, Object> listProductsForApp(Long id, String keyword, Integer pageNum, Integer pageSize, Long storeId, Integer position);
+
+    int insert(FsStoreUserEndCategoryScrm entity);
+
+    int update(FsStoreUserEndCategoryScrm entity);
+
+    int deleteById(Long id);
+
+    int deleteByIds(Long[] ids);
+}

+ 295 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreUserEndCategoryScrmServiceImpl.java

@@ -0,0 +1,295 @@
+package com.fs.hisStore.service.impl;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.hisStore.domain.FsStoreProductScrm;
+import com.fs.hisStore.domain.FsStoreProductUserEndCategory;
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import com.fs.hisStore.dto.FsStoreProductSortItemDTO;
+import com.fs.hisStore.mapper.FsStoreProductScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductTagRelationScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductUserEndCategoryMapper;
+import com.fs.hisStore.mapper.FsStoreUserEndCategoryScrmMapper;
+import com.fs.hisStore.service.IFsStoreUserEndCategoryScrmService;
+import com.fs.hisStore.vo.FsStoreProductTagNameVO;
+import com.fs.hisStore.vo.FsStoreUserEndCategoryProductVO;
+import com.github.pagehelper.Page;
+import com.github.pagehelper.PageHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.stream.Collectors;
+
+/**
+ * 商品用户分端类 Service 实现
+ */
+@Service
+public class FsStoreUserEndCategoryScrmServiceImpl implements IFsStoreUserEndCategoryScrmService {
+
+    @Autowired
+    private FsStoreUserEndCategoryScrmMapper mapper;
+
+    @Autowired
+    private FsStoreProductUserEndCategoryMapper productUserEndCategoryMapper;
+
+    @Autowired
+    private FsStoreProductScrmMapper fsStoreProductScrmMapper;
+
+    @Autowired
+    private FsStoreProductTagRelationScrmMapper productTagRelationMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /** Redis key 用于存储固定的好评率随机值 */
+    private static final String POSITIVE_RATING_REDIS_KEY = "product:positiveRating:fixed:";
+    private static final double MIN_RATING = 95.0;
+    private static final double MAX_RATING = 99.9;
+
+    @Override
+    public List<FsStoreUserEndCategoryProductVO> listProductsByCategoryId(Long categoryId, String keyword) {
+        if (categoryId == null) return new ArrayList<>();
+        List<Long> productIds = productUserEndCategoryMapper.selectDistinctProductIdsByCategoryId(categoryId, keyword);
+        if (productIds == null || productIds.isEmpty()) return new ArrayList<>();
+        long total = (productIds instanceof Page) ? ((Page<?>) productIds).getTotal() : productIds.size();
+        int pageNum = (productIds instanceof Page) ? ((Page<?>) productIds).getPageNum() : 1;
+        int pageSize = (productIds instanceof Page) ? ((Page<?>) productIds).getPageSize() : 10;
+        List<FsStoreProductScrm> products = fsStoreProductScrmMapper.getStoreProductInProductIds(productIds);
+        Map<Long, FsStoreProductScrm> productMap = products.stream().collect(Collectors.toMap(FsStoreProductScrm::getProductId, p -> p, (a, b) -> a));
+        List<FsStoreProductTagNameVO> tagNames = productTagRelationMapper.selectProductTagNamesByProductIds(productIds);
+        // 按商品ID分组,每个商品的标签列表按sort排序
+        Map<Long, List<FsStoreProductTagNameVO>> tagVoMap = new LinkedHashMap<>();
+        for (FsStoreProductTagNameVO tn : tagNames) {
+            tagVoMap.computeIfAbsent(tn.getProductId(), k -> new ArrayList<>()).add(tn);
+        }
+        // 转换为标签名称列表,已按sort排序(SQL已排序)
+               Map<Long, List<String>> tagMap = new LinkedHashMap<>();
+        for (Map.Entry<Long, List<FsStoreProductTagNameVO>> entry : tagVoMap.entrySet()) {
+            List<String> tagNameList = entry.getValue().stream()
+                    .map(FsStoreProductTagNameVO::getTagName)
+                    .collect(Collectors.toList());
+            tagMap.put(entry.getKey(), tagNameList);
+        }
+        Map<Long, Long> relSortMap = toRelationSortMap(
+                productUserEndCategoryMapper.selectSortByCategoryAndProductIds(categoryId, productIds));
+        List<FsStoreUserEndCategoryProductVO> result = new ArrayList<>();
+        for (Long pid : productIds) {
+            FsStoreProductScrm p = productMap.get(pid);
+            if (p == null) continue;
+            FsStoreUserEndCategoryProductVO vo = new FsStoreUserEndCategoryProductVO();
+            vo.setProductId(p.getProductId());
+            vo.setProductName(p.getProductName());
+            vo.setImage(p.getImage());
+            vo.setPrice(p.getPrice());
+            vo.setOtPrice(p.getOtPrice());
+            vo.setSales(p.getSales());
+            vo.setSort(relSortMap.getOrDefault(pid, 0L));
+            vo.setTagList(tagMap.getOrDefault(pid, new ArrayList<>()));
+            result.add(vo);
+        }
+        // 包装为 Page 以携带正确的 total,供 getDataTable 使用
+        Page<FsStoreUserEndCategoryProductVO> pageResult = new Page<>(pageNum, pageSize);
+        pageResult.setTotal(total);
+        pageResult.addAll(result);
+        return pageResult;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int saveProductsSort(Long userEndCategoryId, List<FsStoreProductSortItemDTO> items) {
+        if (userEndCategoryId == null || items == null || items.isEmpty()) {
+            return 0;
+        }
+        int n = 0;
+        for (FsStoreProductSortItemDTO item : items) {
+            if (item == null || item.getProductId() == null) {
+                continue;
+            }
+            long s = item.getSort() == null ? 0L : item.getSort();
+            if (s < 0) {
+                s = 0;
+            }
+            if (s > 9999) {
+                s = 9999;
+            }
+            n += productUserEndCategoryMapper.updateRelSortByCategoryAndProduct(userEndCategoryId, item.getProductId(), s);
+        }
+        return n;
+    }
+
+    @Override
+    public Map<String, Object> listProductsForApp(Long id, String keyword, Integer pageNum, Integer pageSize, Long storeId, Integer position) {
+        Map<String, Object> out = new HashMap<>();
+        out.put("list", new ArrayList<FsStoreUserEndCategoryProductVO>());
+        out.put("total", 0L);
+        if (pageNum == null || pageSize == null || pageSize <= 0) return out;
+        PageHelper.startPage(pageNum, pageSize);
+        List<Long> productIds;
+        if (position == null) {
+            position = 1;
+        }
+        if (position == 1 || position == 2) {
+            productIds = productUserEndCategoryMapper.selectDistinctProductIdsByPosition(id,storeId, position, keyword);
+        } else if (id != null && id != 0L) {
+            productIds = productUserEndCategoryMapper.selectDistinctProductIdsByCategoryId(id, keyword);
+        } else {
+            productIds = productUserEndCategoryMapper.selectDistinctProductIds(keyword);
+        }
+        long total = productIds instanceof Page ? ((Page<?>) productIds).getTotal() : (productIds != null ? productIds.size() : 0);
+        if (productIds == null || productIds.isEmpty()) {
+            out.put("total", total);
+            return out;
+        }
+        List<FsStoreProductScrm> products = fsStoreProductScrmMapper.getStoreProductInProductIdsForApp(productIds);
+        Map<Long, FsStoreProductScrm> productMap = products.stream().collect(Collectors.toMap(FsStoreProductScrm::getProductId, p -> p, (a, b) -> a));
+        List<FsStoreProductTagNameVO> tagNames = productTagRelationMapper.selectProductTagNamesByProductIds(productIds);
+        // 按商品ID分组,每个商品的标签列表按sort排序
+        Map<Long, List<FsStoreProductTagNameVO>> tagVoMap = new LinkedHashMap<>();
+        for (FsStoreProductTagNameVO tn : tagNames) {
+            tagVoMap.computeIfAbsent(tn.getProductId(), k -> new ArrayList<>()).add(tn);
+        }
+        // 转换为标签名称列表,已按sort排序(SQL已排序)
+        Map<Long, List<String>> tagMap = new LinkedHashMap<>();
+        for (Map.Entry<Long, List<FsStoreProductTagNameVO>> entry : tagVoMap.entrySet()) {
+            List<String> tagNameList = entry.getValue().stream()
+                    .map(FsStoreProductTagNameVO::getTagName)
+                    .collect(Collectors.toList());
+            tagMap.put(entry.getKey(), tagNameList);
+        }
+        Map<Long, Long> relSortMap = relationSortMapForApp(id, storeId, position, productIds);
+        List<FsStoreUserEndCategoryProductVO> result = new ArrayList<>();
+        for (Long pid : productIds) {
+            FsStoreProductScrm p = productMap.get(pid);
+            if (p == null) continue;
+            FsStoreUserEndCategoryProductVO vo = new FsStoreUserEndCategoryProductVO();
+            vo.setProductId(p.getProductId());
+            vo.setProductName(p.getProductName());
+            vo.setImage(p.getImage());
+            vo.setPrice(p.getPrice());
+            vo.setOtPrice(p.getOtPrice());
+            vo.setSales(p.getSales());
+            vo.setSort(relSortMap.getOrDefault(pid, 0L));
+            vo.setTagList(tagMap.getOrDefault(pid, new ArrayList<>()));
+            vo.setPositiveRating(getFixedPositiveRating(p.getProductId()));
+            result.add(vo);
+        }
+        out.put("list", result);
+        out.put("total", total);
+        return out;
+    }
+
+    @Override
+    public FsStoreUserEndCategoryScrm selectById(Long id) {
+        return mapper.selectById(id);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectList(FsStoreUserEndCategoryScrm query) {
+        return mapper.selectList(query);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectListPage(FsStoreUserEndCategoryScrm query) {
+        return mapper.selectList(query);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectListForProduct(Long storeId) {
+        return mapper.selectListForProduct(storeId);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectTop8ByPosition(Long storeId, Integer position) {
+        return mapper.selectTop8ByPosition(storeId, position);
+    }
+
+    @Override
+    public int insert(FsStoreUserEndCategoryScrm entity) {
+        entity.setCreateTime(DateUtils.getNowDate());
+        entity.setUpdateTime(DateUtils.getNowDate());
+        return mapper.insert(entity);
+    }
+
+    @Override
+    public int update(FsStoreUserEndCategoryScrm entity) {
+        entity.setUpdateTime(DateUtils.getNowDate());
+        return mapper.update(entity);
+    }
+
+    @Override
+    public int deleteById(Long id) {
+        productUserEndCategoryMapper.deleteByCategoryIds(new Long[]{id});
+        return mapper.deleteById(id);
+    }
+
+    @Override
+    public int deleteByIds(Long[] ids) {
+        if (ids == null || ids.length == 0) return 0;
+        productUserEndCategoryMapper.deleteByCategoryIds(ids);
+        return mapper.deleteByIds(ids);
+    }
+
+    private Map<Long, Long> relationSortMapForApp(Long categoryId, Long storeId, Integer position, List<Long> productIds) {
+        if (productIds == null || productIds.isEmpty()) {
+            return new HashMap<>();
+        }
+        if (position != null && (position == 1 || position == 2)) {
+            if (categoryId != null && categoryId != 0L) {
+                return toRelationSortMap(
+                        productUserEndCategoryMapper.selectSortByCategoryAndProductIds(categoryId, productIds));
+            }
+            return toRelationSortMap(
+                    productUserEndCategoryMapper.selectAggSortByPositionAndProductIds(storeId, position, productIds));
+        }
+        if (categoryId != null && categoryId != 0L) {
+            return toRelationSortMap(
+                    productUserEndCategoryMapper.selectSortByCategoryAndProductIds(categoryId, productIds));
+        }
+        return toRelationSortMap(productUserEndCategoryMapper.selectAggSortGlobalAndProductIds(productIds));
+    }
+
+    private static Map<Long, Long> toRelationSortMap(List<FsStoreProductUserEndCategory> rows) {
+        Map<Long, Long> m = new HashMap<>();
+        if (rows == null) {
+            return m;
+        }
+        for (FsStoreProductUserEndCategory row : rows) {
+            if (row.getProductId() == null) {
+                continue;
+            }
+            m.put(row.getProductId(), row.getSort() != null ? row.getSort() : 0L);
+        }
+        return m;
+    }
+
+    /**
+     * 获取固定的好评率随机值
+     * 先从 Redis 获取,如果不存在则生成随机值并保存到 Redis(永久保存)
+     * @return 固定的好评率值
+     */
+    private BigDecimal getFixedPositiveRating(Long productId) {
+        // 先从 Redis 获取
+        BigDecimal cachedRating = redisCache.getCacheObject(POSITIVE_RATING_REDIS_KEY + productId);
+        if (cachedRating != null) {
+            return cachedRating;
+        }
+        
+        // Redis 不存在,生成随机值
+        double rating = ThreadLocalRandom.current().nextDouble(MIN_RATING, MAX_RATING);
+        BigDecimal fixedRating = new BigDecimal(rating).setScale(1, RoundingMode.HALF_UP);
+        
+        // 保存到 Redis(永久保存,不设置过期时间)
+        redisCache.setCacheObject(POSITIVE_RATING_REDIS_KEY + productId, fixedRating);
+        
+        return fixedRating;
+    }
+}

+ 19 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductTagNameVO.java

@@ -0,0 +1,19 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 商品-标签名称(用于按商品ID批量查标签)
+ */
+@Data
+public class FsStoreProductTagNameVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    private String tagName;
+    /** 排序值 */
+    private Long sort;
+}

+ 41 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreUserEndCategoryProductVO.java

@@ -0,0 +1,41 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 用户端分类下的商品项:商品ID、名称、售价、原价、销量、产品标签列表
+ */
+@Data
+public class FsStoreUserEndCategoryProductVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    private String productName;
+    /** 主图(App 列表用) */
+    private String image;
+    /** 实际销售价格 */
+    private BigDecimal price;
+    /** 原价 */
+    private BigDecimal otPrice;
+    /** 好评率 */
+    private BigDecimal positiveRating;
+    /** 销量 */
+    private Long sales;
+    /** 在用户分端类下的展示排序(fs_store_product_user_end_category.sort;App 聚合场景可能为多条关联中的最大 sort) */
+    private Long sort;
+    /** 产品标签名称列表 */
+    private List<String> tagList;
+    
+    /**
+     * 设置好评率(从外部传入固定值)
+     * @param rating 好评率值
+     */
+    public void setPositiveRating(BigDecimal rating) {
+        this.positiveRating = rating;
+    }
+}

+ 7 - 0
fs-service/src/main/resources/mapper/hisStore/FsStoreProductScrmMapper.xml

@@ -557,4 +557,11 @@
         #{item}
     </foreach>
     </select>
+
+    <select id="getStoreProductInProductIdsForApp" resultType="com.fs.hisStore.domain.FsStoreProductScrm">
+        <include refid="selectFsStoreProductVo"/>
+        where is_del = 0 and is_show = 1 and is_audit = 1
+        and product_id IN
+        <foreach collection="productIds" index="index" item="item" open="(" separator="," close=")">#{item}</foreach>
+    </select>
 </mapper>

+ 36 - 0
fs-service/src/main/resources/mapper/hisStore/FsStoreProductTagRelationScrmMapper.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.hisStore.mapper.FsStoreProductTagRelationScrmMapper">
+
+    <insert id="insertBatch">
+        insert into fs_store_product_tag_relation_scrm (product_id, tag_id) values
+        <foreach collection="tagIds" item="tid" separator=",">(#{productId}, #{tid})</foreach>
+    </insert>
+
+    <delete id="deleteByProductId">
+        delete from fs_store_product_tag_relation_scrm where product_id = #{productId}
+    </delete>
+
+    <select id="selectTagIdsByProductId" resultType="java.lang.Long">
+        select tag_id from fs_store_product_tag_relation_scrm where product_id = #{productId}
+    </select>
+
+    <select id="countByTagId" resultType="int">
+        select count(*) from fs_store_product_tag_relation_scrm where tag_id = #{tagId}
+    </select>
+
+    <delete id="deleteByTagIds">
+        delete from fs_store_product_tag_relation_scrm where tag_id in
+        <foreach collection="tagIds" item="id" open="(" separator="," close=")">#{id}</foreach>
+    </delete>
+
+    <select id="selectProductTagNamesByProductIds" resultType="com.fs.hisStore.vo.FsStoreProductTagNameVO">
+        select r.product_id as productId, t.tag_name as tagName, t.sort as sort
+        from fs_store_product_tag_relation_scrm r
+        left join fs_store_product_tag_scrm t on r.tag_id = t.id
+        where r.product_id in
+        <foreach collection="productIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
+        and t.status = 1
+        order by t.sort asc
+    </select>
+</mapper>

+ 118 - 0
fs-service/src/main/resources/mapper/hisStore/FsStoreProductUserEndCategoryMapper.xml

@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="UTF-8" ?>

+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

+<mapper namespace="com.fs.hisStore.mapper.FsStoreProductUserEndCategoryMapper">

+

+    <insert id="insertBatch">

+        insert into fs_store_product_user_end_category (product_id, user_end_category_id, sort) values

+        <foreach collection="categoryIds" item="cid" separator=",">(#{productId}, #{cid}, 0)</foreach>

+    </insert>

+

+    <delete id="deleteByProductId">

+        delete from fs_store_product_user_end_category where product_id = #{productId}

+    </delete>

+

+    <select id="selectCategoryIdsByProductId" resultType="java.lang.Long">

+        select user_end_category_id from fs_store_product_user_end_category where product_id = #{productId}

+    </select>

+

+    <delete id="deleteByCategoryIds">

+        delete from fs_store_product_user_end_category where user_end_category_id in

+        <foreach collection="categoryIds" item="id" open="(" separator="," close=")">#{id}</foreach>

+    </delete>

+

+    <!-- 排序使用关联表 a.sort;GROUP BY 避免同一商品多分类时 DISTINCT+ORDER BY 不稳定 -->

+    <select id="selectDistinctProductIdsByCategoryId" resultType="java.lang.Long">

+        select t.product_id from (

+        select a.product_id,

+               min(case when a.sort is null or a.sort = 0 then 1 else 0 end) as o1,

+               max(case when a.sort is null or a.sort = 0 then 0 else a.sort end) as o2,

+               max(c.create_time) as ct

+        from fs_store_product_user_end_category a

+        left join fs_store_product_scrm c on a.product_id = c.product_id

+        where a.user_end_category_id = #{categoryId} and c.is_del = 0 and c.is_show = 1 and c.is_display = 1 and c.is_audit = 1

+        <if test="keyword != null and keyword != ''">

+            and c.product_name like CONCAT('%', #{keyword}, '%')

+        </if>

+        group by a.product_id

+        ) t

+        order by t.o1 asc, t.o2 desc, t.ct desc, t.product_id desc

+    </select>

+

+    <select id="selectDistinctProductIds" resultType="java.lang.Long">

+        select t.product_id from (

+        select a.product_id,

+               min(case when a.sort is null or a.sort = 0 then 1 else 0 end) as o1,

+               max(case when a.sort is null or a.sort = 0 then 0 else a.sort end) as o2,

+               max(c.create_time) as ct

+        from fs_store_product_user_end_category  a

+        left join fs_store_product_scrm c on a.product_id = c.product_id

+        where c.is_del = 0 and c.is_show = 1 and c.is_display = 1 and c.is_audit = 1

+        <if test="keyword != null and keyword != ''">

+            and c.product_name like CONCAT('%', #{keyword}, '%')

+        </if>

+        group by a.product_id

+        ) t

+        order by t.o1 asc, t.o2 desc, t.ct desc, t.product_id desc

+    </select>

+

+    <!-- 按区域位置(1金刚区 2瀑布区)查询商品ID,支持 keyword、storeId;排序为关联表 a.sort -->

+    <select id="selectDistinctProductIdsByPosition" resultType="java.lang.Long">

+        select t.product_id from (

+        select a.product_id,

+               min(case when a.sort is null or a.sort = 0 then 1 else 0 end) as o1,

+               max(case when a.sort is null or a.sort = 0 then 0 else a.sort end) as o2,

+               max(c.create_time) as ct

+        from fs_store_product_user_end_category a

+        left join fs_store_product_scrm c on a.product_id = c.product_id

+        left join fs_store_user_end_category_scrm uec on a.user_end_category_id = uec.id

+        where uec.status = 1 and uec.position = #{position}

+        and c.is_del = 0 and c.is_show = 1 and c.is_display = 1 and c.is_audit = 1

+        <if test="id != null and id != 0">and a.user_end_category_id = #{id}</if>

+        <if test="storeId != null">and uec.store_id = #{storeId}</if>

+        <if test="keyword != null and keyword != ''">

+            and c.product_name like CONCAT('%', #{keyword}, '%')

+        </if>

+        group by a.product_id

+        ) t

+        order by t.o1 asc, t.o2 desc, t.ct desc, t.product_id desc

+    </select>

+

+    <update id="updateRelSortByCategoryAndProduct">

+        update fs_store_product_user_end_category

+        set sort = #{sort}

+        where user_end_category_id = #{userEndCategoryId} and product_id = #{productId}

+    </update>

+

+    <select id="selectSortByCategoryAndProductIds" resultType="com.fs.hisStore.domain.FsStoreProductUserEndCategory">

+        select product_id, user_end_category_id, sort

+        from fs_store_product_user_end_category

+        where user_end_category_id = #{userEndCategoryId}

+        and product_id in

+        <foreach collection="productIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>

+    </select>

+

+    <select id="selectAggSortByPositionAndProductIds" resultType="com.fs.hisStore.domain.FsStoreProductUserEndCategory">

+        select a.product_id,

+               max(case when a.sort is null or a.sort = 0 then 0 else a.sort end) as sort

+        from fs_store_product_user_end_category a

+        left join fs_store_product_scrm c on a.product_id = c.product_id

+        left join fs_store_user_end_category_scrm uec on a.user_end_category_id = uec.id

+        where uec.status = 1 and uec.position = #{position}

+        and c.is_del = 0 and c.is_show = 1 and c.is_display = 1 and c.is_audit = 1

+        <if test="storeId != null">and uec.store_id = #{storeId}</if>

+        and a.product_id in

+        <foreach collection="productIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>

+        group by a.product_id

+    </select>

+

+    <select id="selectAggSortGlobalAndProductIds" resultType="com.fs.hisStore.domain.FsStoreProductUserEndCategory">

+        select a.product_id,

+               max(case when a.sort is null or a.sort = 0 then 0 else a.sort end) as sort

+        from fs_store_product_user_end_category a

+        left join fs_store_product_scrm c on a.product_id = c.product_id

+        where c.is_del = 0 and c.is_show = 1 and c.is_display = 1 and c.is_audit = 1

+        and a.product_id in

+        <foreach collection="productIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>

+        group by a.product_id

+    </select>

+</mapper>


+ 75 - 0
fs-service/src/main/resources/mapper/hisStore/FsStoreUserEndCategoryScrmMapper.xml

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.hisStore.mapper.FsStoreUserEndCategoryScrmMapper">
+
+    <resultMap id="BaseResultMap" type="com.fs.hisStore.domain.FsStoreUserEndCategoryScrm">
+        <id property="id" column="id"/>
+        <result property="storeId" column="store_id"/>
+        <result property="categoryName" column="category_name"/>
+        <result property="position" column="position"/>
+        <result property="icon" column="icon"/>
+        <result property="status" column="status"/>
+        <result property="sort" column="sort"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <sql id="Base_Column_List">id, store_id, category_name, position, icon, status, sort, create_time, update_time</sql>
+
+    <select id="selectById" resultMap="BaseResultMap">
+        select <include refid="Base_Column_List"/> from fs_store_user_end_category_scrm where id = #{id}
+    </select>
+
+    <select id="selectList" resultMap="BaseResultMap">
+        select <include refid="Base_Column_List"/> from fs_store_user_end_category_scrm
+        <where>
+            <if test="storeId != null">and store_id = #{storeId}</if>
+            <if test="categoryName != null and categoryName != ''">and category_name like concat('%', #{categoryName}, '%')</if>
+            <if test="position != null">and position = #{position}</if>
+            <if test="status != null">and status = #{status}</if>
+        </where>
+        order by sort asc, create_time desc
+    </select>
+
+    <select id="selectListForProduct" resultMap="BaseResultMap">
+        select <include refid="Base_Column_List"/> from fs_store_user_end_category_scrm
+        where status = 1
+        <if test="storeId != null">and store_id = #{storeId}</if>
+        order by position asc, sort asc, create_time desc
+    </select>
+
+    <select id="selectTop8ByPosition" resultMap="BaseResultMap">
+        select <include refid="Base_Column_List"/> from fs_store_user_end_category_scrm
+        where status = 1 and position = #{position}
+        <if test="storeId != null">and store_id = #{storeId}</if>
+        order by sort asc, create_time desc
+        limit 8
+    </select>
+
+    <insert id="insert" parameterType="com.fs.hisStore.domain.FsStoreUserEndCategoryScrm" useGeneratedKeys="true" keyProperty="id">
+        insert into fs_store_user_end_category_scrm (store_id, category_name, position, icon, status, sort, create_time, update_time)
+        values (#{storeId}, #{categoryName}, #{position}, #{icon}, #{status}, #{sort}, #{createTime}, #{updateTime})
+    </insert>
+
+    <update id="update" parameterType="com.fs.hisStore.domain.FsStoreUserEndCategoryScrm">
+        update fs_store_user_end_category_scrm
+        <set>
+            <if test="categoryName != null">category_name = #{categoryName},</if>
+            <if test="position != null">position = #{position},</if>
+            <if test="icon != null">icon = #{icon},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="sort != null">sort = #{sort},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </set>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from fs_store_user_end_category_scrm where id = #{id}
+    </delete>
+
+    <delete id="deleteByIds">
+        delete from fs_store_user_end_category_scrm where id in
+        <foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach>
+    </delete>
+</mapper>