|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|