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

feat(ai): AI 使用会员门禁后端 T-B0/T-B1/T-B2(任务书 85e93fb56)

按 docs/superpowers/specs/2026-11-02-ai-member-gate-design.md 实现:
- T-B0 门禁核心:MembershipService 新增 isAiMemberGateEnabled/checkAiFeatureGate/
  getMemberGateInfo;新增 AiGateStatus 枚举与 AiGateResult DTO;判定用
  effectiveRank+isActive+everPaid 原始维度,区分「过期」与「从未付费」。
- T-B1 控制器接线:/chat/send、/nutrition/send、/health-coach/send(等级下限
  FAMILY/PREMIUM 收敛为 BASIC+)、/butler/send 前置门禁;新增 POST /api/ai/gate
  (含 features 明细);保留 /health-coach/gate 兼容;错误码 46003/46004。
- T-B2 ButlerController:/sessions/create 加门禁;list/detail/archive/delete/
  update-conversation 保持开放(过期可查看/删除/归档历史会话)。
- DatabaseInitializer 同步 ai.coach.member_gate_enabled 种子描述文案(不改键/值)。
- 不纳入门禁端点零改动:/report/analyze、情绪识别、舌诊、饮食识别;
  AiGateway.chat/consumeMemberQuota 配额链零改动。
- 测试:新增 MembershipServiceAiGateTest(12)、ButlerControllerGateTest(9)、
  更新 AIChatControllerGateTest(18)与 AIChatControllerTest(mock 门禁放行)。

