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

feat(backend): A-后端 内容维度阈值显隐配置

- 新增 content_dimension_threshold 表(迁移424 + schema.sql 同步)
- 新增 ContentDimensionThreshold 实体/Mapper/Service(batchSave/listByContent/listByContents/deleteById)
- FiveDimensionScoreService.getFamilyAverageScores:家庭维度指标平均值(五维+子项+自定义码)
- DimensionThresholdFilter:OR 逻辑阈值显隐过滤,fail-open 不误隐藏
- 接入商品列表/商品推荐/文章列表/活动列表/小游戏列表 5 处列表过滤
- 新增管理端 /api/admin/content-dimension-threshold 接口(batch-save/list/delete,delete 用 POST 遵循项目规范)
E2E Test Bot 2 недель назад
Родитель
Сommit
89f9a8df49

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3373,6 +3373,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         seedFamilyAttributionSurveyTemplate();
         // 迁移426: 家庭归因报告表 attribution_reports(B1 核心闭环③,report_json+intermediate_state+redline_hits+status)
         createAttributionReportsTable();
+
+        // 迁移427: 创建 content_dimension_threshold 表(A-后端 内容维度阈值显隐配置:商品/文章/活动/游戏 五维+子项+自定义指标)
+        createContentDimensionThresholdTable();
     }
 
     /**
@@ -14341,6 +14344,35 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    /**
+     * 迁移427: 内容维度阈值显隐配置表(A-后端)
+     * - content_type: 'PRODUCT'|'ARTICLE'|'ACTIVITY'|'MINIGAME'
+     * - dimension_code: 五维码(body/mind/wisdom/action/wealth)/子项码/自定义指标码
+     * - threshold: 0-100,与 FiveDimensionScore.score 同量纲
+     * - 显隐规则(OR 逻辑,在前端/列表服务层计算):任一维度得分 < 阈值 → 显示;全部维度得分 >= 阈值 → 隐藏
+     * 幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。
+     */
+    void createContentDimensionThresholdTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `content_dimension_threshold` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`content_type` VARCHAR(20) NOT NULL COMMENT '内容类型: PRODUCT/ARTICLE/ACTIVITY/MINIGAME', "
+                    + "`content_id` BIGINT NOT NULL COMMENT '内容ID(products/articles/activities/mini_games 主键)', "
+                    + "`dimension_code` VARCHAR(50) NOT NULL COMMENT '维度码: 五维码/子项码/自定义指标码', "
+                    + "`dimension_name` VARCHAR(100) NULL COMMENT '展示名(自定义指标即自定义名)', "
+                    + "`threshold` INT NOT NULL COMMENT '阈值 0-100', "
+                    + "`is_custom` TINYINT DEFAULT 0 COMMENT '1=管理员自定义指标', "
+                    + "`enabled` TINYINT DEFAULT 1 COMMENT '1=启用', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "UNIQUE KEY `uk_content_dim` (`content_type`, `content_id`, `dimension_code`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容维度阈值显隐配置'");
+            log.info("迁移427: 已创建 content_dimension_threshold 表");
+        } catch (Exception e) {
+            log.warn("迁移427: 创建 content_dimension_threshold 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     
 
     void createPpointConsumptionLogTable() {

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

@@ -23,8 +23,8 @@ public class MiniGameController {
      */
     @Operation(summary = "获取小游戏列表")
     @PostMapping("/list")
-    public Result<List<MiniGame>> getGameList() {
-        List<MiniGame> games = miniGameService.getEnabledGames();
+    public Result<List<MiniGame>> getGameList(@RequestAttribute(value = "familyId", required = false) Long familyId) {
+        List<MiniGame> games = miniGameService.getEnabledGames(familyId);
         return Result.success(games);
     }
     @Operation(summary = "获取游戏详情")

+ 137 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ContentDimensionThresholdController.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
+import com.etotem.cfc.service.ContentDimensionThresholdService;
+import com.etotem.cfc.util.DimensionCodes;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 管理端-内容维度阈值显隐配置(商品/文章/活动/游戏 五维+子项+自定义指标)。
+ *
+ * <p>显隐规则(OR 逻辑):对某个内容项,若其配置的任一维度上「用户指标值 &lt; 阈值」→ 该内容显示;
+ * 若其配置的全部维度上「用户指标值 ≥ 阈值」→ 该内容不显示(隐藏)。
+ * 阈值 0-100,与 FiveDimensionScore.score 同量纲;家庭维度指标取家庭成员平均值。
+ *
+ * <p>维度选择器复用现有 /api/admin/dimension-config/tree(不新建维度树接口)。
+ * 维度码校验:复用 DimensionCodes 五维码集合做基础校验;子项码/自定义码不在白名单内,放行即可。
+ */
+@Tag(name = "管理端-内容维度阈值", description = "商品/文章/活动/游戏的维度阈值显隐配置")
+@RestController
+@RequestMapping("/api/admin/content-dimension-threshold")
+public class ContentDimensionThresholdController {
+
+    @Resource
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
+    /** 允许的内容类型 */
+    private static final String[] CONTENT_TYPES = {"PRODUCT", "ARTICLE", "ACTIVITY", "MINIGAME"};
+
+    private boolean isAdmin(String role) {
+        return "admin".equals(role) || "operator".equals(role);
+    }
+
+    private boolean validContentType(String contentType) {
+        if (contentType == null) return false;
+        for (String t : CONTENT_TYPES) {
+            if (t.equals(contentType)) return true;
+        }
+        return false;
+    }
+
+    /**
+     * 校验单个阈值配置:
+     * - dimensionCode 非空;若为五维码必须落在 DimensionCodes 白名单(body/mind/wisdom/action/wealth);
+     *   子项码/自定义码不在白名单内直接放行。
+     * - threshold 必须 0-100。
+     */
+    private ContentDimensionThreshold normalize(Item item) {
+        if (item == null || item.dimensionCode == null || item.dimensionCode.trim().isEmpty()) {
+            throw new IllegalArgumentException("dimensionCode 不能为空");
+        }
+        String code = item.dimensionCode.trim();
+        if (DimensionCodes.isValid(code)) {
+            code = code.toLowerCase();
+        }
+        if (item.threshold == null || item.threshold < 0 || item.threshold > 100) {
+            throw new IllegalArgumentException("threshold 必须在 0-100 之间");
+        }
+        ContentDimensionThreshold t = new ContentDimensionThreshold();
+        t.setDimensionCode(code);
+        t.setDimensionName(item.dimensionName != null ? item.dimensionName : item.customName);
+        t.setThreshold(item.threshold);
+        t.setIsCustom(item.isCustom != null ? item.isCustom : 0);
+        return t;
+    }
+
+    @Operation(summary = "批量保存内容维度阈值(先删旧再插新)")
+    @PostMapping("/batch-save")
+    public Result<List<ContentDimensionThreshold>> batchSave(@RequestBody BatchSaveReq req,
+                                                             @RequestAttribute("role") String role) {
+        if (!isAdmin(role)) {
+            return Result.error("无权限:仅平台管理员可配置维度阈值");
+        }
+        if (req == null || !validContentType(req.contentType) || req.contentId == null) {
+            return Result.error("contentType 必须是 PRODUCT/ARTICLE/ACTIVITY/MINIGAME 且 contentId 不能为空");
+        }
+        List<ContentDimensionThreshold> items = new ArrayList<>();
+        if (req.items != null) {
+            for (Item item : req.items) {
+                items.add(normalize(item));
+            }
+        }
+        return Result.success(contentDimensionThresholdService.batchSave(req.contentType, req.contentId, items));
+    }
+
+    @Operation(summary = "查询某内容项的维度阈值列表")
+    @PostMapping("/list")
+    public Result<List<ContentDimensionThreshold>> list(@RequestParam String contentType,
+                                                        @RequestParam Long contentId,
+                                                        @RequestAttribute("role") String role) {
+        if (!isAdmin(role)) {
+            return Result.error("无权限:仅平台管理员可查看维度阈值");
+        }
+        if (!validContentType(contentType) || contentId == null) {
+            return Result.error("contentType 必须是 PRODUCT/ARTICLE/ACTIVITY/MINIGAME 且 contentId 不能为空");
+        }
+        return Result.success(contentDimensionThresholdService.listByContent(contentType, contentId));
+    }
+
+    @Operation(summary = "删除单条维度阈值")
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestParam Long id,
+                               @RequestAttribute("role") String role) {
+        if (!isAdmin(role)) {
+            return Result.error("无权限:仅平台管理员可删除维度阈值");
+        }
+        boolean ok = contentDimensionThresholdService.deleteById(id);
+        return ok ? Result.success(null) : Result.error("删除失败");
+    }
+
+    /** 批量保存请求体 */
+    public static class BatchSaveReq {
+        public String contentType;
+        public Long contentId;
+        public List<Item> items;
+    }
+
+    /** 单条阈值配置项 */
+    public static class Item {
+        public String dimensionCode;
+        public String dimensionName;
+        public Integer threshold;
+        public Integer isCustom;
+        public String customName;
+    }
+}

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -64,8 +64,8 @@ public class TaskController {
 
     @Operation(summary = "获取可选择的小游戏列表")
     @PostMapping("/minigame-options")
-    public Result<List<MiniGame>> getMinigameOptions() {
-        List<MiniGame> games = miniGameService.getEnabledGames();
+    public Result<List<MiniGame>> getMinigameOptions(@RequestAttribute(value = "familyId", required = false) Long familyId) {
+        List<MiniGame> games = miniGameService.getEnabledGames(familyId);
         return Result.success(games);
     }
 

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ContentDimensionThreshold.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 内容维度阈值显隐配置(商品/文章/活动/游戏 五维+子项+管理员自定义指标)。
+ *
+ * 显隐规则(OR 逻辑):对某个内容项,若其配置的任一维度上「用户指标值 &lt; 阈值」→ 该内容显示;
+ * 若其配置的全部维度上「用户指标值 ≥ 阈值」→ 该内容不显示(隐藏)。
+ * 「小于阈值显示,大于或等于阈值隐藏」。
+ *
+ * 阈值 0-100,与 FiveDimensionScore.score 同量纲。家庭维度指标取家庭成员平均值。
+ * 自定义指标统一存进 FiveDimensionScore 体系(dimension_type='other'),与五维同表。
+ */
+@Data
+@TableName("content_dimension_threshold")
+public class ContentDimensionThreshold implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 内容类型: PRODUCT | ARTICLE | ACTIVITY | MINIGAME */
+    private String contentType;
+
+    /** 内容ID(对应 products/articles/activities/mini_games 主键) */
+    private Long contentId;
+
+    /** 维度码: 五维码(body/mind/wisdom/action/wealth) / 子项码 / 自定义指标码 */
+    private String dimensionCode;
+
+    /** 展示名(自定义指标即自定义名) */
+    private String dimensionName;
+
+    /** 阈值 0-100 */
+    private Integer threshold;
+
+    /** 1=管理员自定义指标 */
+    private Integer isCustom;
+
+    /** 1=启用 */
+    private Integer enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ContentDimensionThresholdMapper.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 内容维度阈值显隐配置 Mapper。
+ * 查询逻辑统一走 Service 层的 LambdaQueryWrapper(selectByContent/listByContents/batchSave/deleteById),
+ * 无需自定义 XML 方法。
+ */
+@Mapper
+public interface ContentDimensionThresholdMapper extends BaseMapper<ContentDimensionThreshold> {
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -8,6 +8,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ActivityDTO;
 import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.Task;
@@ -64,6 +65,12 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
     @Resource
     private CfEarnDispatchService cfEarnDispatchService;
 
@@ -96,6 +103,8 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         }
         final String level = memberLevel;
         List<Activity> records = pageResult.getRecords();
+        // 内容维度阈值显隐过滤(A-后端):OR 逻辑,fail-open 不误隐藏(游客无家庭上下文时全保留)
+        applyDimensionThresholdFilter(records, userId, isGuest);
         List<Long> activityIds = records.stream().map(Activity::getId).collect(Collectors.toList());
         Map<Long, String> regStatusMap = new HashMap<>();
         Set<Long> checkedInActivityIds = new HashSet<>();
@@ -127,6 +136,28 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         return Result.success(data);
     }
 
+    /**
+     * 内容维度阈值显隐过滤(A-后端)。
+     * 从登录用户上下文取 familyId;游客或无家庭得分数据时 fail-open 不误隐藏。
+     * 显隐规则(OR 逻辑):任一配置维度得分 < 阈值 → 显示;全部 >= 阈值 → 隐藏。
+     */
+    private void applyDimensionThresholdFilter(List<Activity> records, Long userId, boolean isGuest) {
+        if (records == null || records.isEmpty()) return;
+        Long familyId = null;
+        if (!isGuest && userId != null && userId > 0) {
+            try {
+                User u = userMapper.selectById(userId);
+                if (u != null) familyId = u.getFamilyId();
+            } catch (Exception ignore) { /* 取不到 familyId 则 fail-open */ }
+        }
+        Map<String, Integer> familyAvg = fiveDimensionScoreService.getFamilyAverageScores(familyId);
+        if (familyAvg.isEmpty()) return; // 无家庭得分数据 → fail-open
+        List<Long> ids = records.stream().map(Activity::getId).collect(Collectors.toList());
+        Map<Long, List<ContentDimensionThreshold>> byId =
+                contentDimensionThresholdService.listByContents("ACTIVITY", ids);
+        DimensionThresholdFilter.filterVisible(records, Activity::getId, "ACTIVITY", familyAvg, byId);
+    }
+
     public Result<ActivityDTO> detail(Long id, Long userId) {
         Activity activity = this.getById(id);
         if (activity == null) {

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -8,6 +8,7 @@ import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ArticleCategory;
 import com.etotem.cfc.entity.ArticleQuizRecord;
 import com.etotem.cfc.entity.ArticleReadingRecord;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.Product;
@@ -82,6 +83,12 @@ public class ArticleService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
     @Resource
     private JdbcTemplate jdbcTemplate;
 
@@ -135,6 +142,9 @@ public class ArticleService {
             }
         }
 
+        // 内容维度阈值显隐过滤(A-后端):OR 逻辑,fail-open 不误隐藏
+        applyDimensionThresholdFilter(visible, familyId);
+
         enrichTotalDurationSeconds(visible);
 
         // 手动分页(与 getFeatured 相同模式),total 为过滤后的真实总数
@@ -149,6 +159,21 @@ public class ArticleService {
         return filtered;
     }
 
+    /**
+     * 内容维度阈值显隐过滤(A-后端)。
+     * 家庭维度指标取家庭成员平均值;无家庭得分数据 → fail-open 不误隐藏。
+     * 显隐规则(OR 逻辑):任一配置维度得分 < 阈值 → 显示;全部 >= 阈值 → 隐藏。
+     */
+    private void applyDimensionThresholdFilter(List<Article> visible, Long familyId) {
+        if (visible == null || visible.isEmpty()) return;
+        Map<String, Integer> familyAvg = fiveDimensionScoreService.getFamilyAverageScores(familyId);
+        if (familyAvg.isEmpty()) return; // 无家庭得分数据 → fail-open
+        List<Long> ids = visible.stream().map(Article::getId).collect(Collectors.toList());
+        Map<Long, List<ContentDimensionThreshold>> byId =
+                contentDimensionThresholdService.listByContents("ARTICLE", ids);
+        DimensionThresholdFilter.filterVisible(visible, Article::getId, "ARTICLE", familyAvg, byId);
+    }
+
     public List<Article> getFeatured(int size, Long userId, Long familyId, String vendorType, Long memberId) {
         return getFeatured(size, null, userId, familyId, vendorType, memberId);
     }

+ 88 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContentDimensionThresholdService.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
+import com.etotem.cfc.mapper.ContentDimensionThresholdMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class ContentDimensionThresholdService {
+
+    @Resource
+    private ContentDimensionThresholdMapper contentDimensionThresholdMapper;
+
+    /**
+     * 按内容类型+内容ID 删除旧配置并批量插入新配置(Upsert语义)。
+     *
+     * @param contentType PRODUCT|ARTICLE|ACTIVITY|MINIGAME
+     * @param contentId   内容主键
+     * @param items       新阈值配置列表(不含 id)
+     * @return 保存后的阈值列表(含 id 与 updatedAt)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public List<ContentDimensionThreshold> batchSave(String contentType, Long contentId,
+                                                     List<ContentDimensionThreshold> items) {
+        if (contentType == null || contentId == null) {
+            throw new IllegalArgumentException("contentType 与 contentId 不能为空");
+        }
+        if (items == null) items = java.util.Collections.emptyList();
+
+        // 先删旧(幂等:无记录时 delete 返回 0)
+        contentDimensionThresholdMapper.delete(
+                new LambdaQueryWrapper<ContentDimensionThreshold>()
+                        .eq(ContentDimensionThreshold::getContentType, contentType)
+                        .eq(ContentDimensionThreshold::getContentId, contentId));
+
+        Date now = new Date();
+        List<ContentDimensionThreshold> saved = items.stream()
+                .peek(item -> {
+                    if (item.getCreatedAt() == null) item.setCreatedAt(now);
+                    item.setUpdatedAt(now);
+                    if (item.getEnabled() == null) item.setEnabled(1);
+                })
+                .collect(Collectors.toList());
+        if (!saved.isEmpty()) {
+            saved.forEach(contentDimensionThresholdMapper::insert);
+        }
+        return listByContent(contentType, contentId);
+    }
+
+    /** 按内容类型+内容ID 查询阈值列表(按 dimensionCode 排序) */
+    public List<ContentDimensionThreshold> listByContent(String contentType, Long contentId) {
+        if (contentType == null || contentId == null) return java.util.Collections.emptyList();
+        return contentDimensionThresholdMapper.selectList(
+                new LambdaQueryWrapper<ContentDimensionThreshold>()
+                        .eq(ContentDimensionThreshold::getContentType, contentType)
+                        .eq(ContentDimensionThreshold::getContentId, contentId)
+                        .orderByAsc(ContentDimensionThreshold::getDimensionCode));
+    }
+
+    /**
+     * 按内容类型+内容ID列表 批量查询阈值配置。
+     * 用于列表过滤时一次加载全部候选内容的阈值。
+     */
+    public Map<Long, List<ContentDimensionThreshold>> listByContents(String contentType, List<Long> contentIds) {
+        if (contentType == null || contentIds == null || contentIds.isEmpty()) {
+            return java.util.Collections.emptyMap();
+        }
+        return contentDimensionThresholdMapper.selectList(
+                new LambdaQueryWrapper<ContentDimensionThreshold>()
+                        .eq(ContentDimensionThreshold::getContentType, contentType)
+                        .in(ContentDimensionThreshold::getContentId, contentIds))
+                .stream()
+                .collect(Collectors.groupingBy(ContentDimensionThreshold::getContentId));
+    }
+
+    /** 按主键删除单条阈值配置 */
+    public boolean deleteById(Long id) {
+        if (id == null) return false;
+        return contentDimensionThresholdMapper.deleteById(id) > 0;
+    }
+}

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionThresholdFilter.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
+import com.etotem.cfc.util.DimensionCodes;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.function.Function;
+
+/**
+ * 内容维度阈值过滤工具。
+ *
+ * <p>显隐规则(OR 逻辑):对某个内容项,若其配置的任一维度上「用户指标值 &lt; 阈值」→ 该内容显示;
+ * 若其配置的全部维度上「用户指标值 ≥ 阈值」→ 该内容不显示(隐藏)。
+ * 「小于阈值显示,大于或等于阈值隐藏」。
+ *
+ * <p>Fail-open:
+ * <ul>
+ *   <li>无阈值配置 → 保留</li>
+ *   <li>无得分数据(familyAvgScores 取不到对应 key)→ 视为无分,按「显示」处理</li>
+ *   <li>familyAvgScores / thresholdsByContentId 为 null/空 → 全部保留</li>
+ * </ul>
+ *
+ * <p>使用方式(内存循环,在 service 层 selectPage 之后、返回 DTO 之前调用):
+ * <pre>{@code
+ * Map<String, Integer> familyAvg = fiveDimensionScoreService.getFamilyAverageScores(familyId);
+ * List<Long> ids = records.stream().map(Item::getId).collect(toList());
+ * Map<Long, List<ContentDimensionThreshold>> byId = thresholdService.listByContents(contentType, ids);
+ * DimensionThresholdFilter.filterVisible(records, Item::getId, contentType, familyAvg, byId);
+ * }</pre>
+ */
+@Component
+public class DimensionThresholdFilter {
+
+    /**
+     * 对 items 就地移除满足「全部维度得分 >= 阈值」的内容项(即隐藏)。
+     *
+     * @param items                  待过滤列表(in-place 修改)
+     * @param idGetter               从 item 提取 contentId
+     * @param contentType            内容类型 PRODUCT|ARTICLE|ACTIVITY|MINIGAME
+     * @param familyAvgScores        家庭平均分 Map&lt;dimensionCode, score&gt;(key 含五维码/子项码/自定义码)
+     * @param thresholdsByContentId  contentId → 该内容的阈值列表
+     */
+    @SuppressWarnings("unchecked")
+    public static <T> void filterVisible(List<T> items, Function<T, Long> idGetter,
+                                         String contentType,
+                                         Map<String, Integer> familyAvgScores,
+                                         Map<Long, List<ContentDimensionThreshold>> thresholdsByContentId) {
+        if (items == null || items.isEmpty()) return;
+        if (familyAvgScores == null) {
+            // 无得分数据 → fail-open 全部保留
+            return;
+        }
+        if (thresholdsByContentId == null || thresholdsByContentId.isEmpty()) {
+            // 无阈值配置 → fail-open 全部保留
+            return;
+        }
+        items.removeIf(item -> {
+            Long id = idGetter.apply(item);
+            if (id == null) return false;
+            List<ContentDimensionThreshold> ths = thresholdsByContentId.get(id);
+            if (ths == null || ths.isEmpty()) return false; // 无阈值 → 保留
+            return shouldHide(ths, familyAvgScores);
+        });
+    }
+
+    /**
+     * 全部维度得分 ≥ 阈值 → 返回 true(隐藏);任一维度得分 &lt; 阈值 → 返回 false(保留)。
+     * 任何维度得分缺失 → fail-open,视为「显示」。
+     */
+    private static boolean shouldHide(List<ContentDimensionThreshold> ths, Map<String, Integer> scores) {
+        for (ContentDimensionThreshold t : ths) {
+            if (t == null || t.getDimensionCode() == null || t.getThreshold() == null) continue;
+            Integer v = scores.get(t.getDimensionCode());
+            if (v == null) return false; // 无分 → fail-open
+            if (v < t.getThreshold()) return false; // 任一 < 阈值 → 保留
+        }
+        return true; // 全部 >= 阈值 → 隐藏
+    }
+
+    // ---- detailJson 子项展开工具(供 FiveDimensionScoreService 调用) ----
+
+    /**
+     * 把 FiveDimensionScore.detailJson 中所有「非五维码」的数值叶子字段展开到 code→值映射。
+     * 支持顶层平铺 JSON 与嵌套 JSON 对象/数组混合形态。
+     * 遇到已收录的 (memberId, code) 只保留首次(最新)一条。
+     *
+     * @param detailJson detail_json 字段字符串
+     * @param memberId   成员 ID
+     * @param codeToMemberScore code → (memberId → score) 累积 Map(直接修改)
+     */
+    public static void expandDetailJson(String detailJson, Long memberId,
+                                        Map<String, Map<Long, Integer>> codeToMemberScore) {
+        if (detailJson == null || detailJson.isEmpty()) return;
+        try {
+            Object root = JSON.parse(detailJson);
+            collectNumericLeaves(root, memberId, codeToMemberScore);
+        } catch (Exception ignored) {
+            // 非法 JSON 静默忽略,避免影响主逻辑
+        }
+    }
+
+    private static void collectNumericLeaves(Object node, Long memberId,
+                                             Map<String, Map<Long, Integer>> codeToMemberScore) {
+        if (node == null) return;
+        if (node instanceof JSONObject) {
+            JSONObject obj = (JSONObject) node;
+            for (String key : obj.keySet()) {
+                if (DimensionCodes.isValid(key)) continue; // 五维码跳过(已在主记录聚合)
+                Object val = obj.get(key);
+                if (val instanceof Number) {
+                    codeToMemberScore.computeIfAbsent(key, k -> new HashMap<>())
+                            .putIfAbsent(memberId, ((Number) val).intValue());
+                } else {
+                    collectNumericLeaves(val, memberId, codeToMemberScore);
+                }
+            }
+        } else if (node instanceof JSONArray) {
+            JSONArray arr = (JSONArray) node;
+            for (int i = 0; i < arr.size(); i++) {
+                collectNumericLeaves(arr.get(i), memberId, codeToMemberScore);
+            }
+        }
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java

@@ -93,6 +93,52 @@ public class FiveDimensionScoreService {
         return result;
     }
 
+    /**
+     * 获取家庭全部成员的维度指标平均值 Map&lt;dimensionCode, 平均分&gt;。
+     *
+     * <p>实现(家庭平均,指标量纲 0-100):
+     * <ol>
+     *   <li>取该家庭 five_dimension_scores 全部记录(含五维码 + 自定义 'other' 记录)。</li>
+     *   <li>对每个成员,按 dimensionCode 取 assessedAt 最新一条,再按 dimensionCode 跨成员求平均(四舍五入取整)。</li>
+     *   <li>同时展开每条记录的 detailJson 中的子项/自定义指标分数进同一 Map(key=子项码/自定义码)。</li>
+     *   <li>返回含「五维码 + 自定义码 + 子项码 → 平均分」的统一 Map。</li>
+     * </ol>
+     * 若某成员某维度无记录,则该维度按该成员缺席处理(不参与该维度平均,而非按 0 计),
+     * 避免缺数据时把平均分拉低而误触发「隐藏」。若某子项码在 detailJson 中无数据,则该子项视为「无分」(调用方 fail-open)。
+     *
+     * @param familyId 家庭ID(为 null 时返回空 Map,调用方 fail-open 不隐藏)
+     * @return dimensionCode → 家庭平均分
+     */
+    public Map<String, Integer> getFamilyAverageScores(Long familyId) {
+        Map<String, Integer> result = new HashMap<>();
+        if (familyId == null) return result;
+
+        List<FiveDimensionScore> all = fiveDimensionScoreMapper.selectList(
+                new LambdaQueryWrapper<FiveDimensionScore>()
+                        .eq(FiveDimensionScore::getFamilyId, familyId)
+                        .orderByDesc(FiveDimensionScore::getAssessedAt));
+        if (all == null || all.isEmpty()) return result;
+
+        // code → (memberId → score),putIfAbsent 保留每 member+code 的最新一条(all 已按 assessedAt desc)
+        Map<String, Map<Long, Integer>> codeToMemberScore = new HashMap<>();
+        for (FiveDimensionScore s : all) {
+            if (s.getMemberId() == null || s.getDimensionCode() == null) continue;
+            codeToMemberScore.computeIfAbsent(s.getDimensionCode(), k -> new HashMap<>())
+                    .putIfAbsent(s.getMemberId(), s.getScore());
+            // 展开 detailJson 中的子项/自定义指标(非五维码)分数
+            DimensionThresholdFilter.expandDetailJson(s.getDetailJson(), s.getMemberId(), codeToMemberScore);
+        }
+
+        // 跨成员平均(四舍五入取整)
+        for (Map.Entry<String, Map<Long, Integer>> e : codeToMemberScore.entrySet()) {
+            Collection<Integer> vals = e.getValue().values();
+            if (vals.isEmpty()) continue;
+            double avg = vals.stream().mapToInt(Integer::intValue).average().orElse(0);
+            result.put(e.getKey(), (int) Math.round(avg));
+        }
+        return result;
+    }
+
     /** 获取某个成员在指定时间范围内的所有维度记录 */
     public List<FiveDimensionScore> getMemberHistory(Long memberId, String memberType,
                                                      Date fromDate, Date toDate) {

+ 39 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/MiniGameService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.service.api.MiniGameServiceInterface;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.MiniGame;
 import com.etotem.cfc.entity.PointsLog;
@@ -35,15 +36,47 @@ public class MiniGameService implements MiniGameServiceInterface {
     @Resource
     private GameRecordService gameRecordService;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
     /**
-     * 获取所有启用的小游戏
+     * 获取所有启用的小游戏(无家庭上下文 → fail-open 全保留)
      */
+    @Override
     public List<MiniGame> getEnabledGames() {
-        return miniGameMapper.selectList(
-            new LambdaQueryWrapper<MiniGame>()
-                .eq(MiniGame::getStatus, 1)
-                .orderByAsc(MiniGame::getSortOrder)
-        );
+        return getEnabledGames(null);
+    }
+
+    /**
+     * 获取所有启用的小游戏,并按内容维度阈值显隐过滤(A-后端)。
+     * 游戏无 dimension 字段,纯靠 content_dimension_threshold 表关联(content_type='MINIGAME')。
+     *
+     * @param familyId 家庭ID(为 null 时 fail-open 全保留)
+     */
+    public List<MiniGame> getEnabledGames(Long familyId) {
+        List<MiniGame> games = miniGameMapper.selectList(
+                new LambdaQueryWrapper<MiniGame>()
+                        .eq(MiniGame::getStatus, 1)
+                        .orderByAsc(MiniGame::getSortOrder));
+        applyDimensionThresholdFilter(games, familyId);
+        return games;
+    }
+
+    /**
+     * 内容维度阈值显隐过滤(A-后端):OR 逻辑,fail-open 不误隐藏。
+     * 显隐规则:任一配置维度得分 < 阈值 → 显示;全部 >= 阈值 → 隐藏。
+     */
+    private void applyDimensionThresholdFilter(List<MiniGame> games, Long familyId) {
+        if (games == null || games.isEmpty()) return;
+        Map<String, Integer> familyAvg = fiveDimensionScoreService.getFamilyAverageScores(familyId);
+        if (familyAvg.isEmpty()) return; // 无家庭得分数据 → fail-open
+        List<Long> ids = games.stream().map(MiniGame::getId).collect(java.util.stream.Collectors.toList());
+        Map<Long, List<ContentDimensionThreshold>> byId =
+                contentDimensionThresholdService.listByContents("MINIGAME", ids);
+        DimensionThresholdFilter.filterVisible(games, MiniGame::getId, "MINIGAME", familyAvg, byId);
     }
 
     /**

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -64,6 +64,12 @@ public class ProductRecommendationService {
     @Resource
     private ArticleMapper articleMapper;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
     /**
      * 维度页推荐
      *
@@ -212,6 +218,20 @@ public class ProductRecommendationService {
             }
         }
 
+        // 内容维度阈值显隐过滤(A-后端):OR 逻辑(任一维度得分 < 阈值 → 保留),fail-open 不误隐藏
+        Map<String, Integer> familyAvg = fiveDimensionScoreService.getFamilyAverageScores(familyId);
+        if (!familyAvg.isEmpty() && !candidates.isEmpty()) {
+            List<Long> pids = candidates.stream()
+                    .map(c -> c.get("id") != null ? ((Number) c.get("id")).longValue() : null)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toList());
+            Map<Long, List<ContentDimensionThreshold>> byId =
+                    contentDimensionThresholdService.listByContents("PRODUCT", pids);
+            DimensionThresholdFilter.filterVisible(candidates,
+                    c -> c.get("id") != null ? ((Number) c.get("id")).longValue() : null,
+                    "PRODUCT", familyAvg, byId);
+        }
+
         // 5. 最终排序并返回
         return sortAndReturn(candidates, limit, userId, familyId);
     }

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.dto.MembershipLevelDTO;
 import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.AssessmentProduct;
+import com.etotem.cfc.entity.ContentDimensionThreshold;
 import com.etotem.cfc.entity.GrowthArchiveProduct;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductBundleItem;
@@ -76,6 +77,12 @@ public class ProductService {
     @Resource
     private ProductSkuMapper productSkuMapper;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
     public Product getById(Long id) {
         return productMapper.selectById(id);
     }
@@ -229,6 +236,9 @@ wrapper.in(Product::getId, productIds)
         List<ProductDTO> records = result.getRecords().stream()
             .map(p -> ProductDTO.from(p, isGuest, level))
             .collect(Collectors.toList());
+        // 内容维度阈值显隐过滤(A-后端):任一配置维度得分 < 阈值 → 显示;全部 >= 阈值 → 隐藏。
+        // 无阈值配置 / 无得分数据 → fail-open 不误隐藏。familyId 从登录用户上下文取,游客跳过。
+        applyDimensionThresholdFilter(records, userId, isGuest);
         // 批量查询赠送规则,避免 N+1(列表页优化)
         List<Long> productIds = result.getRecords().stream()
             .map(Product::getId)
@@ -251,6 +261,27 @@ wrapper.in(Product::getId, productIds)
         return Result.success(data);
     }
 
+    /**
+     * 内容维度阈值显隐过滤(A-后端)。
+     * 从登录用户上下文取 familyId;游客或无家庭得分数据时 fail-open 不隐藏。
+     * 显隐规则(OR 逻辑):任一配置维度得分 < 阈值 → 显示;全部 >= 阈值 → 隐藏。
+     */
+    private void applyDimensionThresholdFilter(List<ProductDTO> records, Long userId, boolean isGuest) {
+        if (records == null || records.isEmpty()) return;
+        Long familyId = null;
+        if (!isGuest && userId != null && userId > 0) {
+            try {
+                User u = userMapper.selectById(userId);
+                if (u != null) familyId = u.getFamilyId();
+            } catch (Exception ignore) { /* 取不到 familyId 则 fail-open */ }
+        }
+        Map<String, Integer> familyAvg = fiveDimensionScoreService.getFamilyAverageScores(familyId);
+        if (familyAvg.isEmpty()) return; // 无家庭得分数据 → fail-open
+        List<Long> pids = records.stream().map(ProductDTO::getId).collect(Collectors.toList());
+        Map<Long, List<ContentDimensionThreshold>> byId = contentDimensionThresholdService.listByContents("PRODUCT", pids);
+        DimensionThresholdFilter.filterVisible(records, ProductDTO::getId, "PRODUCT", familyAvg, byId);
+    }
+
     public Result<ProductDTO> detail(Long id, Long userId) {
         Product product = productMapper.selectById(id);
         if (product == null) {

+ 16 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6920,3 +6920,19 @@ CREATE TABLE IF NOT EXISTS audit_log (
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间',
     KEY idx_module_target_created (module, target_id, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)';
+
+-- 内容维度阈值显隐配置表(A-后端:商品/文章/活动/游戏 五维+子项+自定义指标)
+-- 显隐规则(OR 逻辑):任一维度得分 < 阈值 → 显示;全部维度得分 >= 阈值 → 隐藏
+CREATE TABLE IF NOT EXISTS content_dimension_threshold (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    content_type VARCHAR(20) NOT NULL COMMENT '内容类型: PRODUCT/ARTICLE/ACTIVITY/MINIGAME',
+    content_id BIGINT NOT NULL COMMENT '内容ID(products/articles/activities/mini_games 主键)',
+    dimension_code VARCHAR(50) NOT NULL COMMENT '维度码: 五维码/子项码/自定义指标码',
+    dimension_name VARCHAR(100) COMMENT '展示名(自定义指标即自定义名)',
+    threshold INT NOT NULL COMMENT '阈值 0-100',
+    is_custom TINYINT DEFAULT 0 COMMENT '1=管理员自定义指标',
+    enabled TINYINT DEFAULT 1 COMMENT '1=启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_content_dim (content_type, content_id, dimension_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容维度阈值显隐配置';