Просмотр исходного кода

fix(security): 统一商品可见性过滤,堵住供应商专属商品跨体系泄露

背景:用户115(非Enagic体系)在 /api/recommend/list 推荐商品中可见商品29
(TDS调节净水直饮水机,vendor_visible=1,distribution_system_id=3)。
根因:除 ProductService.list 外,多个面向用户的商品查询入口缺少
vendor_visible / min_visible_level 过滤,逻辑散落且不一致。

变更:
- 新增公共组件 ProductVisibilitySupport(applyToQuery 下推过滤 +
  VisibilityContext/isVisibleTo 供 selectById 路径内存判断)
- 接入 10 个商品查询入口:recommend/list、recommend/dimension-products、
  related-products(商品/文章/内容关联)、profile/my、growth/recommendations、
  recommend/search、ai/chat、product/combo-tiers、shop/category/products
- ContentRecommendService 内联过滤重构为调用公共组件

测试:新增 8 个测试类(组件 10 例 + 各入口断言),相关回归 14 类 48 例全绿
Xiaogang Liao 1 день назад
Родитель
Сommit
df401ba65a
22 измененных файлов с 1436 добавлено и 59 удалено
  1. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthController.java
  2. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java
  3. 3 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java
  4. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java
  5. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ContentRecommendService.java
  6. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecommendationService.java
  7. 9 11
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductCategoryService.java
  8. 35 19
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
  9. 8 3
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  10. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java
  11. 19 14
      cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java
  12. 7 3
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java
  13. 160 0
      cfc-backend/src/main/java/com/etotem/cfc/service/support/ProductVisibilitySupport.java
  14. 216 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ContentRecommendProductVisibilityTest.java
  15. 86 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/GrowthRecommendationProductVisibilityTest.java
  16. 99 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductCategoryProductsVisibilityTest.java
  17. 233 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductRecommendationFilterTest.java
  18. 94 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductServiceListByDimensionVisibilityTest.java
  19. 214 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductVisibilitySupportTest.java
  20. 108 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendServiceImplQueryProductsVisibilityTest.java
  21. 19 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationNutritionTagTest.java
  22. 100 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationSearchProductVisibilityTest.java

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthController.java