验证:mvn compile BUILD SUCCESS;上述 4 个测试类 56/56 通过。
Sisyphus 1 неделя назад
Родитель
Сommit
9e1fc2f255

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1100,7 +1100,18 @@ public class DatabaseInitializer implements CommandLineRunner {
             insertSysConfigSeed("trial_days", "7", "试用天数");
             insertSysConfigSeed("trial_features", "[\"free_activities\",\"free_courses\",\"teacher_consultation\"]", "试用期间可用功能");
             insertSysConfigSeed("member_pay_virtual_enabled", "1", "会员支付是否使用虚拟支付通道(1=虚拟,0=普通微信)");
-            insertSysConfigSeed("ai.coach.member_gate_enabled", "true", "小程序AI教练会员付费开关(true=开启会员门禁,false=所有登录用户可用)");
+            insertSysConfigSeed("ai.coach.member_gate_enabled", "true", "小程序AI使用会员门禁主开关(true=开启门禁,仅有效期内BASIC及以上会员可用全部AI功能;false=所有登录用户可用)");
+            // 任务书 2026-11-02-ai-member-gate-design:开关语义由「仅AI教练」扩展为「全部AI使用功能」,
+            // 仅同步已存在记录的 description 文案,不改 config_key / config_value(幂等,可重复执行)
+            try {
+                jdbcTemplate.update(
+                        "UPDATE sys_config SET description = ? WHERE config_key = ? AND (description IS NULL OR description <> ?)",
+                        "小程序AI使用会员门禁主开关(true=开启门禁,仅有效期内BASIC及以上会员可用全部AI功能;false=所有登录用户可用)",
+                        "ai.coach.member_gate_enabled",
+                        "小程序AI使用会员门禁主开关(true=开启门禁,仅有效期内BASIC及以上会员可用全部AI功能;false=所有登录用户可用)");
+            } catch (Exception e) {
+                // 描述文案同步失败不影响主流程,忽略
+            }
 insertSysConfigSeed("gift_membership_threshold", "198000", "购买送会员商品累计达到该金额(分)赠送会员");
 insertSysConfigSeed("gift_membership_level", "FAMILY", "达到送会员额度后赠送的会员等级");
 insertSysConfigSeed("gift_membership_original_threshold", "1000000", "送会员商品原价达标阈值(分),达到此金额后送会员商品部分实付一口价");

+ 90 - 60
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -1,10 +1,12 @@
 package com.etotem.cfc.controller.ai;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AiGateResult;
 import com.etotem.cfc.dto.HealthAnalysisResult;
 import com.etotem.cfc.dto.RecommendationQuery;
 import com.etotem.cfc.dto.RecommendationResult;
 import com.etotem.cfc.dto.TaskParseResult;
+import com.etotem.cfc.enums.AiGateStatus;
 import com.etotem.cfc.service.AIService;
 import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.FamilyContextService;
@@ -14,7 +16,6 @@ import com.etotem.cfc.service.SelfCheckAnalysisService;
 import com.etotem.cfc.service.TaskParseService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.MembershipService;
-import com.etotem.cfc.service.SysConfigService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
@@ -65,9 +66,6 @@ public class AIChatController {
     @Resource
     private MembershipService membershipService;
 
-    @Resource
-    private SysConfigService sysConfigService;
-
     @Resource
     private com.etotem.cfc.service.ChatMirrorService chatMirrorService;
 
@@ -86,54 +84,56 @@ public class AIChatController {
     @Resource
     private RedisTemplate<String, Object> redisTemplate;
 
-    /**
-     * AI 教练会员付费开关配置键(sys_config)。
-     * "true" = 开启会员门禁(非会员不可用);"false" = 关闭门禁(所有登录用户可用)。
-     * 缺失时按开启(member-only)处理,属于付费开关的默认安全口径。
-     */
-    private static final String AI_COACH_MEMBER_GATE_KEY = "ai.coach.member_gate_enabled";
-
-    /** 会员专享业务错误码:前端据此展示会员开通付费墙 */
+    /** 会员专享业务错误码:前端据此展示会员开通付费墙(UPGRADE_REQUIRED) */
     private static final int ERR_AI_COACH_MEMBER_ONLY = 46003;
 
+    /** 会员已过期业务错误码:前端据此展示续费引导(MEMBER_EXPIRED) */
+    private static final int ERR_AI_MEMBER_EXPIRED = 46004;
+
     /** AI 教练对应的会员权益功能码 */
     private static final String AI_COACH_FEATURE = "mascot_ai";
 
     /**
-     * 判断 AI 教练会员付费开关是否开启。
-     * 读取 sys_config.ai.coach.member_gate_enabled;缺失或解析异常时默认开启(member-only)。
+     * 门禁拦截统一响应:过期 → 46004;从未付费 → 46003。
      */
-    private boolean isAiCoachMemberGateEnabled() {
-        try {
-            String v = sysConfigService.getValue(AI_COACH_MEMBER_GATE_KEY);
-            if (v == null || v.trim().isEmpty()) {
-                return true;
-            }
-            return "true".equalsIgnoreCase(v.trim()) || "1".equals(v.trim());
-        } catch (Exception e) {
-            log.warn("读取AI教练会员开关失败,按开启处理: {}", e.getMessage());
-            return true;
+    private <T> Result<T> gateError(AiGateResult gate) {
+        if (gate != null && gate.getStatus() == AiGateStatus.MEMBER_EXPIRED) {
+            return Result.error(ERR_AI_MEMBER_EXPIRED, "会员已过期,请续费后继续使用");
         }
+        return Result.error(ERR_AI_COACH_MEMBER_ONLY, "该功能为会员专享,请先开通会员");
     }
 
     /**
-     * 判断用户是否有权使用 AI 教练功能。
-     * 开关关闭 → 所有登录用户可用;开关开启 → 需 FAMILY/PREMIUM 有效会员。
+     * 门禁校验:返回 null 表示放行,否则返回拦截响应。
+     * 原 canUseAiCoach()(仅 FAMILY/PREMIUM)已收敛到此处统一门禁(BASIC+ 且需在有效期内)。
      */
-    private boolean canUseAiCoach(Long userId) {
-        if (!isAiCoachMemberGateEnabled()) {
-            return true;
-        }
-        if (userId == null || userId <= 0) {
-            return false;
-        }
-        try {
-            String level = membershipService.getMemberLevel(userId);
-            return "FAMILY".equals(level) || "PREMIUM".equals(level);
-        } catch (Exception e) {
-            log.warn("检查AI教练会员权限失败,按无权限处理: {}", e.getMessage());
-            return false;
+    private <T> Result<T> checkGate(Long userId) {
+        AiGateResult gate = membershipService.checkAiFeatureGate(userId);
+        if (gate == null || !gate.isCanUse()) {
+            return gateError(gate);
         }
+        return null;
+    }
+
+    /**
+     * 组装 /api/ai/gate 的 features 明细:
+     * 纳入门禁的 4 项同源同 status;不纳入门禁的 4 项固定放行。
+     */
+    private Map<String, AiGateResult.FeatureGate> buildGateFeatures(AiGateResult gate) {
+        boolean canUse = gate != null && gate.isCanUse();
+        String reason = gate != null ? gate.getReason() : AiGateStatus.UPGRADE_REQUIRED.name();
+        AiGateResult.FeatureGate gated = new AiGateResult.FeatureGate(canUse, canUse ? null : reason);
+
+        Map<String, AiGateResult.FeatureGate> features = new LinkedHashMap<>();
+        features.put("chat", gated);
+        features.put("nutrition", gated);
+        features.put("healthCoach", gated);
+        features.put("butler", gated);
+        features.put("reportAnalyze", AiGateResult.FeatureGate.allow());
+        features.put("emotion", AiGateResult.FeatureGate.allow());
+        features.put("tongue", AiGateResult.FeatureGate.allow());
+        features.put("diet", AiGateResult.FeatureGate.allow());
+        return features;
     }
 
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
@@ -148,6 +148,12 @@ public class AIChatController {
             return Result.error("消息不能为空");
         }
 
+        // AI 使用会员门禁(前置;放行后仍走原配额链 AiGateway/consumeMemberQuota)
+        Result<Map<String, Object>> gateBlocked = checkGate(userId);
+        if (gateBlocked != null) {
+            return gateBlocked;
+        }
+
         // 复用上下文组装(mascot + 家庭/报告 + 画像 + 自检 + 记忆层)
         Map<String, Object> inputs = buildChatInputs(userId, params);
 
@@ -427,6 +433,12 @@ public class AIChatController {
             return Result.error("需要reportId进行营养分析");
         }
 
+        // AI 使用会员门禁(参数校验后、分析前)
+        Result<Map<String, Object>> gateBlocked = checkGate(userId);
+        if (gateBlocked != null) {
+            return gateBlocked;
+        }
+
         // 获取用户mascot设置
         com.etotem.cfc.entity.User user = userService.getUserInfo(userId);
         String mascotCode = user != null ? user.getMascot() : null;
@@ -619,9 +631,10 @@ public class AIChatController {
         if (query == null || query.trim().isEmpty()) {
             return Result.error("消息不能为空");
         }
-        // 会员付费门禁:开关开启时,非会员不可使用 AI 教练
-        if (!canUseAiCoach(userId)) {
-            return Result.error(ERR_AI_COACH_MEMBER_ONLY, "AI 教练为会员专享功能,请先开通会员");
+        // 会员付费门禁:有效期内 BASIC 及以上会员可用(开关关闭时对所有登录用户放行)
+        Result<Map<String, Object>> gateBlocked = checkGate(userId);
+        if (gateBlocked != null) {
+            return gateBlocked;
         }
         Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
         Map<String, Object> inputs = new LinkedHashMap<>();
@@ -661,34 +674,46 @@ public class AIChatController {
     }
 
     /**
-     * AI 教练会员付费开关状态(小程序入口页调用,用于决定是否展示付费墙)。
+     * AI 使用会员门禁统一状态接口(新):返回 AiGateResult + features 明细。
+     * 前端一次请求即可渲染会员状态条与功能锁。
+     */
+    @Operation(summary = "获取AI使用会员门禁状态(全量功能)")
+    @PostMapping("/gate")
+    public Result<AiGateResult> aiGateStatus(@RequestAttribute("userId") Long userId) {
+        AiGateResult gate = membershipService.checkAiFeatureGate(userId);
+        if (gate == null) {
+            gate = new AiGateResult();
+            gate.setStatus(AiGateStatus.ALLOW);
+            gate.setEnabled(true);
+            gate.setCanUse(true);
+            gate.setCanViewHistory(true);
+            gate.setMemberLevel("FREE");
+            gate.setIsActive(false);
+            gate.setDaysRemaining(0L);
+        }
+        gate.setFeatures(buildGateFeatures(gate));
+        return Result.success(gate);
+    }
+
+    /**
+     * AI 教练会员付费开关状态(兼容保留,前端新代码统一改调 /api/ai/gate)。
      * enabled=true 表示开关开启(会员门禁生效);canUse=false 表示当前用户被拦截。
      */
     @Operation(summary = "获取AI教练会员付费开关状态")
     @PostMapping("/health-coach/gate")
     public Result<Map<String, Object>> aiCoachGateStatus(@RequestAttribute("userId") Long userId) {
-        boolean enabled = isAiCoachMemberGateEnabled();
-        boolean canUse = canUseAiCoach(userId);
+        AiGateResult gate = membershipService.checkAiFeatureGate(userId);
+        boolean enabled = gate != null && gate.isEnabled();
+        boolean canUse = gate != null && gate.isCanUse();
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("enabled", enabled);
         result.put("canUse", canUse);
         result.put("memberOnly", enabled);
         result.put("feature", AI_COACH_FEATURE);
-        String level = "FREE";
-        Object expire = null;
-        if (userId != null && userId > 0) {
-            try {
-                com.etotem.cfc.entity.User u = userService.getUserInfo(userId);
-                if (u != null) {
-                    level = u.getMemberLevel() != null ? u.getMemberLevel() : "FREE";
-                    expire = u.getMemberExpireTime();
-                }
-            } catch (Exception e) {
-                log.warn("读取用户会员信息失败: {}", e.getMessage());
-            }
-        }
-        result.put("memberLevel", level);
-        result.put("memberExpireTime", expire);
+        result.put("status", gate != null ? gate.getStatus() : AiGateStatus.UPGRADE_REQUIRED);
+        result.put("canViewHistory", gate != null && gate.isCanViewHistory());
+        result.put("memberLevel", gate != null && gate.getMemberLevel() != null ? gate.getMemberLevel() : "FREE");
+        result.put("memberExpireTime", gate != null ? gate.getMemberExpireTime() : null);
         return Result.success(result);
     }
 
@@ -704,6 +729,11 @@ public class AIChatController {
         if (query == null || query.trim().isEmpty()) {
             return Result.error("消息不能为空");
         }
+        // AI 使用会员门禁
+        Result<Map<String, Object>> gateBlocked = checkGate(userId);
+        if (gateBlocked != null) {
+            return gateBlocked;
+        }
         Map<String, Object> inputs = new LinkedHashMap<>();
         if (params.get("memberId") != null) {
             inputs.put("child_id", Long.valueOf(params.get("memberId").toString()));

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/ButlerController.java

@@ -1,8 +1,11 @@
 package com.etotem.cfc.controller.ai;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AiGateResult;
 import com.etotem.cfc.entity.ButlerSession;
+import com.etotem.cfc.enums.AiGateStatus;
 import com.etotem.cfc.service.ButlerSessionService;
+import com.etotem.cfc.service.MembershipService;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -21,11 +24,36 @@ public class ButlerController {
     @Resource
     private ButlerSessionService butlerSessionService;
 
+    @Resource
+    private MembershipService membershipService;
+
+    /** 会员专享业务错误码(UPGRADE_REQUIRED,与 AIChatController 同码) */
+    private static final int ERR_AI_COACH_MEMBER_ONLY = 46003;
+
+    /** 会员已过期业务错误码(MEMBER_EXPIRED,与 AIChatController 同码) */
+    private static final int ERR_AI_MEMBER_EXPIRED = 46004;
+
+    /**
+     * AI 管家新建会话门禁:仅有效期内 BASIC 及以上会员可新建。
+     * 历史会话的 list/detail/archive/delete/update-conversation 保持开放(决策 3)。
+     */
+    private <T> Result<T> gateError(AiGateResult gate) {
+        if (gate != null && gate.getStatus() == AiGateStatus.MEMBER_EXPIRED) {
+            return Result.error(ERR_AI_MEMBER_EXPIRED, "会员已过期,请续费后继续使用");
+        }
+        return Result.error(ERR_AI_COACH_MEMBER_ONLY, "该功能为会员专享,请先开通会员");
+    }
+
     @Operation(summary = "创建新会话")
     @PostMapping("/sessions/create")
     public Result<Map<String, Object>> createSession(
             @RequestAttribute("userId") Long userId,
             @RequestBody Map<String, String> params) {
+        // 门禁:新建会话=新建对话入口,FREE/过期会员拦截(历史会话不受影响)
+        AiGateResult gate = membershipService.checkAiFeatureGate(userId);
+        if (gate == null || !gate.isCanUse()) {
+            return gateError(gate);
+        }
         String title = params.getOrDefault("title", "新会话");
         String context = params.get("context");
         Long sessionId = butlerSessionService.createSession(userId, title, context);

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AiGateResult.java

@@ -0,0 +1,80 @@
+package com.etotem.cfc.dto;
+
+import com.etotem.cfc.enums.AiGateStatus;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * AI 使用会员门禁判定结果(任务书 2026-11-02-ai-member-gate-design §1.1 / §2)。
+ *
+ * <p>由 {@code MembershipService#checkAiFeatureGate(Long)} 产出,供
+ * {@code /api/ai/gate} 与各 AI 发送端点复用。</p>
+ */
+@Data
+public class AiGateResult implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 三态结果:ALLOW / UPGRADE_REQUIRED / MEMBER_EXPIRED */
+    private AiGateStatus status;
+
+    /** 门禁开关是否开启(false = 未启用门禁,不拦截) */
+    private boolean enabled;
+
+    /** 门禁功能当前是否可用 */
+    private boolean canUse;
+
+    /** 是否允许查看/删除/归档历史会话(过期 = true) */
+    private boolean canViewHistory;
+
+    /** 归一化有效等级(getMemberLevel 结果,用于展示,与 /api/membership/my 口径一致) */
+    private String memberLevel;
+
+    /** 当前是否存在有效付费记录(paid 且 endDate > now);用 Boolean 以便 JSON 字段名为 isActive */
+    private Boolean isActive;
+
+    /** 个人到期时间(展示用) */
+    private Date memberExpireTime;
+
+    /** 生效的到期时间(个人或家庭,取对应源;过期时返回最后到期时间) */
+    private Date endDate;
+
+    /** 剩余天数(isActive 时有效,最小 0) */
+    private Long daysRemaining;
+
+    /** UPGRADE_REQUIRED / MEMBER_EXPIRED / null */
+    private String reason;
+
+    /**
+     * 各功能门禁明细,仅 /api/ai/gate 填充(其他端点返回 null 不序列化)。
+     * 纳入门禁的 chat/nutrition/healthCoach/butler 同源同 status;
+     * reportAnalyze/emotion/tongue/diet 固定 canUse=true(保留 FREE 引流钩子)。
+     */
+    private Map<String, FeatureGate> features;
+
+    /** 单个功能的门禁状态 */
+    @Data
+    public static class FeatureGate implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private boolean canUse;
+
+        private String reason;
+
+        public FeatureGate() {
+        }
+
+        public FeatureGate(boolean canUse, String reason) {
+            this.canUse = canUse;
+            this.reason = reason;
+        }
+
+        public static FeatureGate allow() {
+            return new FeatureGate(true, null);
+        }
+    }
+}

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/enums/AiGateStatus.java

@@ -0,0 +1,17 @@
+package com.etotem.cfc.enums;
+
+/**
+ * AI 使用会员门禁三态结果。
+ *
+ * <p>见任务书 docs/superpowers/specs/2026-11-02-ai-member-gate-design.md §1.1:</p>
+ * <ul>
+ *   <li>{@link #ALLOW}:可用(开关关闭 / 有效付费会员且等级达到 BASIC+)</li>
+ *   <li>{@link #UPGRADE_REQUIRED}:从未付费(FREE),需开通会员</li>
+ *   <li>{@link #MEMBER_EXPIRED}:曾付费但已过期,需续费;历史会话仍可查看/删除/归档</li>
+ * </ul>
+ */
+public enum AiGateStatus {
+    ALLOW,
+    UPGRADE_REQUIRED,
+    MEMBER_EXPIRED
+}

+ 208 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.dto.*;
 import com.etotem.cfc.entity.*;
+import com.etotem.cfc.enums.AiGateStatus;
 import com.etotem.cfc.mapper.*;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -1359,6 +1360,213 @@ public class MembershipService implements MembershipServiceInterface {
         return personal;
     }
 
+    // ==================== AI 使用会员门禁(任务书 2026-11-02-ai-member-gate-design) ====================
+
+    /**
+     * AI 会员门禁总开关配置键(sys_config)。
+     * 键名与原「AI 教练」开关一致(复用,不新建开关),语义已扩展为「全部 AI 使用功能门禁主开关」。
+     * "true" = 开启门禁(仅有效期内 BASIC 及以上会员可用);"false" = 所有登录用户可用。
+     */
+    public static final String AI_MEMBER_GATE_CONFIG_KEY = "ai.coach.member_gate_enabled";
+
+    private static final String GATE_LEVEL_FREE = "FREE";
+    private static final String GATE_LEVEL_BASIC = "BASIC";
+    /** 服务商为独立角色,不构成会员等级身份,不参与付费判定 */
+    private static final String GATE_LEVEL_PROVIDER = "PROVIDER";
+
+    /**
+     * 判断 AI 会员门禁开关是否开启(读 sys_config.ai.coach.member_gate_enabled)。
+     * 缺失/解析异常时默认开启(沿用现有付费开关的安全口径)。
+     */
+    public boolean isAiMemberGateEnabled() {
+        try {
+            String v = sysConfigService.getValue(AI_MEMBER_GATE_CONFIG_KEY);
+            if (v == null || v.trim().isEmpty()) {
+                return true;
+            }
+            String trimmed = v.trim();
+            return "true".equalsIgnoreCase(trimmed) || "1".equals(trimmed);
+        } catch (Exception e) {
+            log.warn("读取AI会员门禁开关失败,按开启处理: {}", e.getMessage());
+            return true;
+        }
+    }
+
+    /**
+     * AI 使用会员门禁统一判定(严格按任务书 §1.2 伪代码)。
+     *
+     * <p>判定用 effectiveRank + isActive + everPaid 原始维度,不依赖 getMemberLevel 归一化:
+     * 否则 FAMILY 过期被归一化为 FREE 后会误判为 UPGRADE_REQUIRED,
+     * 导致过期会员无法识别「曾付费」而失去历史会话查看权限。</p>
+     *
+     * @param userId 登录用户ID
+     * @return 结构化门禁结果,永不为 null
+     */
+    public AiGateResult checkAiFeatureGate(Long userId) {
+        boolean enabled = isAiMemberGateEnabled();
+        AiGateInfo info;
+        try {
+            info = getMemberGateInfo(userId);
+        } catch (Exception e) {
+            // 门禁内部异常按非会员处理(fail-closed),避免影响端点可用性判断
+            log.warn("AI门禁判定异常,按非会员处理: userId={}, error={}", userId, e.getMessage());
+            info = new AiGateInfo();
+        }
+
+        AiGateResult result = new AiGateResult();
+        result.setEnabled(enabled);
+        result.setMemberLevel(info.effectiveLevel);
+        result.setIsActive(info.isActive);
+        result.setMemberExpireTime(info.memberExpireTime);
+        result.setEndDate(info.endDate);
+        result.setDaysRemaining(info.daysRemaining);
+
+        // ① 开关关闭 → 不门禁,走原逻辑(含原配额链)
+        if (!enabled) {
+            return fillGateResult(result, AiGateStatus.ALLOW, true, true, null);
+        }
+
+        // ② 存在有效付费记录:等级下限 BASIC+
+        if (info.isActive) {
+            if (info.effectiveRank >= getLevelOrder(GATE_LEVEL_BASIC)) {
+                return fillGateResult(result, AiGateStatus.ALLOW, true, true, null);
+            }
+            // 理论不可达(有效付费但等级低于 BASIC),防御性拦截
+            return fillGateResult(result, AiGateStatus.UPGRADE_REQUIRED, false, false,
+                    AiGateStatus.UPGRADE_REQUIRED.name());
+        }
+
+        // ③ 无有效付费记录:区分「曾付费过期」与「从未付费」
+        if (info.everPaid) {
+            // 决策 3:可查看 + 可删除/归档历史会话,不可新建/继续对话
+            return fillGateResult(result, AiGateStatus.MEMBER_EXPIRED, false, true,
+                    AiGateStatus.MEMBER_EXPIRED.name());
+        }
+        return fillGateResult(result, AiGateStatus.UPGRADE_REQUIRED, false, false,
+                AiGateStatus.UPGRADE_REQUIRED.name());
+    }
+
+    private AiGateResult fillGateResult(AiGateResult result, AiGateStatus status,
+                                        boolean canUse, boolean canViewHistory, String reason) {
+        result.setStatus(status);
+        result.setCanUse(canUse);
+        result.setCanViewHistory(canViewHistory);
+        result.setReason(reason);
+        return result;
+    }
+
+    /**
+     * 内部:独立计算 level / isActive / endDate / everPaid(不依赖 getMemberLevel 归一化)。
+     */
+    private AiGateInfo getMemberGateInfo(Long userId) {
+        AiGateInfo info = new AiGateInfo();
+        if (userId == null || userId <= 0) {
+            return info;
+        }
+
+        User user;
+        try {
+            user = userMapper.selectById(userId);
+        } catch (Exception e) {
+            log.warn("AI门禁读取用户失败,按非会员处理: userId={}, error={}", userId, e.getMessage());
+            return info;
+        }
+        if (user == null) {
+            return info;
+        }
+
+        Date now = new Date();
+
+        // 个人身份(不归一化 PROVIDER,仅用于「是否付费」判定)
+        String personalRaw = (user.getMemberLevel() != null && !user.getMemberLevel().isEmpty())
+                ? user.getMemberLevel() : GATE_LEVEL_FREE;
+        Date personalExpire = user.getMemberExpireTime();
+        boolean personalPaid = !GATE_LEVEL_FREE.equals(personalRaw) && !GATE_LEVEL_PROVIDER.equals(personalRaw);
+        boolean personalActive = personalPaid && personalExpire != null && personalExpire.after(now);
+        info.memberExpireTime = personalExpire;
+
+        // 家庭身份(family_memberships)
+        Long familyId = user.getFamilyId();
+        FamilyMembership activeFamily = familyId != null ? getActiveFamilyMembership(familyId) : null;
+        boolean familyActive = activeFamily != null;
+        FamilyMembership latestPaidFamily = familyId != null ? getLatestPaidFamilyMembership(familyId) : null;
+        boolean familyAnyPaid = latestPaidFamily != null;
+        Date familyEndDate = activeFamily != null ? activeFamily.getEndDate() : null;
+        String familyRaw = (activeFamily != null && activeFamily.getLevelCode() != null)
+                ? activeFamily.getLevelCode() : GATE_LEVEL_FREE;
+
+        // 原始有效等级(未归一化),取个人/家庭更高者
+        String effectiveRaw = getLevelOrder(familyRaw) > getLevelOrder(personalRaw) ? familyRaw : personalRaw;
+
+        info.effectiveRank = getLevelOrder(effectiveRaw);
+        info.isActive = personalActive || familyActive;
+        info.everPaid = personalPaid || familyAnyPaid;
+
+        // 到期时间:生效时取当前生效源;过期时回退到个人最后到期时间 / 家庭最后付费记录到期时间(供前端展示)
+        if (info.isActive) {
+            info.endDate = familyActive ? familyEndDate : personalExpire;
+        } else {
+            info.endDate = personalExpire != null ? personalExpire
+                    : (latestPaidFamily != null ? latestPaidFamily.getEndDate() : null);
+        }
+        info.daysRemaining = (info.isActive && info.endDate != null)
+                ? computeDaysRemaining(now, info.endDate) : 0L;
+
+        // 对外展示等级仍用归一化口径(与 /api/membership/my 一致)
+        try {
+            info.effectiveLevel = getMemberLevel(userId);
+        } catch (Exception e) {
+            log.warn("AI门禁读取归一化会员等级失败,回退原始等级: userId={}, error={}", userId, e.getMessage());
+            info.effectiveLevel = effectiveRaw;
+        }
+        return info;
+    }
+
+    /**
+     * 家庭任意已支付记录(含已过期),用于识别「曾经是付费会员」。
+     * 按到期时间倒序取最新一条。
+     */
+    private FamilyMembership getLatestPaidFamilyMembership(Long familyId) {
+        if (familyId == null) {
+            return null;
+        }
+        try {
+            List<FamilyMembership> list = membershipMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMembership>()
+                            .eq(FamilyMembership::getFamilyId, familyId)
+                            .eq(FamilyMembership::getPaymentStatus, "paid")
+                            .orderByDesc(FamilyMembership::getEndDate)
+                            .last("LIMIT 1"));
+            return (list == null || list.isEmpty()) ? null : list.get(0);
+        } catch (Exception e) {
+            log.warn("查询家庭付费会员记录失败: familyId={}, error={}", familyId, e.getMessage());
+            return null;
+        }
+    }
+
+    /** 剩余天数(向上取整,最小 0) */
+    private long computeDaysRemaining(Date now, Date endDate) {
+        if (now == null || endDate == null) {
+            return 0L;
+        }
+        long diff = endDate.getTime() - now.getTime();
+        if (diff <= 0) {
+            return 0L;
+        }
+        return (diff + 86400000L - 1) / 86400000L;
+    }
+
+    /** AI 门禁内部判定维度(不对外暴露) */
+    private static class AiGateInfo {
+        private String effectiveLevel = GATE_LEVEL_FREE;
+        private int effectiveRank = 1;
+        private boolean isActive = false;
+        private boolean everPaid = false;
+        private Date memberExpireTime;
+        private Date endDate;
+        private long daysRemaining = 0L;
+    }
+
     /**
      * 获取用户可见的会员等级编码集合:级别数字 &lt;= 用户级别数字 的所有等级编码(低级别可见则高级别也可见)。
      * 游客/匿名(userId 为空或&lt;=0)返回空集合,仅可见 min_visible_level IS NULL(全部可见)的商品/分类。

+ 197 - 79
cfc-backend/src/test/java/com/etotem/cfc/controller/ai/AIChatControllerGateTest.java

@@ -1,38 +1,43 @@
 package com.etotem.cfc.controller.ai;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.User;
+import com.etotem.cfc.dto.AiGateResult;
+import com.etotem.cfc.enums.AiGateStatus;
 import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.MembershipService;
-import com.etotem.cfc.service.SysConfigService;
+import com.etotem.cfc.service.PortraitService;
 import com.etotem.cfc.service.UserService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.lang.reflect.Field;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 /**
- * AI 教练会员付费开关单元测试(AIChatController § gate 逻辑)。
+ * AI 使用会员门禁单元测试(AIChatController,任务书 2026-11-02-ai-member-gate-design §3.1)。
  *
- * 测试矩阵:
- * - 开关关闭 → 任何登录用户均可使用
- * - 开关开启 + FREE → 返回 46003 错误
- * - 开关开启 + BASIC → 返回 46003 错误
- * - 开关开启 + FAMILY → 放行
- * - 开关开启 + PREMIUM → 放行
- * - gate 状态接口正确返回 enabled / canUse / memberLevel / memberExpireTime
+ * <p>门禁判定本身在 MembershipService(见 MembershipServiceAiGateTest),
+ * 本测试聚焦控制器接线与错误码/响应结构:</p>
+ * <ul>
+ *   <li>开关关闭(gate.enabled=false → ALLOW)→ 所有登录用户放行</li>
+ *   <li>BASIC / FAMILY / PREMIUM 有效 → 放行</li>
+ *   <li>FREE(UPGRADE_REQUIRED)→ 46003</li>
+ *   <li>曾付费过期(MEMBER_EXPIRED)→ 46004</li>
+ *   <li>/chat/send、/nutrition/send、/health-coach/send、/butler/send 同一门禁</li>
+ *   <li>/api/ai/gate 返回 features 明细;/health-coach/gate 兼容保留</li>
+ * </ul>
  */
 class AIChatControllerGateTest {
 
     private AIChatController controller;
-    private SysConfigService sysConfigService;
     private MembershipService membershipService;
     private UserService userService;
     private AiGateway aiGateway;
@@ -40,17 +45,16 @@ class AIChatControllerGateTest {
     @BeforeEach
     void setUp() throws Exception {
         controller = new AIChatController();
-        sysConfigService = mock(SysConfigService.class);
         membershipService = mock(MembershipService.class);
         userService = mock(UserService.class);
         aiGateway = mock(AiGateway.class);
-        inject("sysConfigService", sysConfigService);
         inject("membershipService", membershipService);
         inject("userService", userService);
+        inject("portraitService", mock(PortraitService.class));
         inject("aiGateway", aiGateway);
         // aiGateway.chat 在放行路径会被调用,提供最小 stub
-        when(aiGateway.chat(anyString(), anyLong(), anyString(), anyMap()))
-                .thenReturn(new LinkedHashMap<>(Map.of("answer", "OK", "conversationId", "")));
+        when(aiGateway.chat(anyString(), anyLong(), any(), anyMap()))
+                .thenReturn(chatResponse());
     }
 
     private void inject(String name, Object v) throws Exception {
@@ -65,83 +69,220 @@ class AIChatControllerGateTest {
         return p;
     }
 
+    private Map<String, String> sendStringParams(String query) {
+        Map<String, String> p = new LinkedHashMap<>();
+        p.put("query", query);
+        return p;
+    }
+
+    private Map<String, Object> chatResponse() {
+        Map<String, Object> resp = new LinkedHashMap<>();
+        resp.put("answer", "OK");
+        resp.put("conversationId", "");
+        return resp;
+    }
+
+    /** 构造门禁结果 */
+    private AiGateResult gate(boolean enabled, AiGateStatus status, boolean canUse,
+                              boolean canViewHistory, String level) {
+        AiGateResult g = new AiGateResult();
+        g.setEnabled(enabled);
+        g.setStatus(status);
+        g.setCanUse(canUse);
+        g.setCanViewHistory(canViewHistory);
+        g.setMemberLevel(level);
+        g.setIsActive(status == AiGateStatus.ALLOW && enabled);
+        g.setDaysRemaining(0L);
+        g.setReason(status == AiGateStatus.ALLOW ? null : status.name());
+        return g;
+    }
+
+    private AiGateResult allow(String level) {
+        return gate(true, AiGateStatus.ALLOW, true, true, level);
+    }
+
+    private AiGateResult upgradeRequired() {
+        return gate(true, AiGateStatus.UPGRADE_REQUIRED, false, false, "FREE");
+    }
+
+    private AiGateResult expired() {
+        return gate(true, AiGateStatus.MEMBER_EXPIRED, false, true, "FREE");
+    }
+
+    // ── health-coach/send ──
+
     @Test
     void 开关关闭_FREE用户也可用() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("false");
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(gate(false, AiGateStatus.ALLOW, true, true, "FREE"));
         Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
         assertEquals(200, r.getCode());
-        // 放行后调用 aiGateway.chat
-        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+        verify(aiGateway).chat(anyString(), eq(1L), any(), anyMap());
     }
 
     @Test
-    void 开关关闭_BASIC用户也可用() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("false");
+    void 开关开启_BASIC用户放行() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow("BASIC"));
         Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
         assertEquals(200, r.getCode());
-        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+        verify(aiGateway).chat(anyString(), eq(1L), any(), anyMap());
+    }
+
+    @Test
+    void 开关开启_FAMILY用户放行() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow("FAMILY"));
+        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(200, r.getCode());
+        verify(aiGateway).chat(anyString(), eq(1L), any(), anyMap());
+    }
+
+    @Test
+    void 开关开启_PREMIUM用户放行() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow("PREMIUM"));
+        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(200, r.getCode());
+        verify(aiGateway).chat(anyString(), eq(1L), any(), anyMap());
     }
 
     @Test
     void 开关开启_FREE用户被拦截返回46003() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(upgradeRequired());
         Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
         assertEquals(46003, r.getCode());
-        assertEquals("AI 教练为会员专享功能,请先开通会员", r.getMessage());
-        verify(aiGateway, never()).chat(anyString(), anyLong(), anyString(), anyMap());
+        assertEquals("该功能为会员专享,请先开通会员", r.getMessage());
+        verify(aiGateway, never()).chat(anyString(), anyLong(), any(), anyMap());
     }
 
     @Test
-    void 开关开启_BASIC用户被拦截返回46003() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        when(membershipService.getMemberLevel(1L)).thenReturn("BASIC");
+    void 开关开启_过期会员返回46004() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(expired());
         Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(46004, r.getCode());
+        assertEquals("会员已过期,请续费后继续使用", r.getMessage());
+        verify(aiGateway, never()).chat(anyString(), anyLong(), any(), anyMap());
+    }
+
+    // ── chat/send · nutrition/send · butler/send 同门禁 ──
+
+    @Test
+    void chat_send_FREE被拦截返回46003() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(upgradeRequired());
+        Result<?> r = controller.sendMessage(1L, sendStringParams("你好"));
         assertEquals(46003, r.getCode());
-        verify(aiGateway, never()).chat(anyString(), anyLong(), anyString(), anyMap());
+        verifyNoInteractions(aiGateway);
     }
 
     @Test
-    void 开关开启_FAMILY用户可用() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        when(membershipService.getMemberLevel(1L)).thenReturn("FAMILY");
-        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+    void chat_send_过期会员返回46004() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(expired());
+        Result<?> r = controller.sendMessage(1L, sendStringParams("你好"));
+        assertEquals(46004, r.getCode());
+        verifyNoInteractions(aiGateway);
+    }
+
+    @Test
+    void nutrition_send_过期会员返回46004() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(expired());
+        Map<String, String> p = sendStringParams("分析这份报告");
+        p.put("reportId", "99");
+        Result<?> r = controller.sendNutritionMessage(1L, p);
+        assertEquals(46004, r.getCode());
+        verifyNoInteractions(aiGateway);
+    }
+
+    @Test
+    void butler_send_FREE被拦截返回46003() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(upgradeRequired());
+        Result<?> r = controller.sendHealthButlerMessage(1L, sendMessageParams("帮我安排一下"));
+        assertEquals(46003, r.getCode());
+        verifyNoInteractions(aiGateway);
+    }
+
+    @Test
+    void butler_send_有效会员放行() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow("FAMILY"));
+        Result<?> r = controller.sendHealthButlerMessage(1L, sendMessageParams("帮我安排一下"));
         assertEquals(200, r.getCode());
