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

feat(ai): AI 教练会员付费门禁 + 首页推荐区块重构 + T39 实名回填设计文档

后端会员门禁(AIChatController):
- 新增 ai.coach.member_gate_enabled 系统开关(默认开启=member-only,缺失按安全口径开启)
- 开关开启时需 FAMILY/PREMIUM 有效会员,否则返回 46003(前端据此展示付费墙)
- 权益功能码 mascot_ai;DatabaseInitializer 补种子 ai.coach.member_gate_enabled='true'
- 新增 AIChatControllerGateTest:开关开/关 + 会员判定单测

小程序 AI 教练前端:
- xiaofat-miniprogram/pages/ai-coach/index.vue + utils/api.js:接入会员门禁付费墙

首页推荐区块重构(cfc-frontend/pages/home-pages/parent-index.vue):
- 移除 RecommendedFeed 混合信息流,改为「推荐商品(DimensionProducts 商城两列大卡片)+ 推荐活动(DimensionActivities)独立区块」
- 修复首页全局样式泄漏阻断 DimensionProducts 商城卡片布局

docs: 新增 T39 用户真实姓名回填昵称统一规则设计文档
Agnes Agent 1 неделя назад
Родитель
Сommit
1e644bffea

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

@@ -1100,6 +1100,7 @@ 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("gift_membership_threshold", "198000", "购买送会员商品累计达到该金额(分)赠送会员");
 insertSysConfigSeed("gift_membership_level", "FAMILY", "达到送会员额度后赠送的会员等级");
 insertSysConfigSeed("gift_membership_original_threshold", "1000000", "送会员商品原价达标阈值(分),达到此金额后送会员商品部分实付一口价");

+ 94 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -13,6 +13,8 @@ import com.etotem.cfc.service.RecommendationService;
 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;