@@ -23,9 +23,10 @@ public class GrowthController {
     @PostMapping("/recommendations")
     public Result<Map<String, Object>> getRecommendations(
             @RequestParam(defaultValue = "health") String category,
-            @RequestParam(defaultValue = "3") int limit) {
+            @RequestParam(defaultValue = "3") int limit,
+            @RequestAttribute(value = "userId", required = false) Long userId) {
         List<Map<String, Object>> articles = growthRecommendationService.getRecommendedArticles(category, limit);
-        List<Map<String, Object>> products = growthRecommendationService.getRecommendedProducts(category, limit);
+        List<Map<String, Object>> products = growthRecommendationService.getRecommendedProducts(category, limit, userId);
         Map<String, Object> data = new HashMap<>();
         data.put("articles", articles);
         data.put("products", products);

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java

@@ -22,7 +22,7 @@ public class AdminProfileController {
             return Result.error("缺少memberId参数");
         }
         Map<String, Object> profile = profileReadService.getProfile(memberId);
-        Map<String, Object> recs = recommendService.getRecommendations(memberId);
+        Map<String, Object> recs = recommendService.getRecommendations(memberId, null);
         profile.putAll(recs);
         return Result.success(profile);
     }

+ 3 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java

@@ -37,7 +37,7 @@ public class ProcurementTaskController {
 
         // === 高端:五维全覆盖,价格区间较高 ===
         Map<String, List<Product>> highProducts = productService.listByDimensions(
-                Arrays.asList("body", "mind", "wisdom", "action", "wealth"), 2);
+                Arrays.asList("body", "mind", "wisdom", "action", "wealth"), 2, userId);
         List<Long> highSkuIds = buildSkuIdsFromProducts(highProducts);
         tiers.add(buildTier("high", "尊享全家幸福包",
                 "涵盖身、智、富、行、心五大维度的完整幸福提案,精选优质产品,助您全家安享福贵双全",
@@ -45,14 +45,14 @@ public class ProcurementTaskController {
 
         // === 中端:身+智+行,中等价位 ===
         Map<String, List<Product>> mediumProducts = productService.listByDimensions(
-                Arrays.asList("body", "wisdom", "action"), 2);
+                Arrays.asList("body", "wisdom", "action"), 2, userId);
         List<Long> mediumSkuIds = buildSkuIdsFromProducts(mediumProducts);
         tiers.add(buildTier("medium", "平衡成长包",
                 "精选2-3个核心维度的关键产品,性价比高,适合大多数家庭的起步配置",
                 mediumProducts, mediumSkuIds, 10000, 80000));
 
         // === 低端:单一维度,入门价位 ===
-        List<Product> lowProducts = productService.listByDimension("body", null, null, 3);
+        List<Product> lowProducts = productService.listByDimension("body", null, null, 3, userId);
         List<Long> lowSkuIds = lowProducts.stream().map(p -> p.getId()).collect(java.util.stream.Collectors.toList());
         tiers.add(buildTier("low", "基础启动包",
                 "单一维度必需品,入门首选,让您以最低成本体验核心关怀",

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java

@@ -26,7 +26,8 @@ public class ProfileController {
             return Result.error("未登录");
         }
         Map<String, Object> profile = profileReadService.getProfile(memberId);
-        Map<String, Object> recommendations = recommendService.getRecommendations(memberId);
+        Long currentUserId = (Long) request.getAttribute("userId");
+        Map<String, Object> recommendations = recommendService.getRecommendations(memberId, currentUserId);
         profile.putAll(recommendations);
         return Result.success(profile);
     }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContentRecommendService.java

@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.stream.Collectors;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
 
@@ -57,6 +58,15 @@ public class ContentRecommendService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private ProductVisibilitySupport productVisibilitySupport;
+
+    @Resource
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
     /**
      * 通用推荐入口
      */
@@ -271,6 +281,8 @@ private List<RecommendItemVO> recommendProducts(String dimensionCode, Long userI
                 .or().eq(Product::getDomain, dim));
         }
 
+        // 商品可见性过滤(min_visible_level + vendor_visible),复用公共组件(与 ProductService.list 对齐)
+        productVisibilitySupport.applyToQuery(wrapper, userId, null);
         SortUtil.applySort(wrapper);
         List<Product> allProducts = productMapper.selectList(wrapper);
         if (allProducts.isEmpty()) return new ArrayList<>();

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecommendationService.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.mapper.ArticleMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -25,6 +26,9 @@ public class GrowthRecommendationService {
     @Resource
     private AIService aiService;
 
+    @Resource
+    private ProductVisibilitySupport productVisibilitySupport;
+
     /** 获取成长板块推荐文章(按 growth_category 过滤,取精选) */
     public List<Map<String, Object>> getRecommendedArticles(String growthCategory, int limit) {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
@@ -47,12 +51,14 @@ public class GrowthRecommendationService {
     }
 
     /** 获取成长板块推荐商品(按 growth_category 过滤,取在售) */
-    public List<Map<String, Object>> getRecommendedProducts(String growthCategory, int limit) {
+    public List<Map<String, Object>> getRecommendedProducts(String growthCategory, int limit, Long userId) {
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
                 .eq(Product::getStatus, "on_shelf")
                 .orderByAsc(Product::getSortOrder)
                 .orderByDesc(Product::getCreatedAt)
                 .last("LIMIT " + limit);
+        // 商品可见性过滤(min_visible_level + vendor_visible)
+        productVisibilitySupport.applyToQuery(wrapper, userId, null);
         List<Product> products = productMapper.selectList(wrapper);
         return products.stream().map(p -> {
             Map<String, Object> m = new HashMap<>();

+ 9 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/ProductCategoryService.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductCategory;
 import com.etotem.cfc.mapper.ProductCategoryMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -24,6 +25,9 @@ public class ProductCategoryService {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private ProductVisibilitySupport productVisibilitySupport;
+
     public Result<List<Map<String, Object>>> tree(Boolean includeDisabled) {
         return tree(includeDisabled, null);
     }
@@ -110,20 +114,14 @@ public class ProductCategoryService {
 
     /** 按会员级别过滤分类下商品:userId 为空时不过滤(管理端/无限制),非空时仅返回用户级别可见的商品 */
     public Result<List<Map<String, Object>>> products(Long categoryId, Long userId) {
-        List<Product> products = productMapper.selectList(
-            new LambdaQueryWrapper<Product>()
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
                 .eq(Product::getCategoryId, categoryId)
                 .eq(Product::getStatus, "on_shelf")
                 .orderByAsc(Product::getSortOrder)
-                .orderByDesc(Product::getCreatedAt)
-        );
-        if (userId != null) {
-            List<String> visibleLevelCodes = membershipService.getVisibleMemberLevelCodes(userId);
-            products = products.stream()
-                .filter(p -> p.getMinVisibleLevel() == null || p.getMinVisibleLevel().isEmpty()
-                    || visibleLevelCodes.contains(p.getMinVisibleLevel()))
-                .collect(Collectors.toList());
-        }
+                .orderByDesc(Product::getCreatedAt);
+        // 商品可见性过滤(min_visible_level + vendor_visible),与商城列表 /api/product/list 对齐
+        productVisibilitySupport.applyToQuery(wrapper, userId, null);
+        List<Product> products = productMapper.selectList(wrapper);
         List<Map<String, Object>> result = products.stream().map(p -> {
             Map<String, Object> m = new LinkedHashMap<>();
             m.put("id", p.getId());

+ 35 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -9,6 +9,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.stream.Collectors;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 import com.etotem.cfc.util.SortUtil;
 
 /**
@@ -70,6 +71,9 @@ public class ProductRecommendationService {
     @Resource
     private ContentDimensionThresholdService contentDimensionThresholdService;
 
+    @Resource
+    private ProductVisibilitySupport productVisibilitySupport;
+
     /**
      * 维度页推荐
      *
@@ -116,13 +120,14 @@ public class ProductRecommendationService {
                     .collect(Collectors.toList());
 
             if (!mappedProductIds.isEmpty()) {
-                List<Product> mapped = productMapper.selectList(
-                        new LambdaQueryWrapper<Product>()
+                LambdaQueryWrapper<Product> mappedWrapper = new LambdaQueryWrapper<Product>()
                         .in(Product::getId, mappedProductIds)
-                                .eq(Product::getStatus, "on_shelf")
-                                .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
-                                .orderByAsc(Product::getSortOrder)
-                );
+                        .eq(Product::getStatus, "on_shelf")
+                        .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
+                        .orderByAsc(Product::getSortOrder);
+                // 商品可见性过滤(min_visible_level + vendor_visible),与商城列表 /api/product/list 对齐
+                productVisibilitySupport.applyToQuery(mappedWrapper, userId, null);
+                List<Product> mapped = productMapper.selectList(mappedWrapper);
 
                 HealthFilterService.FilterResult mappedFilter = healthFilterService.filterProducts(healthProfile, mapped);
                 mapped = mappedFilter.allowed;
@@ -182,6 +187,8 @@ public class ProductRecommendationService {
                 domainQw.notIn(Product::getId, purchasedIds);
             }
             SortUtil.applySort(domainQw);
+            // 商品可见性过滤(min_visible_level + vendor_visible)
+            productVisibilitySupport.applyToQuery(domainQw, userId, null);
             List<Product> domainMatched = productMapper.selectList(domainQw);
              HealthFilterService.FilterResult domainFilter = healthFilterService.filterProducts(healthProfile, domainMatched);
              domainMatched = domainFilter.allowed;
@@ -206,7 +213,7 @@ public class ProductRecommendationService {
 
         // 4. 关联关系推荐(动态:画像缺口、共购、复购)
         List<Map<String, Object>> assocResults = queryAssociationRecommendations(
-                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size(), healthProfile);
+                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size(), healthProfile, userId);
 
         // 合并并去重(关联推荐优先级高于维度映射,但保留维度的reason/matchScore)
         if (assocResults != null && !assocResults.isEmpty()) {
@@ -242,17 +249,19 @@ public class ProductRecommendationService {
     private List<Map<String, Object>> queryAssociationRecommendations(
             String dimensionCode, Long familyId, Long memberId,
             Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
-            HealthFilterService.HealthProfile healthProfile) {
+            HealthFilterService.HealthProfile healthProfile, Long userId) {
 
         if (reqCount <= 0) return Collections.emptyList();
 
+        // 商品可见性上下文(min_visible_level + vendor_visible),一次性查库供各关联子方法复用
+        ProductVisibilitySupport.VisibilityContext visibilityCtx = productVisibilitySupport.buildContext(userId, null);
         Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
         List<Map<String, Object>> results = new ArrayList<>();
 
         // 4.1 画像缺口关联:维度得分低时推荐对应商品
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
             List<Map<String, Object>> profileAssocs = queryProfileGapAssociations(
-                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3, healthProfile);
+                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3, healthProfile, visibilityCtx);
             results.addAll(profileAssocs);
             if (results.size() >= reqCount) return results.subList(0, reqCount);
         }
@@ -260,7 +269,7 @@ public class ProductRecommendationService {
         // 4.2 共购关联:买了A的用户也买了B(从订单数据挖掘)
         if (purchasedIds != null && !purchasedIds.isEmpty()) {
             List<Map<String, Object>> coPurchaseAssocs = queryCoPurchaseAssociations(
-                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile);
+                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile, visibilityCtx);
             results.addAll(coPurchaseAssocs);
             if (results.size() >= reqCount) return results.subList(0, reqCount);
         }
@@ -268,7 +277,7 @@ public class ProductRecommendationService {
         // 4.3 复购关联:买了A后X天再买A的关联商品
         if (purchasedIds != null && !purchasedIds.isEmpty()) {
             List<Map<String, Object>> repurchaseAssocs = queryRepurchaseAssociations(
-                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile);
+                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile, visibilityCtx);
             results.addAll(repurchaseAssocs);
         }
 
@@ -281,7 +290,8 @@ public class ProductRecommendationService {
     private List<Map<String, Object>> queryProfileGapAssociations(
             String dimensionCode, Map<String, Integer> memberScores,
             Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
-            HealthFilterService.HealthProfile healthProfile) {
+            HealthFilterService.HealthProfile healthProfile,
+            ProductVisibilitySupport.VisibilityContext visibilityCtx) {
 
         if (reqCount <= 0) return Collections.emptyList();
 
@@ -315,6 +325,7 @@ public class ProductRecommendationService {
             Product product = productMapper.selectById(assoc.getTargetProductId());
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
             if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
+            if (!productVisibilitySupport.isVisibleTo(product, visibilityCtx)) continue; // 商品可见性(供应商专属/会员级别)
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -354,7 +365,8 @@ public class ProductRecommendationService {
      */
     private List<Map<String, Object>> queryCoPurchaseAssociations(
             Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
-            HealthFilterService.HealthProfile healthProfile) {
+            HealthFilterService.HealthProfile healthProfile,
+            ProductVisibilitySupport.VisibilityContext visibilityCtx) {
 
         if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
 
@@ -383,6 +395,7 @@ public class ProductRecommendationService {
             Product product = productMapper.selectById(targetId);
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
             if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
+            if (!productVisibilitySupport.isVisibleTo(product, visibilityCtx)) continue; // 商品可见性(供应商专属/会员级别)
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -411,7 +424,8 @@ public class ProductRecommendationService {
      */
     private List<Map<String, Object>> queryRepurchaseAssociations(
             Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
-            HealthFilterService.HealthProfile healthProfile) {
+            HealthFilterService.HealthProfile healthProfile,
+            ProductVisibilitySupport.VisibilityContext visibilityCtx) {
 
         if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
 
@@ -435,6 +449,7 @@ public class ProductRecommendationService {
             Product product = productMapper.selectById(targetId);
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
             if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
+            if (!productVisibilitySupport.isVisibleTo(product, visibilityCtx)) continue; // 商品可见性(供应商专属/会员级别)
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -962,11 +977,12 @@ public class ProductRecommendationService {
         Set<String> sourceTagSet = new HashSet<>(sourceTags);
 
         // 2. 查询所有在售且有库存的商品
-        List<Product> products = productMapper.selectList(
-                new LambdaQueryWrapper<Product>()
-                        .eq(Product::getStatus, "on_shelf")
-                        .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
-        );
+        LambdaQueryWrapper<Product> tagWrapper = new LambdaQueryWrapper<Product>()
+                .eq(Product::getStatus, "on_shelf")
+                .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0));
+        // 商品可见性过滤(min_visible_level + vendor_visible)
+        productVisibilitySupport.applyToQuery(tagWrapper, userId, null);
+        List<Product> products = productMapper.selectList(tagWrapper);
         if (products == null || products.isEmpty()) return Collections.emptyList();
 
         // 3. 预加载用户权重(推荐曝光日志:购买=1.0,点击=0.6)

+ 8 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -23,6 +23,7 @@ import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductSkuMapper;
 import com.etotem.cfc.mapper.SupplySystemMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -49,6 +50,8 @@ public class ProductService {
     @Resource
     private SupplySystemMemberMapper supplySystemMemberMapper;
 
+    @Resource
+    private ProductVisibilitySupport productVisibilitySupport;
     @Resource
     private MembershipService membershipService;
 
@@ -95,7 +98,7 @@ public class ProductService {
      * @param limit         最大返回数量
      * @return 上架且维度匹配的商品列表
      */
-    public List<Product> listByDimension(String dimensionCode, Integer minPrice, Integer maxPrice, int limit) {
+    public List<Product> listByDimension(String dimensionCode, Integer minPrice, Integer maxPrice, int limit, Long userId) {
         if (dimensionCode == null || dimensionCode.isEmpty() || !DimensionCodes.isValid(dimensionCode)) {
             return new ArrayList<>();
         }
@@ -111,6 +114,8 @@ public class ProductService {
         if (maxPrice != null) {
             wrapper.le(Product::getPrice, maxPrice);
         }
+        // 商品可见性过滤(min_visible_level + vendor_visible)
+        productVisibilitySupport.applyToQuery(wrapper, userId, null);
         wrapper.orderByAsc(Product::getSortOrder).orderByAsc(Product::getId).last("LIMIT " + Math.max(1, limit));
         return productMapper.selectList(wrapper);
     }
@@ -123,12 +128,12 @@ public class ProductService {
      * @param limitPerDim    每维度最大返回数量
      * @return 维度码→上架商品列表
      */
-    public Map<String, List<Product>> listByDimensions(List<String> dimensionCodes, int limitPerDim) {
+    public Map<String, List<Product>> listByDimensions(List<String> dimensionCodes, int limitPerDim, Long userId) {
         Map<String, List<Product>> result = new LinkedHashMap<>();
         if (dimensionCodes == null) return result;
         for (String dim : dimensionCodes) {
             if (dim != null && !dim.isEmpty()) {
-                result.put(dim, listByDimension(dim, null, null, limitPerDim));
+                result.put(dim, listByDimension(dim, null, null, limitPerDim, userId));
             }
         }
         return result;

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java

@@ -4,5 +4,5 @@ import java.util.Map;
 
 public interface RecommendService {
     /** 基于画像获取推荐列表 */
-    Map<String, Object> getRecommendations(Long memberId);
+    Map<String, Object> getRecommendations(Long memberId, Long userId);
 }

+ 19 - 14
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java

@@ -26,6 +26,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.*;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 
 /**
  * 跨表推荐搜索服务
@@ -64,6 +65,9 @@ public class RecommendationService {
     @Resource
     private ProductOrderMapper productOrderMapper;
 
+    @Resource
+    private ProductVisibilitySupport productVisibilitySupport;
+
     /**
      * 按营养标签搜索推荐内容
      */
@@ -75,7 +79,7 @@ public class RecommendationService {
         int limit = query.getLimit() != null ? query.getLimit() : 5;
 
         if (query.getTypes() == null || query.getTypes().contains("product")) {
-            results.addAll(searchProducts(tags, limit));
+            results.addAll(searchProducts(tags, limit, query.getUserId()));
         }
         if (query.getTypes() == null || query.getTypes().contains("activity")) {
             results.addAll(searchActivities(tags, limit));
@@ -122,7 +126,7 @@ public class RecommendationService {
         return aiResults;
     }
 
-    private List<RecommendationResult> searchProducts(List<String> tags, int limit) {
+    private List<RecommendationResult> searchProducts(List<String> tags, int limit, Long userId) {
         Set<Long> added = new HashSet<>();
         List<RecommendationResult> results = new ArrayList<>();
 
@@ -130,18 +134,19 @@ public class RecommendationService {
             if (results.size() >= limit) break;
             String keyword = "%" + tag + "%";
 
-            List<Product> products = productMapper.selectList(
-                    new LambdaQueryWrapper<Product>()
-                            .eq(Product::getStatus, "on_shelf")
-                            .gt(Product::getStock, 0)
-                            .and(w -> w.like(Product::getName, keyword)
-                                    .or()
-                                    .like(Product::getDescription, keyword)
-                                    // 营养标签优先命中:商品已配置 nutrition_tags(逗号分隔)时按标签匹配
-                                    .or()
-                                    .like(Product::getNutritionTags, keyword))
-                            .last("LIMIT " + (limit - results.size()))
-            );
+            LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                    .eq(Product::getStatus, "on_shelf")
+                    .gt(Product::getStock, 0)
+                    .and(w -> w.like(Product::getName, keyword)
+                            .or()
+                            .like(Product::getDescription, keyword)
+                            // 营养标签优先命中:商品已配置 nutrition_tags(逗号分隔)时按标签匹配
+                            .or()
+                            .like(Product::getNutritionTags, keyword))
+                    .last("LIMIT " + (limit - results.size()));
+            // 商品可见性过滤(min_visible_level + vendor_visible)
+            productVisibilitySupport.applyToQuery(wrapper, userId, null);
+            List<Product> products = productMapper.selectList(wrapper);
 
             for (Product p : products) {
                 if (added.add(p.getId())) {

+ 7 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.RecommendService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -19,9 +20,10 @@ public class RecommendServiceImpl implements RecommendService {
     @Autowired private ActivityMapper activityMapper;
     @Autowired private TaskMapper taskMapper;
     @Autowired private ProductMapper productMapper;
+    @Autowired private ProductVisibilitySupport productVisibilitySupport;
 
     @Override
-    public Map<String, Object> getRecommendations(Long memberId) {
+    public Map<String, Object> getRecommendations(Long memberId, Long userId) {
         Map<String, Object> result = new HashMap<>();
 
         LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
@@ -86,7 +88,7 @@ public class RecommendServiceImpl implements RecommendService {
         result.put("tasks", queryTasks(taskTags));
         result.put("articles", queryArticles(articleTags));
         result.put("activities", queryActivities(activityTags));
-        result.put("products", queryProducts());
+        result.put("products", queryProducts(userId));
 
         return result;
     }
@@ -159,12 +161,14 @@ public class RecommendServiceImpl implements RecommendService {
         return list;
     }
 
-    private List<Map<String, Object>> queryProducts() {
+    private List<Map<String, Object>> queryProducts(Long userId) {
         List<Map<String, Object>> list = new ArrayList<>();
         LambdaQueryWrapper<Product> qw = new LambdaQueryWrapper<>();
         qw.eq(Product::getStatus, "on_shelf")
            .isNotNull(Product::getGrowthCategory)
            .last("LIMIT 8");
+        // 商品可见性过滤(min_visible_level + vendor_visible)
+        productVisibilitySupport.applyToQuery(qw, userId, null);
         List<Product> products = productMapper.selectList(qw);
         for (Product p : products) {
             Map<String, Object> item = new HashMap<>();

+ 160 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/support/ProductVisibilitySupport.java

@@ -0,0 +1,160 @@
+package com.etotem.cfc.service.support;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 公共商品可见性过滤组件。
+ *
+ * 所有面向用户的商品查询入口(商城列表、推荐、类目、搜索、相关商品等)
+ * 必须通过 {@link #applyToQuery} 下推两类可见性规则,杜绝
+ * "供应商体系专属商品(vendor_visible=true)" 与 "会员专属商品(min_visible_level)"
+ * 泄露给非目标用户(2026-11 回归根因:ContentRecommendService 等入口漏过滤)。
+ *
+ * 规则(与 ProductService.list 对齐):
+ * 1) min_visible_level:NULL 全可见;否则仅当用户可见级别编码包含该级别
+ * 2) vendor_visible:true 仅"所属 supply_system_member 命中该商品 distributionSystemId"的用户可见;
+ *    teacher/admin 平台角色可见全部;游客/未加入体系仅 vendor_visible NULL/false
+ */
+@Component
+public class ProductVisibilitySupport {
+
+    @Resource
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 下推商品可见性过滤到查询条件。
+     *
+     * @param wrapper 商品查询包装器(已含业务条件,如 status/dimensionCode)
+     * @param userId  当前用户ID(可为 null=游客;-1=匿名 JWT 注入值)
+     * @param role    当前角色(可为 null,为 null 且 userId 非空时按库内角色判定)
+     */
+    /** 可见性上下文:一次性查库,供多次 isVisibleTo/applyToQuery 复用(避免关联推荐 N+1) */
+    public static final class VisibilityContext {
+        public final boolean anonymous;
+        public final boolean platform;
+        public final java.util.Set<Long> systemIds;
+        public final List<String> levelCodes;
+
+        VisibilityContext(boolean anonymous, boolean platform, java.util.Set<Long> systemIds, List<String> levelCodes) {
+            this.anonymous = anonymous;
+            this.platform = platform;
+            this.systemIds = systemIds;
+            this.levelCodes = levelCodes;
+        }
+    }
+
+    /**
+     * 构建可见性上下文(一次性查库)。
+     *
+     * @param userId 当前用户ID(null=游客;-1=匿名 JWT 注入值)
+     * @param role   当前角色(可空,空且 userId 非空时按库内角色判定)
+     */
+    public VisibilityContext buildContext(Long userId, String role) {
+        String effectiveRole = role;
+        if (effectiveRole == null && userId != null) {
+            User u = userMapper.selectById(userId);
+            if (u != null) effectiveRole = u.getRole();
+        }
+        if ("teacher".equals(effectiveRole) || "admin".equals(effectiveRole)) {
+            return new VisibilityContext(false, true, java.util.Collections.emptySet(), null);
+        }
+        boolean anonymous = (userId == null || userId <= 0);
+        List<String> levelCodes = null;
+        if (!anonymous) {
+            try {
+                levelCodes = membershipService.getVisibleMemberLevelCodes(userId);
+            } catch (Exception e) {
+                levelCodes = java.util.Collections.emptyList();
+            }
+        }
+        List<Long> systemIds = new ArrayList<>();
+        if (!anonymous) {
+            systemIds = supplySystemMemberMapper.selectList(
+                    new LambdaQueryWrapper<SupplySystemMember>()
+                            .eq(SupplySystemMember::getUserId, userId)
+                            .eq(SupplySystemMember::getStatus, "active"))
+                    .stream()
+                    .map(SupplySystemMember::getSystemId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+        }
+        return new VisibilityContext(anonymous, false, new java.util.HashSet<>(systemIds), levelCodes);
+    }
+
+    /**
+     * 单商品可见性判断(内存,供 selectById 路径过滤)。
+     */
+    public boolean isVisibleTo(Product p, VisibilityContext ctx) {
+        if (p == null || ctx == null) return false;
+        if (ctx.platform) return true;
+        // 1) 最低可见会员级别:NULL 全可见;否则须包含在用户可见级别
+        String minLevel = p.getMinVisibleLevel();
+        if (minLevel != null && !minLevel.isEmpty()) {
+            if (ctx.anonymous) return false;
+            if (ctx.levelCodes == null || !ctx.levelCodes.contains(minLevel)) return false;
+        }
+        // 2) 供应商体系专属:true 须同体系
+        if (Boolean.TRUE.equals(p.getVendorVisible())) {
+            if (ctx.anonymous) return false;
+            Long systemId = p.getDistributionSystemId();
+            return systemId != null && ctx.systemIds.contains(systemId);
+        }
+        return true;
+    }
+
+    public void applyToQuery(LambdaQueryWrapper<Product> wrapper, Long userId, String role) {
+        applyToQuery(wrapper, buildContext(userId, role));
+    }
+
+    /**
+     * 下推商品可见性过滤(基于预建上下文,避免重复查库)。
+     */
+    public void applyToQuery(LambdaQueryWrapper<Product> wrapper, VisibilityContext ctx) {
+        if (ctx == null || ctx.platform) {
+            return;
+        }
+        // 1) 最低可见会员级别
+        if (ctx.anonymous) {
+            wrapper.isNull(Product::getMinVisibleLevel);
+        } else {
+            wrapper.and(w -> {
+                w.isNull(Product::getMinVisibleLevel);
+                if (ctx.levelCodes != null && !ctx.levelCodes.isEmpty()) {
+                    w.or().in(Product::getMinVisibleLevel, ctx.levelCodes);
+                }
+            });
+        }
+        // 2) 供应商体系专属
+        List<Long> mySystemIds = new ArrayList<>(ctx.systemIds);
+        if (!mySystemIds.isEmpty()) {
+            final List<Long> ids = mySystemIds;
+            wrapper.and(w -> w
+                    .isNull(Product::getVendorVisible)
+                    .or().eq(Product::getVendorVisible, false)
+                    .or(w2 -> w2.eq(Product::getVendorVisible, true)
+                            .in(Product::getDistributionSystemId, ids)));
+        } else {
+            wrapper.and(w -> w.isNull(Product::getVendorVisible).or().eq(Product::getVendorVisible, false));
+        }
+    }
+}

+ 216 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ContentRecommendProductVisibilityTest.java

@@ -0,0 +1,216 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.PeriodicServiceMapper;
+import com.etotem.cfc.mapper.RecommendationLogMapper;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ContentRecommendService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 推荐商品接口(/api/recommend/list type=product)可见性回归:
+ * 供应商体系专属商品(vendor_visible=true)不得推荐给非体系用户。
+ *
+ * 背景Bug:用户115(非Enagic服务商体系)在推荐商品中看到商品29
+ * (TDS调节净水直饮水机,vendor_visible=1,distribution_system_id=3=Enagic体系)。
+ * 根因:ContentRecommendService.recommendProducts 漏掉与 ProductService.list
+ * 一致的 vendorVisible/minVisibleLevel 可见性过滤。
+ */
+class ContentRecommendProductVisibilityTest {
+
+    private ContentRecommendService service;
+    private ProductMapper productMapper;
+    private UserMapper userMapper;
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+    private MembershipService membershipService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        service = new ContentRecommendService();
+        productMapper = mock(ProductMapper.class);
+        ProductOrderMapper productOrderMapper = mock(ProductOrderMapper.class);
+        PeriodicServiceMapper periodicServiceMapper = mock(PeriodicServiceMapper.class);
+        RecommendationLogMapper recommendationLogMapper = mock(RecommendationLogMapper.class);
+        userMapper = mock(UserMapper.class);
+        supplySystemMemberMapper = mock(SupplySystemMemberMapper.class);
+        membershipService = mock(MembershipService.class);
+
+        set("productMapper", productMapper);
+        set("productOrderMapper", productOrderMapper);
+        set("periodicServiceMapper", periodicServiceMapper);
+        set("recommendationLogMapper", recommendationLogMapper);
+        set("userMapper", userMapper);
+        set("supplySystemMemberMapper", supplySystemMemberMapper);
+        set("membershipService", membershipService);
+
+        // 可见性过滤已收敛到公共组件:共享同一组 mock 注入
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        setField(support, "userMapper", userMapper);
+        setField(support, "supplySystemMemberMapper", supplySystemMemberMapper);
+        setField(support, "membershipService", membershipService);
+        set("productVisibilitySupport", support);
+
+        // 基础返回:商品列表候选(含供应商专属商品29与公开商品)、无订单、无周期服务
+        when(productMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(productOrderMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(productOrderMapper.selectCount(any())).thenReturn(0L);
+        when(periodicServiceMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(recommendationLogMapper.insert(any())).thenReturn(1);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = ContentRecommendService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private void setField(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private Product product29() {
+        Product p = new Product();
+        p.setId(29L);
+        p.setName("TDS调节净水直饮水机");
+        p.setStatus("on_shelf");
+        p.setVendorVisible(Boolean.TRUE);          // 供应商体系专属
+        p.setDistributionSystemId(3L);             // Enagic体系
+        p.setDimensionWeights("{\"body\":50,\"wealth\":50}");
+        p.setSalesCount(791);
+        p.setStock(100);
+        p.setProductType("physical");
+        return p;
+    }
+
+    private Product publicProduct() {
+        Product p = new Product();
+        p.setId(3L);
+        p.setName("肠道菌群检测套装");
+        p.setStatus("on_shelf");
+        p.setVendorVisible(null);                  // 公开
+        p.setDistributionSystemId(null);
+        p.setSalesCount(10);
+        p.setStock(100);
+        p.setProductType("physical");
+        return p;
+    }
+
+    private User user(Long id, String role) {
+        User u = new User();
+        u.setId(id);
+        u.setRole(role);
+        return u;
+    }
+
+    @Test
+    void recommendProducts_非体系用户_推荐商品查询排除供应商专属商品() throws Exception {
+        // 用户115:parent 角色,不属于任何 supply_system_member(非Enagic服务商体系)
+        when(userMapper.selectById(115L)).thenReturn(user(115L, "parent"));
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        when(productMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(product29(), publicProduct()));
+
+        service.recommend(productRequest(), 115L, 119L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        // 供应商体系可见性必须下推到推荐商品查询条件
+        assertTrue(sql.toLowerCase().contains("vendor_visible"), "推荐商品查询缺少 vendor_visible 过滤: " + sql);
+    }
+
+    @Test
+    void recommendProducts_体系内用户_推荐商品查询保留自身体系专属商品() throws Exception {
+        // 用户12:Enagic体系(systemId=3)admin
+        when(userMapper.selectById(12L)).thenReturn(user(12L, "parent"));
+        SupplySystemMember mem = new SupplySystemMember();
+        mem.setId(1L);
+        mem.setSystemId(3L);
+        mem.setUserId(12L);
+        mem.setStatus("active");
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(mem));
+        when(membershipService.getVisibleMemberLevelCodes(12L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        when(productMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(product29(), publicProduct()));
+
+        service.recommend(productRequest(), 12L, 119L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        // 体系内用户必须能看到自身体系(3)的 vendor_visible=true 商品
+        assertTrue(sql.toLowerCase().contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("distribution_system_id IN"),
+                "体系内可见条件应包含 distribution_system_id IN: " + sql);
+        assertTrue(captor.getValue().getParamNameValuePairs().containsValue(3L),
+                "体系内可见参数应 distributionSystemId=3: " + captor.getValue().getParamNameValuePairs());
+    }
+
+    @Test
+    void recommendProducts_非会员可见级别商品_推荐查询下推会员级别过滤() throws Exception {
+        when(userMapper.selectById(115L)).thenReturn(user(115L, "parent"));
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+
+        service.recommend(productRequest(), 115L, 119L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.toLowerCase().contains("min_visible_level"), "推荐商品查询缺少 min_visible_level 过滤: " + sql);
+    }
+
+    private com.etotem.cfc.dto.RecommendRequestDTO productRequest() {
+        com.etotem.cfc.dto.RecommendRequestDTO req = new com.etotem.cfc.dto.RecommendRequestDTO();
+        req.setType("product");
+        req.setDimensionCode("body");
+        req.setSize(30);
+        return req;
+    }
+}

+ 86 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GrowthRecommendationProductVisibilityTest.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.GrowthRecommendationService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * /api/growth/recommendations 推荐商品可见性回归(匿名可达接口):
+ * 必须下推 vendor_visible + min_visible_level 过滤。
+ */
+class GrowthRecommendationProductVisibilityTest {
+
+    private GrowthRecommendationService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        set(support, "supplySystemMemberMapper", systemMemberMapper);
+        set(support, "membershipService", membershipService);
+        set(support, "userMapper", userMapper);
+        when(userMapper.selectById(115L)).thenReturn(u(115L, "parent"));
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L)).thenReturn(Collections.singletonList("FAMILY"));
+
+        service = new GrowthRecommendationService();
+        productMapper = mock(ProductMapper.class);
+        set(service, "productMapper", productMapper);
+        set(service, "articleMapper", mock(ArticleMapper.class));
+        set(service, "aiService", mock(AIService.class));
+        set(service, "productVisibilitySupport", support);
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+    }
+
+    private void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private User u(Long id, String role) { User x = new User(); x.setId(id); x.setRole(role); return x; }
+
+    @Test
+    void getRecommendedProducts_查询下推可见性过滤() {
+        service.getRecommendedProducts("health", 3, 115L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "缺少 min_visible_level 过滤: " + sql);
+    }
+}

+ 99 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductCategoryProductsVisibilityTest.java

@@ -0,0 +1,99 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.ProductCategoryService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 类目商品查询(/api/shop/category/products)可见性回归:
+ * 必须下推 vendor_visible + min_visible_level 过滤(此前仅内存过滤 min_visible_level 且 vendor_visible 完全缺失)。
+ */
+class ProductCategoryProductsVisibilityTest {
+
+    private ProductCategoryService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        // 构造"真实"可见性组件(便于验证下推 SQL),其依赖全部 mock
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        set(support, "supplySystemMemberMapper", systemMemberMapper);
+        set(support, "membershipService", membershipService);
+        set(support, "userMapper", userMapper);
+        when(userMapper.selectById(115L)).thenReturn(parent(115L));
+        when(userMapper.selectById(2L)).thenReturn(teacher(2L));
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L)).thenReturn(Collections.singletonList("FAMILY"));
+
+        service = new ProductCategoryService();
+        productMapper = mock(ProductMapper.class);
+        set(service, "productMapper", productMapper);
+        set(service, "productVisibilitySupport", support);
+
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+    }
+
+    private void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private User parent(Long id) { User u = new User(); u.setId(id); u.setRole("parent"); return u; }
+    private User teacher(Long id) { User u = new User(); u.setId(id); u.setRole("teacher"); return u; }
+
+    @Test
+    void products_普通用户_查询下推可见性过滤() {
+        service.products(1L, 115L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "缺少 min_visible_level 过滤: " + sql);
+    }
+
+    @Test
+    void products_平台角色_不加可见性过滤() {
+        service.products(1L, 2L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertFalse(sql.contains("vendor_visible"), "teacher 不应加过滤: " + sql);
+        assertFalse(sql.contains("min_visible_level"), "teacher 不应加过滤: " + sql);
+    }
+}

+ 233 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductRecommendationFilterTest.java

@@ -0,0 +1,233 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.RecommendationProductAssociation;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.*;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 维度推荐(/api/recommend/dimension-products)与相关商品推荐可见性回归:
+ * dimension fallback / 关联推荐(profile_gap 等) / 标签匹配相关商品
+ * 全部不得把供应商专属商品(vendor_visible=true)推荐给非体系用户。
+ */
+class ProductRecommendationFilterTest {
+
+    private ProductRecommendationService service;
+    private ProductMapper productMapper;
+    private RecommendationProductAssociationMapper associationMapper;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+        TableInfoHelper.initTableInfo(assistant, User.class);
+
+        // 真实可见性组件(便于验证 SQL 下推与内存判断),依赖 mock
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper supportUserMapper = mock(UserMapper.class);
+        set(support, "supplySystemMemberMapper", systemMemberMapper);
+        set(support, "membershipService", membershipService);
+        set(support, "userMapper", supportUserMapper);
+        when(supportUserMapper.selectById(115L)).thenReturn(user(115L, "parent"));
+        when(supportUserMapper.selectById(12L)).thenReturn(user(12L, "parent"));
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L)).thenReturn(Collections.singletonList("FAMILY"));
+        when(membershipService.getVisibleMemberLevelCodes(12L)).thenReturn(Collections.singletonList("FAMILY"));
+
+        service = new ProductRecommendationService();
+        productMapper = mock(ProductMapper.class);
+        associationMapper = mock(RecommendationProductAssociationMapper.class);
+        userMapper = mock(UserMapper.class);
+
+        ProductDimensionMappingMapper dimensionMappingMapper = mock(ProductDimensionMappingMapper.class);
+        ProductOrderMapper orderMapper = mock(ProductOrderMapper.class);
+        FiveDimensionScoreMapper fiveDimensionScoreMapper = mock(FiveDimensionScoreMapper.class);
+        ProductRecommendationLogMapper productRecommendationLogMapper = mock(ProductRecommendationLogMapper.class);
+        HealthFilterService healthFilterService = mock(HealthFilterService.class);
+        ReferralTreeMapper referralTreeMapper = mock(ReferralTreeMapper.class);
+        SupplySystemMemberMapper svcSystemMemberMapper = mock(SupplySystemMemberMapper.class);
+        SupplySystemService supplySystemService = mock(SupplySystemService.class);
+        UserContentWeightService userContentWeightService = mock(UserContentWeightService.class);
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        FiveDimensionScoreService fiveDimensionScoreService = mock(FiveDimensionScoreService.class);
+        ContentDimensionThresholdService contentDimensionThresholdService = mock(ContentDimensionThresholdService.class);
+
+        set(service, "productMapper", productMapper);
+        set(service, "recommendationProductAssociationMapper", associationMapper);
+        set(service, "userMapper", userMapper);
+        set(service, "dimensionMappingMapper", dimensionMappingMapper);
+        set(service, "orderMapper", orderMapper);
+        set(service, "fiveDimensionScoreMapper", fiveDimensionScoreMapper);
+        set(service, "productRecommendationLogMapper", productRecommendationLogMapper);
+        set(service, "healthFilterService", healthFilterService);
+        set(service, "referralTreeMapper", referralTreeMapper);
+        set(service, "supplySystemMemberMapper", svcSystemMemberMapper);
+        set(service, "supplySystemService", supplySystemService);
+        set(service, "userContentWeightService", userContentWeightService);
+        set(service, "articleMapper", articleMapper);
+        set(service, "fiveDimensionScoreService", fiveDimensionScoreService);
+        set(service, "contentDimensionThresholdService", contentDimensionThresholdService);
+        set(service, "productVisibilitySupport", support);
+
+        // 默认:无维度映射(走 domain fallback)、无订单、无评分、健康过滤直通
+        when(dimensionMappingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(orderMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(fiveDimensionScoreMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthFilterService.filterProducts(any(), any()))
+                .thenAnswer(inv -> {
+                    HealthFilterService.FilterResult fr = new HealthFilterService.FilterResult();
+                    fr.allowed = (List<Product>) inv.getArgument(1);
+                    return fr;
+                });
+        when(fiveDimensionScoreService.getFamilyAverageScores(any())).thenReturn(Collections.emptyMap());
+        when(contentDimensionThresholdService.listByContents(any(), any())).thenReturn(Collections.emptyMap());
+        when(userContentWeightService.listWeights(any(), any(), any(), any())).thenReturn(Collections.emptyList());
+        when(productRecommendationLogMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+    }
+
+    private void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private User user(Long id, String role) { User u = new User(); u.setId(id); u.setRole(role); return u; }
+
+    private Product p29() {
+        Product p = new Product();
+        p.setId(29L);
+        p.setName("TDS调节净水直饮水机");
+        p.setStatus("on_shelf");
+        p.setVendorVisible(Boolean.TRUE);
+        p.setDistributionSystemId(3L);
+        p.setDomain(null);
+        p.setSalesCount(791);
+        p.setStock(100);
+        p.setProductType("physical");
+        return p;
+    }
+
+    private Product pPublic() {
+        Product p = new Product();
+        p.setId(3L);
+        p.setName("肠道菌群检测套装");
+        p.setStatus("on_shelf");
+        p.setSalesCount(10);
+        p.setStock(100);
+        p.setProductType("physical");
+        return p;
+    }
+
+    private RecommendationProductAssociation profileGapAssoc(Long targetId) {
+        RecommendationProductAssociation a = new RecommendationProductAssociation();
+        a.setId(1L);
+        a.setAssociationType("profile_gap");
+        a.setDimensionCode("body");
+        a.setTargetProductId(targetId);
+        a.setEnabled(1);
+        a.setStrength(new BigDecimal("8.0"));
+        a.setReason(null);
+        return a;
+    }
+
+    @Test
+    void getDimensionRecommendations_domain兜底_查询下推可见性过滤() {
+        when(productMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(p29(), pPublic()));
+
+        service.getDimensionRecommendations("body", null, null, null, 10, 115L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "维度推荐兜底查询缺少 vendor_visible: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "维度推荐兜底查询缺少 min_visible_level: " + sql);
+    }
+
+    @Test
+    void getDimensionRecommendations_关联推荐_非体系用户过滤供应商专属商品() {
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(associationMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(profileGapAssoc(29L)));
+        when(productMapper.selectById(29L)).thenReturn(p29());
+
+        List<Map<String, Object>> result = service.getDimensionRecommendations("body", null, null, null, 10, 115L);
+
+        List<Long> ids = result.stream().map(m -> ((Number) m.get("id")).longValue()).collect(java.util.stream.Collectors.toList());
+        assertFalse(ids.contains(29L), "非体系用户不应被推荐供应商专属商品29: " + ids);
+    }
+
+    @Test
+    void getDimensionRecommendations_关联推荐_体系内用户保留自身体系商品() {
+        ProductVisibilitySupport support2 = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper supportUserMapper = mock(UserMapper.class);
+        SupplySystemMember mem = new SupplySystemMember();
+        mem.setSystemId(3L);
+        mem.setUserId(12L);
+        mem.setStatus("active");
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(mem));
+        when(membershipService.getVisibleMemberLevelCodes(12L)).thenReturn(Collections.singletonList("FAMILY"));
+        when(supportUserMapper.selectById(12L)).thenReturn(user(12L, "parent"));
+        try {
+            set(support2, "supplySystemMemberMapper", systemMemberMapper);
+            set(support2, "membershipService", membershipService);
+            set(support2, "userMapper", supportUserMapper);
+            set(service, "productVisibilitySupport", support2);
+        } catch (Exception e) { throw new RuntimeException(e); }
+
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(associationMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(profileGapAssoc(29L)));
+        when(productMapper.selectById(29L)).thenReturn(p29());
+
+        List<Map<String, Object>> result = service.getDimensionRecommendations("body", null, null, null, 10, 12L);
+
+        List<Long> ids = result.stream().map(m -> ((Number) m.get("id")).longValue()).collect(java.util.stream.Collectors.toList());
+        assertTrue(ids.contains(29L), "体系内用户应保留自身体系(3)专属商品29: " + ids);
+    }
+
+    @Test
+    void getRelatedProductsByProductFull_相关商品查询下推可见性过滤() {
+        Product source = new Product();
+        source.setId(9L);
+        source.setTags("[\"净水\",\"健康\"]");
+        when(productMapper.selectById(9L)).thenReturn(source);
+        when(productMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(p29(), pPublic()));
+
+        service.getRelatedProductsByProductFull(9L, 115L, 119L, 10);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "相关商品查询缺少 vendor_visible: " + sql);
+    }
+}

+ 94 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductServiceListByDimensionVisibilityTest.java

@@ -0,0 +1,94 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * /api/product/combo-tiers 套餐档商品(ProductService.listByDimension/listByDimensions)可见性回归:
+ * 必须下推 vendor_visible + min_visible_level 过滤,防止枚举供应商专属商品。
+ */
+class ProductServiceListByDimensionVisibilityTest {
+
+    private ProductService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        set(support, "supplySystemMemberMapper", systemMemberMapper);
+        set(support, "membershipService", membershipService);
+        set(support, "userMapper", userMapper);
+        when(userMapper.selectById(115L)).thenReturn(u(115L, "parent"));
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L)).thenReturn(Collections.singletonList("FAMILY"));
+
+        service = new ProductService();
+        productMapper = mock(ProductMapper.class);
+        set(service, "productMapper", productMapper);
+        set(service, "productVisibilitySupport", support);
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+    }
+
+    private void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private User u(Long id, String role) { User x = new User(); x.setId(id); x.setRole(role); return x; }
+
+    @Test
+    void listByDimension_查询下推可见性过滤() {
+        service.listByDimension("body", null, null, 3, 115L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "缺少 min_visible_level 过滤: " + sql);
+    }
+
+    @Test
+    void listByDimensions_查询下推可见性过滤() {
+        service.listByDimensions(List.of("body"), 2, 115L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+    }
+}

+ 214 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductVisibilitySupportTest.java

@@ -0,0 +1,214 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 公共商品可见性过滤组件回归测试:
+ * 供应商体系专属商品(vendor_visible=true)与会员级别商品(min_visible_level)
+ * 必须对所有面向用户的商品查询入口生效。
+ */
+class ProductVisibilitySupportTest {
+
+    private ProductVisibilitySupport support;
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+    private MembershipService membershipService;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        support = new ProductVisibilitySupport();
+        supplySystemMemberMapper = mock(SupplySystemMemberMapper.class);
+        membershipService = mock(MembershipService.class);
+        userMapper = mock(UserMapper.class);
+
+        set("supplySystemMemberMapper", supplySystemMemberMapper);
+        set("membershipService", membershipService);
+        set("userMapper", userMapper);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = ProductVisibilitySupport.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(support, value);
+    }
+
+    private LambdaQueryWrapper<Product> freshWrapper() {
+        LambdaQueryWrapper<Product> w = new LambdaQueryWrapper<>();
+        w.eq(Product::getStatus, "on_shelf");
+        return w;
+    }
+
+    private SupplySystemMember member(Long systemId, Long userId) {
+        SupplySystemMember m = new SupplySystemMember();
+        m.setSystemId(systemId);
+        m.setUserId(userId);
+        m.setStatus("active");
+        return m;
+    }
+
+    private User user(Long id, String role) {
+        User u = new User();
+        u.setId(id);
+        u.setRole(role);
+        return u;
+    }
+
+    @Test
+    void applyToQuery_非体系用户_仅放行公开商品与NULL级别() throws Exception {
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        LambdaQueryWrapper<Product> w = freshWrapper();
+        support.applyToQuery(w, 115L, "parent");
+
+        String sql = w.getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "缺少 min_visible_level 过滤: " + sql);
+        // 非体系用户:不得放行任何 distribution_system_id
+        assertFalse(sql.contains("distribution_system_id"), "非体系用户不应出现 distribution_system_id 放行: " + sql);
+    }
+
+    @Test
+    void applyToQuery_体系内用户_放行自身体系专属商品() throws Exception {
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(member(3L, 12L)));
+        when(membershipService.getVisibleMemberLevelCodes(12L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        LambdaQueryWrapper<Product> w = freshWrapper();
+        support.applyToQuery(w, 12L, "parent");
+
+        String sql = w.getSqlSegment();
+        assertTrue(sql.toLowerCase().contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("distribution_system_id IN"), "体系内应放行 distribution_system_id IN: " + sql);
+        assertTrue(w.getParamNameValuePairs().containsValue(3L), "体系参数应含 systemId=3");
+    }
+
+    @Test
+    void applyToQuery_平台角色_不加任何过滤() {
+        LambdaQueryWrapper<Product> w = freshWrapper();
+        support.applyToQuery(w, 999L, "teacher");
+
+        String sql = w.getSqlSegment().toLowerCase();
+        assertFalse(sql.contains("vendor_visible"), "平台角色不应加过滤: " + sql);
+        assertFalse(sql.contains("min_visible_level"), "平台角色不应加过滤: " + sql);
+
+        LambdaQueryWrapper<Product> w2 = freshWrapper();
+        support.applyToQuery(w2, 999L, "admin");
+        assertFalse(w2.getSqlSegment().toLowerCase().contains("vendor_visible"), "admin 不应加过滤");
+    }
+
+    @Test
+    void applyToQuery_游客_仅放行公开与NULL级别() {
+        LambdaQueryWrapper<Product> w = freshWrapper();
+        support.applyToQuery(w, null, null);
+
+        String sql = w.getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "游客缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "游客缺少 min_visible_level 过滤: " + sql);
+        assertFalse(sql.contains("distribution_system_id"), "游客不应放行任何体系: " + sql);
+    }
+
+    private Product p(Long id, Boolean vendorVisible, Long distributionSystemId, String minVisibleLevel) {
+        Product p = new Product();
+        p.setId(id);
+        p.setVendorVisible(vendorVisible);
+        p.setDistributionSystemId(distributionSystemId);
+        p.setMinVisibleLevel(minVisibleLevel);
+        p.setStatus("on_shelf");
+        return p;
+    }
+
+    @Test
+    void isVisibleTo_供应商专属商品_非体系用户不可见() throws Exception {
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        ProductVisibilitySupport.VisibilityContext ctx = support.buildContext(115L, "parent");
+        // 商品29:Enagic体系专属
+        assertFalse(support.isVisibleTo(p(29L, Boolean.TRUE, 3L, null), ctx));
+    }
+
+    @Test
+    void isVisibleTo_供应商专属商品_体系内用户可见() throws Exception {
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(List.of(member(3L, 12L)));
+        when(membershipService.getVisibleMemberLevelCodes(12L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        ProductVisibilitySupport.VisibilityContext ctx = support.buildContext(12L, "parent");
+        assertTrue(support.isVisibleTo(p(29L, Boolean.TRUE, 3L, null), ctx));
+        // 非自身体系(5)仍不可见
+        assertFalse(support.isVisibleTo(p(30L, Boolean.TRUE, 5L, null), ctx));
+    }
+
+    @Test
+    void isVisibleTo_供应商专属商品_平台角色可见() {
+        ProductVisibilitySupport.VisibilityContext ctx = support.buildContext(999L, "teacher");
+        assertTrue(support.isVisibleTo(p(29L, Boolean.TRUE, 3L, null), ctx));
+    }
+
+    @Test
+    void isVisibleTo_会员级别商品_低级别用户不可见() throws Exception {
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+
+        ProductVisibilitySupport.VisibilityContext ctx = support.buildContext(115L, "parent");
+        assertFalse(support.isVisibleTo(p(88L, null, null, "PREMIUM"), ctx));
+        assertTrue(support.isVisibleTo(p(89L, null, null, null), ctx));
+    }
+
+    @Test
+    void isVisibleTo_公开商品_任何用户可见() throws Exception {
+        when(supplySystemMemberMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L))
+                .thenReturn(Collections.singletonList("FAMILY"));
+        ProductVisibilitySupport.VisibilityContext ctx = support.buildContext(115L, "parent");
+        assertTrue(support.isVisibleTo(p(3L, null, null, null), ctx));
+        assertTrue(support.isVisibleTo(p(9L, Boolean.FALSE, 3L, null), ctx));
+    }
+
+    @Test
+    void applyToQuery_匿名负一用户_等效游客() {
+        // userId=-1 为 JwtInterceptor 对匿名请求的注入值,必须等效游客:不查库、仅放行 NULL
+        LambdaQueryWrapper<Product> w = freshWrapper();
+        support.applyToQuery(w, -1L, null);
+
+        String sql = w.getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "匿名(-1)缺少 vendor_visible 过滤: " + sql);
+        assertFalse(sql.contains("distribution_system_id"), "匿名(-1)不应放行任何体系: " + sql);
+    }
+}

+ 108 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendServiceImplQueryProductsVisibilityTest.java

@@ -0,0 +1,108 @@
+package com.etotem.cfc.unit;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.ProfileSnapshot;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProfileSnapshotMapper;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.impl.RecommendServiceImpl;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * /api/profile/my 成长商品推荐(RecommendServiceImpl.queryProducts)可见性回归:
+ * 商品查询必须下推 vendor_visible + min_visible_level 过滤。
+ */
+class RecommendServiceImplQueryProductsVisibilityTest {
+
+    private RecommendServiceImpl service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        set(support, "supplySystemMemberMapper", systemMemberMapper);
+        set(support, "membershipService", membershipService);
+        set(support, "userMapper", userMapper);
+        when(userMapper.selectById(115L)).thenReturn(u(115L, "parent"));
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L)).thenReturn(Collections.singletonList("FAMILY"));
+
+        service = new RecommendServiceImpl();
+        ProfileSnapshotMapper snapshotMapper = mock(ProfileSnapshotMapper.class);
+        ArticleMapper articleMapper = mock(ArticleMapper.class);
+        ActivityMapper activityMapper = mock(ActivityMapper.class);
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        productMapper = mock(ProductMapper.class);
+        set(service, "snapshotMapper", snapshotMapper);
+        set(service, "articleMapper", articleMapper);
+        set(service, "activityMapper", activityMapper);
+        set(service, "taskMapper", taskMapper);
+        set(service, "productMapper", productMapper);
+        set(service, "productVisibilitySupport", support);
+
+        ProfileSnapshot snapshot = new ProfileSnapshot();
+        snapshot.setMemberId(115L);
+        snapshot.setBodyMetrics("{\"sleep_dur_avg\":8,\"exercise_count_week\":3}");
+        snapshot.setMindMetrics("{\"stress_avg\":3,\"emotion_joy_ratio\":0.6}");
+        snapshot.setWisdomMetrics("{\"attention_score\":80}");
+        snapshot.setActionMetrics("{\"task_completion_rate\":0.8}");
+        snapshot.setProblemDomains("[]");
+        when(snapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(snapshot);
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(activityMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+    }
+
+    private void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private User u(Long id, String role) { User x = new User(); x.setId(id); x.setRole(role); return x; }
+
+    @Test
+    void getRecommendations_商品查询下推可见性过滤() {
+        service.getRecommendations(115L, 115L);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "缺少 min_visible_level 过滤: " + sql);
+    }
+}

+ 19 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationNutritionTagTest.java

@@ -8,6 +8,12 @@ import com.etotem.cfc.dto.RecommendationResult;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.service.RecommendationService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.SupplySystemMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -41,6 +47,13 @@ class RecommendationNutritionTagTest {
         service = new RecommendationService();
         productMapper = mock(ProductMapper.class);
         set("productMapper", productMapper);
+
+        // 可见性过滤已收敛到公共组件:注入真实组件(依赖 mock,游客语义)
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        setField(support, "supplySystemMemberMapper", mock(SupplySystemMemberMapper.class));
+        setField(support, "membershipService", mock(MembershipService.class));
+        setField(support, "userMapper", mock(UserMapper.class));
+        set("productVisibilitySupport", support);
     }
 
     private void set(String name, Object value) throws Exception {
@@ -49,6 +62,12 @@ class RecommendationNutritionTagTest {
         f.set(service, value);
     }
 
+    private void setField(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
     @Test
     void search_按营养标签命中商品nutritionTags() {
         Product p = new Product();

+ 100 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationSearchProductVisibilityTest.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystemMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.RecommendationService;
+import com.etotem.cfc.service.support.ProductVisibilitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * /api/recommend/search 推荐搜索(匿名可达)可见性回归:
+ * 商品分支必须下推 vendor_visible + min_visible_level 过滤。
+ */
+class RecommendationSearchProductVisibilityTest {
+
+    private RecommendationService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        ProductVisibilitySupport support = new ProductVisibilitySupport();
+        SupplySystemMemberMapper systemMemberMapper = mock(SupplySystemMemberMapper.class);
+        MembershipService membershipService = mock(MembershipService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        set(support, "supplySystemMemberMapper", systemMemberMapper);
+        set(support, "membershipService", membershipService);
+        set(support, "userMapper", userMapper);
+        when(userMapper.selectById(115L)).thenReturn(u(115L, "parent"));
+        when(systemMemberMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(membershipService.getVisibleMemberLevelCodes(115L)).thenReturn(Collections.singletonList("FAMILY"));
+
+        service = new RecommendationService();
+        productMapper = mock(ProductMapper.class);
+        set(service, "productMapper", productMapper);
+        set(service, "activityMapper", mock(ActivityMapper.class));
+        set(service, "articleMapper", mock(ArticleMapper.class));
+        set(service, "productDimensionMappingMapper", mock(ProductDimensionMappingMapper.class));
+        set(service, "repurchaseReminderRecordMapper", mock(RepurchaseReminderRecordMapper.class));
+        set(service, "repurchaseReminderConfigMapper", mock(RepurchaseReminderConfigMapper.class));
+        set(service, "productOrderMapper", mock(ProductOrderMapper.class));
+        set(service, "productVisibilitySupport", support);
+
+        Product p = new Product();
+        p.setId(3L);
+        p.setName("肠道菌群检测套装");
+        p.setStatus("on_shelf");
+        p.setStock(100);
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
+    }
+
+    private void set(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private User u(Long id, String role) { User x = new User(); x.setId(id); x.setRole(role); return x; }
+
+    @Test
+    void search_商品分支查询下推可见性过滤() {
+        RecommendationQuery q = new RecommendationQuery();
+        q.setNutritionTags(List.of("睡眠"));
+        q.setTypes(List.of("product"));
+        q.setLimit(1);
+        q.setUserId(115L);
+
+        service.search(q);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper, times(1)).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("vendor_visible"), "缺少 vendor_visible 过滤: " + sql);
+        assertTrue(sql.contains("min_visible_level"), "缺少 min_visible_level 过滤: " + sql);
+    }
+}