-        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+        verify(aiGateway).chat(anyString(), eq(1L), any(), anyMap());
     }
 
+    // ── /api/ai/gate ──
+
     @Test
-    void 开关开启_PREMIUM用户可用() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        when(membershipService.getMemberLevel(1L)).thenReturn("PREMIUM");
-        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+    void gate接口_过期会员_返回全量features() {
+        AiGateResult g = expired();
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(g);
+        Result<AiGateResult> r = controller.aiGateStatus(1L);
         assertEquals(200, r.getCode());
-        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+        AiGateResult d = r.getData();
+        assertEquals(AiGateStatus.MEMBER_EXPIRED, d.getStatus());
+        assertEquals(true, d.isEnabled());
+        assertEquals(false, d.isCanUse());
+        assertEquals(true, d.isCanViewHistory());
+        assertEquals("MEMBER_EXPIRED", d.getReason());
+
+        Map<String, AiGateResult.FeatureGate> features = d.getFeatures();
+        assertNotNull(features);
+        assertEquals(8, features.size());
+        assertFalse(features.get("chat").isCanUse());
+        assertFalse(features.get("nutrition").isCanUse());
+        assertFalse(features.get("healthCoach").isCanUse());
+        assertFalse(features.get("butler").isCanUse());
+        assertEquals("MEMBER_EXPIRED", features.get("chat").getReason());
+        // 不纳入门禁的 4 项固定放行(保留 FREE 引流钩子)
+        assertTrue(features.get("reportAnalyze").isCanUse());
+        assertTrue(features.get("emotion").isCanUse());
+        assertTrue(features.get("tongue").isCanUse());
+        assertTrue(features.get("diet").isCanUse());
+        assertNull(features.get("reportAnalyze").getReason());
     }
 
     @Test