@@ -60,6 +62,12 @@ public class AIChatController {
     @Resource
     private UserService userService;
 
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
     @Resource
     private com.etotem.cfc.service.ChatMirrorService chatMirrorService;
 
@@ -78,6 +86,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";
+
+    /** 会员专享业务错误码:前端据此展示会员开通付费墙 */
+    private static final int ERR_AI_COACH_MEMBER_ONLY = 46003;
+
+    /** AI 教练对应的会员权益功能码 */
+    private static final String AI_COACH_FEATURE = "mascot_ai";
+
+    /**
+     * 判断 AI 教练会员付费开关是否开启。
+     * 读取 sys_config.ai.coach.member_gate_enabled;缺失或解析异常时默认开启(member-only)。
+     */
+    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;
+        }
+    }
+
+    /**
+     * 判断用户是否有权使用 AI 教练功能。
+     * 开关关闭 → 所有登录用户可用;开关开启 → 需 FAMILY/PREMIUM 有效会员。
+     */
+    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;
+        }
+    }
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
@@ -561,6 +619,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 教练为会员专享功能,请先开通会员");
+        }
         Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
         Map<String, Object> inputs = new LinkedHashMap<>();
         if (memberId != null) {
@@ -598,6 +660,38 @@ public class AIChatController {
         return Result.success(result);
     }
 
+    /**
+     * AI 教练会员付费开关状态(小程序入口页调用,用于决定是否展示付费墙)。
+     * 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);
+        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);
+        return Result.success(result);
+    }
+
     // ── 健康管家 ──
 
     @Operation(summary = "发送健康管家消息")

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

@@ -0,0 +1,197 @@
+package com.etotem.cfc.controller.ai;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.SysConfigService;
+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.Mockito.*;
+
+/**
+ * AI 教练会员付费开关单元测试(AIChatController § gate 逻辑)。
+ *
+ * 测试矩阵:
+ * - 开关关闭 → 任何登录用户均可使用
+ * - 开关开启 + FREE → 返回 46003 错误
+ * - 开关开启 + BASIC → 返回 46003 错误
+ * - 开关开启 + FAMILY → 放行
+ * - 开关开启 + PREMIUM → 放行
+ * - gate 状态接口正确返回 enabled / canUse / memberLevel / memberExpireTime
+ */
+class AIChatControllerGateTest {
+
+    private AIChatController controller;
+    private SysConfigService sysConfigService;
+    private MembershipService membershipService;
+    private UserService userService;
+    private AiGateway aiGateway;
+
+    @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("aiGateway", aiGateway);
+        // aiGateway.chat 在放行路径会被调用,提供最小 stub
+        when(aiGateway.chat(anyString(), anyLong(), anyString(), anyMap()))
+                .thenReturn(new LinkedHashMap<>(Map.of("answer", "OK", "conversationId", "")));
+    }
+
+    private void inject(String name, Object v) throws Exception {
+        Field f = AIChatController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, v);
+    }
+
+    private Map<String, Object> sendMessageParams(String query) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("query", query);
+        return p;
+    }
+
+    @Test
+    void 开关关闭_FREE用户也可用() {
+        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("false");
+        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(200, r.getCode());
+        // 放行后调用 aiGateway.chat
+        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+    }
+
+    @Test
+    void 开关关闭_BASIC用户也可用() {
+        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("false");
+        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(200, r.getCode());
+        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+    }
+
+    @Test
+    void 开关开启_FREE用户被拦截返回46003() {
+        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
+        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(46003, r.getCode());
+        assertEquals("AI 教练为会员专享功能,请先开通会员", r.getMessage());
+        verify(aiGateway, never()).chat(anyString(), anyLong(), anyString(), anyMap());
+    }
+
+    @Test
+    void 开关开启_BASIC用户被拦截返回46003() {
+        when(sysConfigService.getValue("ai.coach.member_gate_enabled")).thenReturn("true");
+        when(membershipService.getMemberLevel(1L)).thenReturn("BASIC");
+        Result<?> r = controller.sendHealthCoachMessage(1L, sendMessageParams("你好"));
+        assertEquals(46003, r.getCode());
+        verify(aiGateway, never()).chat(anyString(), anyLong(), anyString(), anyMap());
+    }
+
+    @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("你好"));
+        assertEquals(200, r.getCode());
+        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+    }
+
+    @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("你好"));
+        assertEquals(200, r.getCode());
+        verify(aiGateway).chat(anyString(), eq(1L), anyString(), anyMap());
+    }
+
+    @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());
+    }
+
+    @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());
+    }
+
+    // ── 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));
+        Result<?> r = controller.aiCoachGateStatus(1L);
+        assertEquals(200, r.getCode());
+        Map<?, ?> d = (Map<?, ?>) r.getData();
+        assertEquals(false, d.get("enabled"));
+        assertEquals(true, d.get("canUse"));
+        assertEquals("FREE", d.get("memberLevel"));
+    }
+
+    @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");
+        Result<?> r = controller.aiCoachGateStatus(1L);
+        assertEquals(200, r.getCode());
+        Map<?, ?> d = (Map<?, ?>) r.getData();
+        assertEquals(true, d.get("enabled"));
+        assertEquals(false, d.get("canUse"));
+        assertEquals("FREE", d.get("memberLevel"));
+    }
+
+    @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");
+        Result<?> r = controller.aiCoachGateStatus(1L);
+        assertEquals(200, r.getCode());
+        Map<?, ?> d = (Map<?, ?>) r.getData();
+        assertEquals(true, d.get("enabled"));
+        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;
+    }
+}

+ 37 - 17
cfc-frontend/pages/home-pages/parent-index.vue

@@ -384,17 +384,30 @@
         </view>
       </view>
 
-      <!-- ===== 推荐商品+活动(混合信息流) ===== -->
-      <RecommendedFeed
-        :products="recommendedProducts"
-        :activities="recommendedActivities"
-        :loading="loadingProducts || loadingActivities"
-        @itemClick="onFeedItemClick"
-        @more="goMoreDiscover" />
-      <view class="error-state" v-if="errorProducts || errorActivities">
-        <text class="error-state-text">推荐加载失败</text>
+      <!-- ===== 推荐商品(商城两列大卡片) ===== -->
+      <view class="product-section">
+        <view class="article-header">
+          <text class="section-title">🛍️ 推荐商品</text>
+          <text class="article-more" @click="goMoreDiscover">更多 ›</text>
+        </view>
+        <DimensionProducts
+          :products="recommendedProducts"
+          :isLoggedIn="true"
+          :showMore="false"
+          layout="mall"
+          @productClick="onProductClick" />
+        <view class="error-state" v-if="errorProducts">
+          <text class="error-state-text">推荐加载失败</text>
+        </view>
       </view>
 
+      <!-- ===== 推荐活动(独立区块) ===== -->
+      <DimensionActivities
+        :activities="recommendedActivities"
+        :isLoggedIn="true"
+        @activityClick="onActivityClick"
+        @moreActivities="goMoreDiscover" />
+
       <!-- ===== 推荐阅读(书册风格) ===== -->
       <view class="article-section animate-fade-in animate-stagger-11">
         <view class="article-header">
@@ -508,7 +521,8 @@ import ContactCard from '@/pages/home-pages/components/ContactCard.vue'
 import FamilyHealthRanking from '@/pages/home-pages/components/FamilyHealthRanking.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import ContactImport from '@/pages/home-pages/components/ContactImport.vue'
-import RecommendedFeed from '../../components/RecommendedFeed.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
+import DimensionActivities from '../../components/DimensionActivities.vue'
 import MemberSelector from '@/components/MemberSelector.vue'
 import MicroActionCard from '@/pages/home-pages/components/MicroActionCard.vue'
 import FeedbackToast from '@/pages/home-pages/components/FeedbackToast.vue'