-    void configKeyMissing_defaultsToGateOn() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn(null);
-        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
-        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
-        assertEquals(46003, r.getCode());
+    void gate接口_有效会员_全部放行() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow("BASIC"));
+        Result<AiGateResult> r = controller.aiGateStatus(1L);
+        assertEquals(200, r.getCode());
+        AiGateResult d = r.getData();
+        assertEquals(AiGateStatus.ALLOW, d.getStatus());
+        assertEquals(true, d.isCanUse());
+        assertTrue(d.getFeatures().get("chat").isCanUse());
+        assertNull(d.getFeatures().get("chat").getReason());
     }
 
     @Test
-    void configKeyException_defaultsToGateOn() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled"))
-                .thenThrow(new RuntimeException("config error"));
-        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
-        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
-        assertEquals(46003, r.getCode());
+    void gate接口_开关关闭_全部放行() {
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(gate(false, AiGateStatus.ALLOW, true, true, "FREE"));
+        Result<AiGateResult> r = controller.aiGateStatus(1L);
+        assertEquals(200, r.getCode());
+        assertEquals(false, r.getData().isEnabled());
+        assertEquals(true, r.getData().isCanUse());
+        assertTrue(r.getData().getFeatures().get("butler").isCanUse());
+    }
+
+    @Test
+    void gate接口_JSON字段名符合契约() throws Exception {
+        AiGateResult g = expired();
+        g.setMemberExpireTime(new java.util.Date(1767225600000L));
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(g);
+        Result<AiGateResult> r = controller.aiGateStatus(1L);
+
+        String json = new com.fasterxml.jackson.databind.ObjectMapper()
+                .writeValueAsString(r.getData());
+        assertTrue(json.contains("\"status\":\"MEMBER_EXPIRED\""), json);
+        assertTrue(json.contains("\"enabled\":true"), json);
+        assertTrue(json.contains("\"canUse\":false"), json);
+        assertTrue(json.contains("\"canViewHistory\":true"), json);
+        assertTrue(json.contains("\"isActive\":false"), json);
+        assertTrue(json.contains("\"memberExpireTime\":"), json);
+        assertTrue(json.contains("\"reason\":\"MEMBER_EXPIRED\""), json);
+        assertTrue(json.contains("\"features\":{"), json);
+        assertTrue(json.contains("\"healthCoach\":{"), json);
     }
 
-    // ── gate 状态接口 ──
+    // ── 兼容接口 /health-coach/gate ──
 
     @Test
     void gateStatus_开关关闭返回enabledFalse_canUseTrue() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("false");
-        when(userService.getUserInfo(1L)).thenReturn(buildUser(1L, "FREE", null));
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(gate(false, AiGateStatus.ALLOW, true, true, "FREE"));
         Result<?> r = controller.aiCoachGateStatus(1L);
         assertEquals(200, r.getCode());
         Map<?, ?> d = (Map<?, ?>) r.getData();
@@ -152,9 +293,7 @@ class AIChatControllerGateTest {
 
     @Test
     void gateStatus_开关开启_FREE用户canUseFalse() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        when(userService.getUserInfo(1L)).thenReturn(buildUser(1L, "FREE", null));
-        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(upgradeRequired());
         Result<?> r = controller.aiCoachGateStatus(1L);
         assertEquals(200, r.getCode());
         Map<?, ?> d = (Map<?, ?>) r.getData();
@@ -165,10 +304,7 @@ class AIChatControllerGateTest {
 
     @Test
     void gateStatus_开关开启_FAMILY用户canUseTrue() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        User u = buildUser(1L, "FAMILY", null);
-        when(userService.getUserInfo(1L)).thenReturn(u);
-        when(membershipService.getMemberLevel(1L)).thenReturn("FAMILY");
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow("FAMILY"));
         Result<?> r = controller.aiCoachGateStatus(1L);
         assertEquals(200, r.getCode());
         Map<?, ?> d = (Map<?, ?>) r.getData();
@@ -176,22 +312,4 @@ class AIChatControllerGateTest {
         assertEquals(true, d.get("canUse"));
         assertEquals("FAMILY", d.get("memberLevel"));
     }
-
-    @Test
-    void gateStatus_anonymous返回canUseFalse() {
-        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
-        when(userService.getUserInfo(null)).thenReturn(null);
-        Result<?> r = controller.aiCoachGateStatus(null);
-        assertEquals(200, r.getCode());
-        Map<?, ?> d = (Map<?, ?>) r.getData();
-        assertEquals(false, d.get("canUse"));
-    }
-
-    private User buildUser(Long id, String level, java.util.Date expire) {
-        User u = new User();
-        u.setId(id);
-        u.setMemberLevel(level);
-        u.setMemberExpireTime(expire);
-        return u;
-    }
 }