@@ -526,7 +540,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, MemberSelector, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar, AdBanner },
+  components: { DimensionProducts, DimensionActivities, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, MemberSelector, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar, AdBanner },
   data() {
     return {
       nickname: '',
@@ -1191,12 +1205,15 @@ export default {
     goToAssessmentReport() { uni.navigateTo({ url: '/pages/assessment/report' }) },
     goToActivities() { uni.navigateTo({ url: '/pages/activity/index' }) },
     goToRewards() { uni.navigateTo({ url: '/pages/rewards/rewards' }) },
-    onFeedItemClick(item) {
-      if (item.type === 'product') {
-        uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + item.id })
-      } else if (item.type === 'activity') {
-        uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + item.id })
-      }
+    onProductClick: function(prod) {
+      if (!prod) return
+      var pid = prod.productId || prod.id
+      if (!pid) return
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + pid })
+    },
+    onActivityClick: function(act) {
+      if (!act || !act.id) return
+      uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreDiscover() {
       uni.navigateTo({ url: '/pages/discover/index' })
@@ -1852,6 +1869,9 @@ export default {
 .article-section {
   margin-bottom: 48rpx;
 }
+.product-section {
+  margin-bottom: 48rpx;
+}
 .article-header {
   display: flex;
   justify-content: space-between;

+ 114 - 0
docs/superpowers/specs/2026-11-02-realname-nickname-backfill-design.md

@@ -0,0 +1,114 @@
+# 用户真实姓名回填昵称统一规则设计
+
+- 日期:2026-11-02
+- 状态:设计定稿(待派发)
+- 需求方:廖总
+- 来源:"检查当前所有要使用用户真实姓名的地方,如果添加姓名且 user 表的真实姓名还未填,则用这个姓名补充真实姓名和用户昵称"
+
+## 0. 背景与结论
+
+- 已有 `RealNameBackfillService.backfill(userId, contactName)`:仅回填 realName,不回填 nickname。
+- 所有"写 users.real_name"的入口(12 处后端 + 5+ 前端页面)在 realName 为空时均未联动补 nickname。
+- 管家/营养师申请前端收集了 realName,后端未持久化(独立缺口)。
+
+## 1. 统一回填规则
+
+### 触发条件
+任意向 `users.real_name` 写入非空值的操作(新注册、资料更新、地址回填、表单提交、后台创建等)。
+
+### 判定条件
+写入前 `users.real_name` 为空(`null || trim().isEmpty()`)。
+
+### 执行动作
+用该姓名同时补:
+- `users.real_name = 姓名`
+- `users.nickname = 姓名`(仅当 nickname 也为空/空白时),避免覆盖用户已有昵称。
+
+## 2. 所有需接入回填的入口(12 处后端)
+
+| # | 文件/方法 | 场景 | 当前行为 | 接入方式 |
+|---|---|---|---|---|
+| 1 | `UserService.updateUserInfo` | 用户资料页/加入家庭 | 仅写 realName | 写 realName 前调用 helper |
+| 2 | `UserService.backfillProfile` | 就地回写 | 仅写 realName | 写 realName 前调用 helper |
+| 3 | `UserService.registerWithIdCard` | 手机+身份证注册 | 仅写 realName | 新用户创建时调用 helper |
+| 4 | `RealNameBackfillService.backfill` | 地址/收货人回填 | 仅写 realName | **改为同时补两者** |
+| 5 | `GuideManagementController.apply` | 规划师注册 | 仅写 realName | 调用 helper |
+| 6 | `AdminController.createUser` | 后台创建用户 | 仅写 realName | 调用 helper |
+| 7 | `SupplyManageController` add/update | 供应商创建/更新 | 仅写 realName | 调用 helper |
+| 8 | `ButlerController.apply` | 管家申请 | 未接收 realName | **新增接收 + helper** |
+| 9 | `NutritionistController.apply` | 营养师申请 | 未接收 realName | **新增接收 + helper** |
+| 10 | `DataMigrationService` | 迁移脚本 | 仅写 realName | 按需(脚本级) |
+| 11 | `OrganizationService` | 组织服务 | 仅写 realName | 按需 |
+| 12 | `AccountCleanupService` | 注销置空 | 写空 | 不触发(姓名为空) |
+
+## 3. 实现方案
+
+### 3.1 扩展 RealNameBackfillService(推荐)
+
+```java
+// 新增:若 realName 为空,同时补 realName + nickname(nickname 仅在空时补)
+public void backfillRealNameAndNickname(Long userId, String name) {
+    if (userId == null || name == null || name.trim().isEmpty()) return;
+    User user = userMapper.selectById(userId);
+    if (user == null) return;
+    String realName = user.getRealName();
+    if (realName != null && !realName.trim().isEmpty()) return;
+    name = name.trim();
+    user.setRealName(name);
+    String nickname = user.getNickname();
+    if (nickname == null || nickname.trim().isEmpty()) {
+        user.setNickname(name);
+    }
+    userMapper.updateById(user);
+}
+```
+
+- 原 `backfill(Long userId, String contactName)` 内部改为调用 `backfillRealNameAndNickname`(保持兼容)。
+- 新 helper 可被其他入口直接调用。
+
+### 3.2 各入口接入点(伪代码)
+
+```java
+// updateUserInfo
+if (dto.getRealName() != null && !dto.getRealName().trim().isEmpty()) {
+    realNameBackfillService.backfillRealNameAndNickname(userId, dto.getRealName().trim());
+}
+
+// backfillProfile applyUserBackfillField case "realName"
+realNameBackfillService.backfillRealNameAndNickname(userId, value);
+
+// registerWithIdCard 新用户创建
+if (dto.getRealName() != null && !dto.getRealName().trim().isEmpty()) {
+    realNameBackfillService.backfillRealNameAndNickname(user.getId(), dto.getRealName().trim());
+}
+
+// Guide/Admin/Supply 控制器
+if (realName != null && !realName.trim().isEmpty()) {
+    realNameBackfillService.backfillRealNameAndNickname(userId, realName.trim());
+}
+
+// Butler/Nutritionist apply:先接收 realName 参数,再按上同理
+```
+
+## 4. 前端无需改动(但管家/营养师需后端接参)
+
+- `user-edit.vue`、`invite/join-form.vue`、`guide/register/index.vue` 均已传 `realName`,后端接入后自动生效。
+- `butler/apply.vue`、`nutritionist/apply.vue` 前端已有 `realName` 字段,仅需后端 `/api/butler/apply`、`/api/nutritionist/apply` 接收并落库。
+
+## 5. 约束与边界
+
+- nickname 仅在**空时**回填(`null || trim().isEmpty()`),保护用户已设昵称。
+- 触发条件严格为"realName 为空"——已有 realName 的用户修改姓名时不触发回填。
+- 幂等:同一姓名多次触发仅首次生效(realName 非空后即跳过)。
+- 失败静默不阻塞主流程(参考 `RealNameBackfillService` 现有风格)。
+- 管家/营养师申请现有逻辑不改动,仅在 `apply` 入口接收 realName + 调用 helper。
+
+## 6. 验收
+
+- [ ] 地址/收货人姓名回填时,realName 空 → 同时生成 realName + nickname(nickname 也为空时)。
+- [ ] 用户资料页首次填 realName(nickname 空)→ 两者同步。
+- [ ] 就地回写 realName(realName 空)→ 两者同步。
+- [ ] 手机+身份证注册、规划师注册、后台/供应商创建用户 → 首次带 realName 时两者同步。
+- [ ] 管家/营养师申请提交 realName → 落库并同步回填 nickname(若空)。
+- [ ] 用户已有 nickname 时填 realName → nickname 保持不变(不被覆盖)。
+- [ ] `mvn clean compile` 通过;未破坏现有用户体系。

+ 242 - 8
xiaofat-miniprogram/pages/ai-coach/index.vue

@@ -1,26 +1,82 @@
 <template>
   <view class="chat-page">
-    <scroll-view class="chat-body" scroll-y :scroll-top="scrollTop">
+    <!-- 付费墙:开关开启且当前用户非会员时显示 -->
+    <view v-if="gateLocked" class="paywall">
+      <view class="paywall-header">
+        <text class="lock-icon">🔒</text>
+        <text class="title">AI 教练</text>
+        <text class="subtitle">为会员专享功能,请先开通会员</text>
+      </view>
+      <view class="paywall-body">
+        <text class="desc">开通会员后可无限次使用减脂 AI 教练,获得个性化减脂方案与饮食建议。</text>
+        <button class="unlock-btn" :loading="paying" @click="openPlans">
+          {{ paying ? '支付中...' : '👉 开通会员解锁' }}
+        </button>
+      </view>
+    </view>
+
+    <!-- 正常对话页 -->
+    <scroll-view v-else class="chat-body" scroll-y :scroll-top="scrollTop">
       <view v-for="(msg, idx) in messages" :key="idx" class="msg-item" :class="msg.role">
         <view class="bubble">{{ msg.content }}</view>
       </view>
+      <view v-if="sending" class="loading-msg">
+        <text class="dot"></text><text class="dot"></text><text class="dot"></text>
+      </view>
     </scroll-view>
 
-    <view class="chat-input">
-      <input class="input" v-model="inputText" placeholder="输入您的问题..." @confirm="send" confirm-type="send" />
-      <button class="send-btn" size="mini" @click="send">发送</button>
+    <view v-if="!gateLocked" class="chat-input">
+      <input class="input" v-model="inputText" placeholder="输入您的问题..." @confirm="send" confirm-type="send" :disabled="sending" />
+      <button class="send-btn" size="mini" @click="send" :loading="sending">发送</button>
+    </view>
+
+    <!-- 会员方案选择弹窗 -->
+    <view v-if="showPlans" class="modal-mask" @click="showPlans=false">
+      <view class="modal-content" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">选择会员方案</text>
+          <text class="modal-close" @click="showPlans=false">✕</text>
+        </view>
+        <scroll-view scroll-y class="plans-list">
+          <view
+            v-for="(plan, i) in plans"
+            :key="plan.levelCode + plan.period"
+            class="plan-item"
+            :class="{selected: selectedPlanIndex===i}"
+            @click="selectPlan(i)"
+          >
+            <view class="plan-left">
+              <text class="plan-name">{{ plan.levelName || plan.levelCode }}</text>
+              <text class="plan-period">{{ plan.period === 'monthly' ? '月付' : plan.period === 'quarterly' ? '季付' : '年付' }}</text>
+            </view>
+            <view class="plan-right">
+              <text v-if="plan.originalPrice" class="plan-original">{{ (plan.originalPrice/100).toFixed(2) }}元</text>
+              <text class="plan-price">{{ (plan.yearly/100).toFixed(2) }}元</text>
+            </view>
+          </view>
+        </scroll-view>
+        <button class="pay-btn" :loading="paying" @click="confirmPay">{{ paying ? '支付中...' : '立即开通' }}</button>
+      </view>
     </view>
   </view>
 </template>
 
 <script>
-import { aiCoachSend } from '../../utils/api.js'
+import { aiCoachSend, getAiCoachGateStatus, getMembershipPlans, createMembershipOrder } from '../../utils/api.js'
 export default {
   data() {
     return {
       inputText: '',
       messages: [],
-      scrollTop: 0
+      scrollTop: 0,
+      sending: false,
+      // 付费开关相关
+      gateLocked: false,
+      gateInfo: null,
+      showPlans: false,
+      plans: [],
+      selectedPlanIndex: 0,
+      paying: false
     }
   },
   onLoad() {
@@ -28,18 +84,154 @@ export default {
       role: 'assistant',
       content: '你好,我是你的减脂AI教练,请告诉我你的目标和当前情况,我会帮你制定合适的减脂计划。'
     })
+    this.loadGate()
   },
   methods: {
+    loadGate() {
+      getAiCoachGateStatus().then(res => {
+        this.gateInfo = res.data
+        if (res.data && res.data.enabled && !res.data.canUse) {
+          this.gateLocked = true
+        }
+      }).catch(() => {
+        // 接口异常默认放行(fail-open),不阻断体验
+        this.gateLocked = false
+      })
+    },
+    openPlans() {
+      this.showPlans = true
+      this.plans = []
+      this.selectedPlanIndex = 0
+      this.paying = false
+      getMembershipPlans().then(res => {
+        if (res.data && Array.isArray(res.data)) {
+          // 按 levelCode 分组:每个 levelCode 列出 year/month/quarterly 三个档期
+          var map = {}
+          res.data.forEach(p => {
+            if (!map[p.levelCode]) map[p.levelCode] = {}
+            map[p.levelCode][p.period || 'yearly'] = p
+          })
+          var list = []
+          Object.keys(map).forEach(code => {
+            var m = map[code]
+            var yearly = m.yearly || {}
+            var monthly = m.monthly || {}
+            var quarterly = m.quarterly || {}
+            var defaultPeriod = 'yearly'
+            ;[{period:'yearly',price:yearly.yearly||0,orig:yearly.originalYearly||0},{period:'monthly',price:monthly.monthly||0,orig:monthly.originalMonthly||0},{period:'quarterly',price:quarterly.quarterly||0,orig:quarterly.originalQuarterly||0}]
+              .filter(p => p.price > 0)
+              .forEach(p => list.push(Object.assign({levelCode:code, levelName:m[defaultPeriod].levelName||p.levelName||code}, p)))
+          })
+          this.plans = list
+          if (this.plans.length > 0) this.selectedPlanIndex = 0
+        }
+      }).catch(() => {
+        uni.showToast({ title: '获取方案失败', icon: 'none' })
+        this.showPlans = false
+      })
+    },
+    selectPlan(idx) {
+      this.selectedPlanIndex = idx
+    },
+    confirmPay() {
+      if (this.plans.length === 0) return
+      var plan = this.plans[this.selectedPlanIndex]
+      if (!plan) return
+      this.paying = true
+      var payload = {
+        levelCode: plan.levelCode,
+        paymentType: plan.period,
+        period: plan.period
+      }
+      createMembershipOrder(payload).then(res => {
+        this.handlePayResult(res.data)
+      }).catch(err => {
+        this.paying = false
+        uni.showToast({ title: '创建订单失败', icon: 'none' })
+      })
+    },
+    handlePayResult(data) {
+      this.paying = false
+      if (!data) return
+      if (data.payType === 'none') {
+        // 试用/直接开通
+        uni.showToast({ title: '开通成功', icon: 'success' })
+        this.showPlans = false
+        this.gateLocked = false
+        return
+      }
+      if (data.payType === 'virtual') {
+        // 微信虚拟支付(道具直购)
+        this.paying = true
+        var signData = JSON.stringify(data.orderInfo || {})
+        wx.requestVirtualPayment({
+          signData: signData,
+          extInfo: data.extInfo || {},
+          sign: data.sign || '',
+          signature: data.signature || '',
+          env: data.env,
+          success: () => {
+            this.paying = false
+            uni.showToast({ title: '开通成功', icon: 'success' })
+            this.showPlans = false
+            this.loadGate()
+          },
+          fail: (err) => {
+            this.paying = false
+            console.error('虚拟支付失败', err)
+            uni.showModal({ title: '提示', content: '支付失败,是否改用普通微信支付?', showCancel: false, confirmText: '我知道了' })
+          }
+        })
+        return
+      }
+      if (data.payType === 'wechat') {
+        var payParams = data.wechatPayParams
+        if (!payParams) {
+          uni.showToast({ title: '支付参数异常', icon: 'none' })
+          return
+        }
+        this.paying = true
+        uni.requestPayment({
+          ...payParams,
+          success: () => {
+            this.paying = false
+            uni.showToast({ title: '开通成功', icon: 'success' })
+            this.showPlans = false
+            this.loadGate()
+          },
+          fail: (err) => {
+            this.paying = false
+            console.error('微信支付失败', err)
+            uni.showModal({ title: '提示', content: '支付未完成,请重新发起开通', showCancel: false, confirmText: '我知道了' })
+          }
+        })
+        return
+      }
+      uni.showToast({ title: '未知支付方式', icon: 'none' })
+    },
     scrollToBottom() {
       setTimeout(() => {
         this.scrollTop = this.messages.length * 1000
       }, 100)
     },
     send() {
-      if (!this.inputText.trim()) return
+      if (this.gateLocked) {
+        uni.showModal({
+          title: 'AI 教练',
+          content: 'AI 教练为会员专享功能,请先开通会员',
+          confirmText: '去开通',
+          cancelText: '取消',
+          success: (res) => {
+            if (res.confirm) this.openPlans()
+          }
+        })
+        return
+      }
+      if (!this.inputText.trim() || this.sending) return
       var content = this.inputText.trim()
       this.messages.push({ role: 'user', content: content })
       this.inputText = ''
+      this.sending = true
       this.scrollToBottom()
 
       var payload = {
@@ -47,15 +239,25 @@ export default {
         history: this.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content }))
       }
       aiCoachSend(payload).then(res => {
+        this.sending = false
         if (res.data && res.data.response) {
           this.messages.push({ role: 'assistant', content: res.data.response })
         } else if (res.data && res.data.reply) {
           this.messages.push({ role: 'assistant', content: res.data.reply })
+        } else if (res.code === 46003) {
+          // 会员门禁:跳转开通
+          this.gateLocked = true
+          uni.showModal({ title: 'AI 教练', content: res.message || 'AI 教练为会员专享功能,请先开通会员', confirmText: '去开通', cancelText: '取消', success: r => { if (r.confirm) this.openPlans() })
         } else {
           this.messages.push({ role: 'assistant', content: '抱歉,我暂时无法回答这个问题。' })
         }
         this.scrollToBottom()
-      }).catch(() => {
+      }).catch(err => {
+        this.sending = false
+        // api.js 非 200 会 showToast + reject;46003 已在前端 catch 处理
+        if (err && err.code === 46003) {
+          this.gateLocked = true
+        }
         this.messages.push({ role: 'assistant', content: '网络异常,请稍后重试。' })
         this.scrollToBottom()
       })
@@ -76,4 +278,36 @@ export default {
 .chat-input { display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; align-items: center; }
 .input { flex: 1; height: 80rpx; padding: 0 20rpx; background: #f5f5f5; border-radius: 40rpx; margin-right: 20rpx; }
 .send-btn { background: #F97316; color: #fff; border-radius: 40rpx; }
+.loading-msg { display: flex; justify-content: flex-start; margin-bottom: 30rpx; padding-left: 10rpx; }
+.dot { width: 14rpx; height: 14rpx; background: #ccc; border-radius: 50%; margin: 0 8rpx; animation: dot-bounce 1.2s infinite; }
+.dot:nth-child(2) { animation-delay: 0.2s; }
+.dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes dot-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } }
+
+/* 付费墙 */
+.paywall { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80rpx 40rpx; box-sizing: border-box; background: linear-gradient(135deg, #FFF7ED 0%, #FFF 60%); }
+.paywall-header { text-align: center; margin-bottom: 60rpx; }
+.lock-icon { font-size: 100rpx; display: block; margin-bottom: 20rpx; }
+.paywall-header .title { font-size: 44rpx; font-weight: 700; color: #111; display: block; }
+.paywall-header .subtitle { font-size: 28rpx; color: #F97316; margin-top: 12rpx; display: block; }
+.paywall-body { width: 100%; background: #fff; border-radius: 24rpx; padding: 40rpx; box-sizing: border-box; box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.06); }
+.paywall-body .desc { font-size: 28rpx; color: #666; line-height: 1.7; display: block; margin-bottom: 40rpx; }
+.unlock-btn { width: 100%; background: #F97316; color: #fff; border-radius: 48rpx; height: 96rpx; line-height: 96rpx; font-size: 32rpx; font-weight: 600; }
+
+/* 方案弹窗 */
+.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); z-index: 1000; display: flex; align-items: flex-end; }
+.modal-content { width: 100%; background: #fff; border-radius: 32rpx 32rpx 0 0; padding: 40rpx; box-sizing: border-box; max-height: 85vh; display: flex; flex-direction: column; }
+.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30rpx; }
+.modal-title { font-size: 34rpx; font-weight: 700; color: #111; }
+.modal-close { font-size: 36rpx; color: #999; padding: 10rpx; }
+.plans-list { flex: 1; overflow-y: auto; margin-bottom: 30rpx; }
+.plan-item { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border: 2rpx solid #f0f0f0; border-radius: 20rpx; margin-bottom: 20rpx; }
+.plan-item.selected { border-color: #F97316; background: #FFF7ED; }
+.plan-left { display: flex; flex-direction: column; }
+.plan-name { font-size: 30rpx; font-weight: 600; color: #111; }
+.plan-period { font-size: 24rpx; color: #999; margin-top: 6rpx; }
+.plan-right { text-align: right; }
+.plan-original { font-size: 24rpx; color: #bbb; text-decoration: line-through; }
+.plan-price { font-size: 32rpx; font-weight: 700; color: #F97316; }
+.pay-btn { width: 100%; background: #F97316; color: #fff; border-radius: 48rpx; height: 96rpx; line-height: 96rpx; font-size: 32rpx; font-weight: 600; }
 </style>

+ 18 - 0
xiaofat-miniprogram/utils/api.js

@@ -200,4 +200,22 @@ export const abandonGoal = (id) => {
 // ===== AI 教练(走主系统 AiGateway) =====
 export const aiCoachSend = (data) => {
   return request('/api/ai/health-coach/send', 'POST', data)
+}
+
+// ===== AI 教练会员付费开关 =====
+export const getAiCoachGateStatus = () => {
+  return request('/api/ai/health-coach/gate', 'POST', {})
+}
+
+// ===== 会员 =====
+export const getMyMembership = () => {
+  return request('/api/membership/my', 'POST', {})
+}
+
+export const getMembershipPlans = () => {
+  return request('/api/membership/plans', 'POST', {})
+}
+
+export const createMembershipOrder = (data) => {
+  return request('/api/membership/orders', 'POST', data)
 }