+ 161 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ai/ButlerControllerGateTest.java

@@ -0,0 +1,161 @@
+package com.etotem.cfc.controller.ai;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AiGateResult;
+import com.etotem.cfc.entity.ButlerSession;
+import com.etotem.cfc.enums.AiGateStatus;
+import com.etotem.cfc.service.ButlerSessionService;
+import com.etotem.cfc.service.MembershipService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * AI 管家门禁单元测试(T-B2,任务书 §3.2)。
+ *
+ * <p>/sessions/create 纳入门禁(FREE → 46003,过期 → 46004);
+ * list/detail/archive/delete/update-conversation 保持开放(决策 3:过期可查看/删除/归档历史)。</p>
+ */
+class ButlerControllerGateTest {
+
+    private ButlerController controller;
+    private ButlerSessionService butlerSessionService;
+    private MembershipService membershipService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new ButlerController();
+        butlerSessionService = mock(ButlerSessionService.class);
+        membershipService = mock(MembershipService.class);
+        inject("butlerSessionService", butlerSessionService);
+        inject("membershipService", membershipService);
+    }
+
+    private void inject(String name, Object value) throws Exception {
+        Field f = ButlerController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, value);
+    }
+
+    private AiGateResult gate(AiGateStatus status, boolean canUse, boolean canViewHistory) {
+        AiGateResult g = new AiGateResult();
+        g.setEnabled(true);
+        g.setStatus(status);
+        g.setCanUse(canUse);
+        g.setCanViewHistory(canViewHistory);
+        g.setMemberLevel("FREE");
+        g.setIsActive(status == AiGateStatus.ALLOW);
+        g.setReason(status == AiGateStatus.ALLOW ? null : status.name());
+        return g;
+    }
+
+    private Map<String, String> createParams() {
+        Map<String, String> p = new HashMap<>();
+        p.put("title", "新会话");
+        return p;
+    }
+
+    @Test
+    void create_FREE用户返回46003() {
+        when(membershipService.checkAiFeatureGate(1L))
+                .thenReturn(gate(AiGateStatus.UPGRADE_REQUIRED, false, false));
+        Result<Map<String, Object>> r = controller.createSession(1L, createParams());
+        assertEquals(46003, r.getCode());
+        verify(butlerSessionService, never()).createSession(anyLong(), anyString(), any());
+    }
+
+    @Test
+    void create_过期会员返回46004() {
+        when(membershipService.checkAiFeatureGate(1L))
+                .thenReturn(gate(AiGateStatus.MEMBER_EXPIRED, false, true));
+        Result<Map<String, Object>> r = controller.createSession(1L, createParams());
+        assertEquals(46004, r.getCode());
+        assertEquals("会员已过期,请续费后继续使用", r.getMessage());
+        verify(butlerSessionService, never()).createSession(anyLong(), anyString(), any());
+    }
+
+    @Test
+    void create_有效会员放行() {
+        when(membershipService.checkAiFeatureGate(1L))
+                .thenReturn(gate(AiGateStatus.ALLOW, true, true));
+        when(butlerSessionService.createSession(1L, "新会话", null)).thenReturn(99L);
+        Result<Map<String, Object>> r = controller.createSession(1L, createParams());
+        assertEquals(200, r.getCode());
+        assertEquals(99L, r.getData().get("sessionId"));
+    }
+
+    @Test
+    void create_开关关闭时FREE放行() {
+        AiGateResult allow = gate(AiGateStatus.ALLOW, true, true);
+        allow.setEnabled(false);
+        when(membershipService.checkAiFeatureGate(1L)).thenReturn(allow);
+        when(butlerSessionService.createSession(anyLong(), anyString(), any())).thenReturn(5L);
+        Result<Map<String, Object>> r = controller.createSession(1L, createParams());
+        assertEquals(200, r.getCode());
+    }
+
+    // ── 历史会话 CRUD 保持开放:不触发门禁 ──
+
+    @Test
+    void list_过期会员放行_不触发门禁() {
+        when(butlerSessionService.getSessionsByUser(1L, 1, 20, null)).thenReturn(new Page<>());
+        Result<?> r = controller.listSessions(1L, Collections.emptyMap());
+        assertEquals(200, r.getCode());
+        verifyNoInteractions(membershipService);
+    }
+
+    @Test
+    void detail_过期会员放行_不触发门禁() {
+        ButlerSession session = new ButlerSession();
+        session.setId(7L);
+        when(butlerSessionService.getSession(7L, 1L)).thenReturn(session);
+        Map<String, Object> p = new HashMap<>();
+        p.put("sessionId", 7L);
+        Result<?> r = controller.sessionDetail(1L, p);
+        assertEquals(200, r.getCode());
+        verifyNoInteractions(membershipService);
+    }
+
+    @Test
+    void archive_过期会员放行_不触发门禁() {
+        Map<String, Object> p = new HashMap<>();
+        p.put("sessionId", 7L);
+        Result<?> r = controller.archiveSession(1L, p);
+        assertEquals(200, r.getCode());
+        verify(butlerSessionService).archiveSession(7L, 1L);
+        verifyNoInteractions(membershipService);
+    }
+
+    @Test
+    void delete_过期会员放行_不触发门禁() {
+        when(butlerSessionService.deleteSession(7L, 1L)).thenReturn(true);
+        Map<String, Object> p = new HashMap<>();
+        p.put("sessionId", 7L);
+        Result<?> r = controller.deleteSession(1L, p);
+        assertEquals(200, r.getCode());
+        verifyNoInteractions(membershipService);
+    }
+
+    @Test
+    void updateConversation_过期会员放行_不触发门禁() {
+        ButlerSession session = new ButlerSession();
+        session.setId(7L);
+        when(butlerSessionService.getSession(7L, 1L)).thenReturn(session);
+        Map<String, Object> p = new HashMap<>();
+        p.put("sessionId", 7L);
+        p.put("conversationId", "conv-1");
+        Result<?> r = controller.updateConversationId(1L, p);
+        assertEquals(200, r.getCode());
+        verify(butlerSessionService).updateConversationId(7L, "conv-1");
+        verifyNoInteractions(membershipService);
+    }
+}

+ 24 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/AIChatControllerTest.java

@@ -2,10 +2,14 @@ package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.controller.ai.AIChatController;
+import com.etotem.cfc.dto.AiGateResult;
+import com.etotem.cfc.enums.AiGateStatus;
 import com.etotem.cfc.service.AIService;
 import com.etotem.cfc.service.FamilyContextService;
 import com.etotem.cfc.service.HealthAnalysisService;
+import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.RecommendationService;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
@@ -39,6 +43,26 @@ public class AIChatControllerTest {
     @MockBean
     private RecommendationService recommendationService;
 
+    /**
+     * AI 使用会员门禁(任务书 2026-11-02-ai-member-gate-design):/chat/send 已加显式门禁,
+     * 本集成测试聚焦会话组装/返回结构,统一 mock 为有效会员放行。
+     */
+    @MockBean
+    private MembershipService membershipService;
+
+    @BeforeEach
+    public void allowAiGate() {
+        AiGateResult allow = new AiGateResult();
+        allow.setEnabled(true);
+        allow.setStatus(AiGateStatus.ALLOW);
+        allow.setCanUse(true);
+        allow.setCanViewHistory(true);
+        allow.setMemberLevel("FAMILY");
+        allow.setIsActive(true);
+        allow.setDaysRemaining(30L);
+        when(membershipService.checkAiFeatureGate(anyLong())).thenReturn(allow);
+    }
+
     // ===== 发送聊天消息 =====
 
     @Test

+ 291 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/MembershipServiceAiGateTest.java

@@ -0,0 +1,291 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.AiGateResult;
+import com.etotem.cfc.entity.FamilyMembership;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.enums.AiGateStatus;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * AI 使用会员门禁核心判定单元测试(T-B0,任务书 2026-11-02-ai-member-gate-design §1.2)。
+ *
+ * <p>覆盖 DoD 六态:</p>
+ * <ol>
+ *   <li>开关关 → ALLOW</li>
+ *   <li>FREE(从未付费)→ UPGRADE_REQUIRED</li>
+ *   <li>BASIC 有效 → ALLOW</li>
+ *   <li>FAMILY 有效 → ALLOW</li>
+ *   <li>曾付费过期 → MEMBER_EXPIRED(canViewHistory=true)</li>
+ *   <li>BASIC 过期 → MEMBER_EXPIRED</li>
+ * </ol>
+ */
+class MembershipServiceAiGateTest {
+
+    private static final String GATE_KEY = MembershipService.AI_MEMBER_GATE_CONFIG_KEY;
+    private static final Long USER_ID = 1L;
+    private static final Long FAMILY_ID = 10L;
+
+    private MembershipService service;
+    private UserMapper userMapper;
+    private FamilyMembershipMapper membershipMapper;
+    private MembershipLevelMapper levelMapper;
+    private SysConfigService sysConfigService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = spy(new MembershipService());
+        userMapper = mock(UserMapper.class);
+        membershipMapper = mock(FamilyMembershipMapper.class);
+        levelMapper = mock(MembershipLevelMapper.class);
+        sysConfigService = mock(SysConfigService.class);
+
+        inject("userMapper", userMapper);
+        inject("membershipMapper", membershipMapper);
+        inject("levelMapper", levelMapper);
+        inject("sysConfigService", sysConfigService);
+
+        // 会员等级 order 与 membership_levels 种子一致:FREE=1 BASIC=2 FAMILY=3 PREMIUM=4
+        doReturn(1).when(service).getLevelOrder("FREE");
+        doReturn(2).when(service).getLevelOrder("BASIC");
+        doReturn(3).when(service).getLevelOrder("FAMILY");
+        doReturn(4).when(service).getLevelOrder("PREMIUM");
+    }
+
+    private void inject(String name, Object value) throws Exception {
+        Field f = MembershipService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private void gateEnabled(String value) {
+        when(sysConfigService.getValue(GATE_KEY)).thenReturn(value);
+    }
+
+    private User user(String level, Date expire, Long familyId) {
+        User u = new User();
+        u.setId(USER_ID);
+        u.setMemberLevel(level);
+        u.setMemberExpireTime(expire);
+        u.setFamilyId(familyId);
+        when(userMapper.selectById(USER_ID)).thenReturn(u);
+        return u;
+    }
+
+    private FamilyMembership familyMembership(String level, Date endDate) {
+        FamilyMembership m = new FamilyMembership();
+        m.setFamilyId(FAMILY_ID);
+        m.setLevelCode(level);
+        m.setPaymentStatus("paid");
+        m.setEndDate(endDate);
+        return m;
+    }
+
+    private Date daysFromNow(int days) {
+        return new Date(System.currentTimeMillis() + days * 86400000L);
+    }
+
+    // ── ① 开关关闭 ──
+
+    @Test
+    void 开关关闭_FREE用户返回ALLOW() {
+        gateEnabled("false");
+        user("FREE", null, null);
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.ALLOW, r.getStatus());
+        assertFalse(r.isEnabled());
+        assertTrue(r.isCanUse());
+        assertTrue(r.isCanViewHistory());
+        assertNull(r.getReason());
+    }
+
+    @Test
+    void 开关关闭_匿名用户也放行() {
+        gateEnabled("false");
+
+        AiGateResult r = service.checkAiFeatureGate(null);
+
+        assertEquals(AiGateStatus.ALLOW, r.getStatus());
+        assertTrue(r.isCanUse());
+    }
+
+    // ── ② FREE(从未付费)──
+
+    @Test
+    void 开关开启_FREE用户返回UPGRADE_REQUIRED() {
+        gateEnabled("true");
+        user("FREE", null, null);
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.UPGRADE_REQUIRED, r.getStatus());
+        assertTrue(r.isEnabled());
+        assertFalse(r.isCanUse());
+        assertFalse(r.isCanViewHistory());
+        assertEquals("UPGRADE_REQUIRED", r.getReason());
+        assertEquals("FREE", r.getMemberLevel());
+        assertEquals(Boolean.FALSE, r.getIsActive());
+    }
+
+    @Test
+    void 开关缺失_默认开启门禁() {
+        gateEnabled(null);
+        user("FREE", null, null);
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertTrue(r.isEnabled());
+        assertEquals(AiGateStatus.UPGRADE_REQUIRED, r.getStatus());
+        assertFalse(r.isCanUse());
+    }
+
+    @Test
+    void 开关读取异常_默认开启门禁() {
+        when(sysConfigService.getValue(GATE_KEY)).thenThrow(new RuntimeException("config error"));
+        user("FREE", null, null);
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertTrue(r.isEnabled());
+        assertEquals(AiGateStatus.UPGRADE_REQUIRED, r.getStatus());
+    }
+
+    @Test
+    void 服务商PROVIDER_不构成付费会员() {
+        gateEnabled("true");
+        user("PROVIDER", daysFromNow(30), null);
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.UPGRADE_REQUIRED, r.getStatus());
+        assertFalse(r.isCanUse());
+    }
+
+    // ── ③ BASIC 有效 ──
+
+    @Test
+    void 开关开启_BASIC有效返回ALLOW() {
+        gateEnabled("true");
+        user("BASIC", daysFromNow(10), null);
+        doReturn("BASIC").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.ALLOW, r.getStatus());
+        assertTrue(r.isCanUse());
+        assertTrue(r.isCanViewHistory());
+        assertEquals("BASIC", r.getMemberLevel());
+        assertEquals(Boolean.TRUE, r.getIsActive());
+        assertNotNull(r.getEndDate());
+        assertNotNull(r.getMemberExpireTime());
+        assertTrue(r.getDaysRemaining() > 0);
+    }
+
+    // ── ④ FAMILY 有效(家庭会员共享)──
+
+    @Test
+    void 开关开启_FAMILY有效返回ALLOW() {
+        gateEnabled("true");
+        user("FREE", null, FAMILY_ID);
+        doReturn("FAMILY").when(service).getMemberLevel(USER_ID);
+        FamilyMembership active = familyMembership("FAMILY", daysFromNow(30));
+        when(membershipMapper.selectOne(any())).thenReturn(active);
+        when(membershipMapper.selectList(any())).thenReturn(Collections.singletonList(active));
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.ALLOW, r.getStatus());
+        assertTrue(r.isCanUse());
+        assertEquals(Boolean.TRUE, r.getIsActive());
+        assertEquals("FAMILY", r.getMemberLevel());
+        assertNotNull(r.getEndDate());
+    }
+
+    // ── ⑤ 曾付费过期(个人 FAMILY 已过期)──
+
+    @Test
+    void 开关开启_曾付费FAMILY过期返回MEMBER_EXPIRED且可看历史() {
+        gateEnabled("true");
+        Date expiredAt = daysFromNow(-5);
+        user("FAMILY", expiredAt, null);
+        // getMemberLevel 会把过期 FAMILY 归一化为 FREE,门禁不得据此误判为 UPGRADE_REQUIRED
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.MEMBER_EXPIRED, r.getStatus());
+        assertFalse(r.isCanUse());
+        assertTrue(r.isCanViewHistory());
+        assertEquals("MEMBER_EXPIRED", r.getReason());
+        assertEquals(Boolean.FALSE, r.getIsActive());
+        assertEquals(expiredAt, r.getEndDate());
+        assertEquals("FREE", r.getMemberLevel());
+    }
+
+    // ── ⑥ BASIC 过期 ──
+
+    @Test
+    void 开关开启_BASIC过期返回MEMBER_EXPIRED() {
+        gateEnabled("true");
+        user("BASIC", daysFromNow(-1), null);
+        // getMemberLevel 对 BASIC 不做过期归一化,门禁必须独立校验 isActive
+        doReturn("BASIC").when(service).getMemberLevel(USER_ID);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.MEMBER_EXPIRED, r.getStatus());
+        assertFalse(r.isCanUse());
+        assertTrue(r.isCanViewHistory());
+        assertEquals(Boolean.FALSE, r.getIsActive());
+    }
+
+    // ── 家庭曾付费但已过期 ──
+
+    @Test
+    void 开关开启_家庭会员过期返回MEMBER_EXPIRED() {
+        gateEnabled("true");
+        user("FREE", null, FAMILY_ID);
+        doReturn("FREE").when(service).getMemberLevel(USER_ID);
+        // 有效记录(selectOne)为空,但存在历史 paid 记录(selectList)
+        when(membershipMapper.selectOne(any())).thenReturn(null);
+        FamilyMembership expired = familyMembership("FAMILY", daysFromNow(-20));
+        when(membershipMapper.selectList(any())).thenReturn(Collections.singletonList(expired));
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.MEMBER_EXPIRED, r.getStatus());
+        assertFalse(r.isCanUse());
+        assertTrue(r.isCanViewHistory());
+        assertEquals(Boolean.FALSE, r.getIsActive());
+    }
+
+    @Test
+    void 用户不存在_按FREE拦截() {
+        gateEnabled("true");
+        when(userMapper.selectById(USER_ID)).thenReturn(null);
+
+        AiGateResult r = service.checkAiFeatureGate(USER_ID);
+
+        assertEquals(AiGateStatus.UPGRADE_REQUIRED, r.getStatus());
+        assertFalse(r.isCanUse());
+        assertFalse(r.isCanViewHistory());
+    }
+}