2026-08-23-coach-butler.md 53 KB

AI健康教练人格分化与管家自助选择 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 复用吉祥物管线为健康教练提供浠宝/福宝双人格话术;L2 家庭可自助浏览并绑定/更换真人管家;管理端补齐分配管理与停接开关。

架构: 后端在现有 butler 体系上增量扩展(1 列迁移 + 1 新实体 + ButlerService 方法 + 2 个控制器);AI 人格经 /health-coach/send 补注入 coach_id,经 AiGateway context 透传至 LangGraph 按 prompt_key 三级回退路由;小程序新增选择页三态视图;Web 管理端新增分配管理视图与开关列。

技术栈: Spring Boot 2.7.18 + MyBatis-Plus(Java 8)/ FastAPI + LangGraph(Python)/ uni-app Vue 2 Options API / Vue 2 + Element UI

规格: docs/superpowers/specs/2026-08-23-coach-butler-design.md(已批准,commit 8cc0a758)

⚠️ 本仓库验证约定(覆盖技能默认 TDD 流程):

  • 后端唯一验证方式:cd cfc-backend && mvn clean compile(EXIT 0 即通过,无测试基建)
  • 小程序前端验证:提取改动 .vue 的 <script> 块存临时文件后 node --check;禁止运行整包 build
  • 所有 git 命令带 GIT_MASTER=1 前缀;commit message 用中文 SEMANTIC 风格(feat:/fix:/docs:)
  • 每个任务结束必须 commit(单文件或强耦合文件组一个 commit)

文件结构总览

# 文件 动作 职责
1 cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java 修改 迁移261:butler_profiles.accepting
2 cfc-backend/src/main/resources/schema.sql 修改 同步 accepting 列定义
3 cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerProfile.java 修改 加 accepting 字段
4 cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.java 创建 butler_assignments 表实体(当前全库无引用,本功能为首写入方)
5 cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerAssignmentMapper.java 创建 BaseMapper
6 cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java 修改 家庭端三方法 + 管理端三方法
7 cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.java 创建 家庭端 3 端点
8 cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java 修改 管理端 3 端点
9 cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java 修改 /health-coach/send 注入 coach_id
10 cfc-langgraph/app/graphs/health_coach_graph.py 修改 generate_answer prompt 路由
11 cfc-frontend/utils/api.js 修改 3 个接口封装
12 cfc-frontend/pages/butler/select.vue 创建 管家选择页(三态)
13 cfc-frontend/pages.json 修改 注册 select 页
14 cfc-frontend/pages/ai/chat.vue 修改 欢迎语带教练名
15 cfc-frontend/pages/membership/index.vue 修改 我的管家入口卡片
16 cfc-web/src/api/butler.js 修改 3 个封装
17 cfc-web/src/views/admin/ButlerAssignments.vue 创建 分配管理视图
18 cfc-web/src/views/admin/review/ButlerAudit.vue 修改 停接开关列
19 docs/superpowers/api/API_REFERENCE.md 修改 登记 6 个新接口

任务 1:DB 迁移 —— butler_profiles.accepting 停接开关列

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(迁移260 块之后,约 :9143 起)
  • 修改:cfc-backend/src/main/resources/schema.sql(butler_profiles CREATE TABLE,约 :2646 commission_rate_l2 行后)
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerProfile.java(commissionRateL2 字段后,约 :40)

  • [ ] 步骤 1.1:DatabaseInitializer 添加迁移 261

在 // 迁移260: 创建圈子评论表 整块结束后、下一个方法或空行处插入:

		// 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
		ensureColumn("butler_profiles", "accepting",
				"TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");

注意保持与相邻代码相同的缩进风格(该文件使用 Tab)。

  • 步骤 1.2:schema.sql 同步

在 CREATE TABLE IF NOT EXISTS butler_profiles ( 块内,commission_rate_l2 INT DEFAULT 5000, 行之后插入一行:

    accepting TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单',
  • 步骤 1.3:实体加字段

在 ButlerProfile.java 的 private Integer commissionRateL2; 之后加:

    /**
     * 是否接受新家庭绑定: 0=停接/1=接单
     */
    private Integer accepting;
  • 步骤 1.4:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 1.5:Commit

    GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
    cfc-backend/src/main/resources/schema.sql \
    cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerProfile.java
    GIT_MASTER=1 git commit -m "feat: butler_profiles 新增accepting停接开关列"
    

任务 2:新建 ButlerAssignment 实体与 Mapper

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerAssignmentMapper.java

表结构参照(schema.sql 已有,勿改):id, family_id(UNIQUE uk_family_assignment), butler_user_id, subscription_id, level, assigned_at

  • 步骤 2.1:确认 Mapper 风格

运行:cat cfc-backend/src/main/java/com/etom/cfc/mapper/ButlerProfileMapper.java 2>/dev/null || cat cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerProfileMapper.java 观察:是否有 @Mapper 注解、是否继承 BaseMapper<T>。新 Mapper 与其保持完全一致的风格。

  • 步骤 2.2:创建实体

entity/ButlerAssignment.java:

package com.etotem.cfc.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.util.Date;

/**
 * 会员-管家分配记录(家庭自助选择/换绑写入)
 */
@Data
@TableName("butler_assignments")
public class ButlerAssignment {

    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 家庭 ID(uk_family_assignment 唯一)
     */
    private Long familyId;

    /**
     * 管家用户 ID
     */
    private Long butlerUserId;

    /**
     * 关联订阅 ID
     */
    private Long subscriptionId;

    /**
     * 订阅级别 L1/L2
     */
    private String level;

    private Date assignedAt;
}

若项目实体不使用 Lombok @Data(对照 ButlerProfile.java 是否有 getter/setter 手写),则改为手写各字段的 getter/setter,去掉 @Data。

  • 步骤 2.3:创建 Mapper

mapper/ButlerAssignmentMapper.java(按步骤 2.1 观察到的风格调整注解):

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ButlerAssignment;

public interface ButlerAssignmentMapper extends BaseMapper<ButlerAssignment> {
}
  • 步骤 2.4:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 2.5:Commit

    GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.java \
    cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerAssignmentMapper.java
    GIT_MASTER=1 git commit -m "feat: 新增butler_assignments实体与Mapper"
    

任务 3:ButlerService 家庭端方法(浏览/查询/绑定)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java

已有依赖:@Resource UserMapper userMapper; @Resource ButlerProfileMapper butlerProfileMapper; @Resource UserService userService; 需要新增依赖:ButlerAssignmentMapper、MemberSubscriptionService(Bean 名与类名首字母小写一致,符合注入规范)。

  • 步骤 3.1:添加依赖注入与 import

import 区新增:

import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.etotem.cfc.entity.ButlerAssignment;
import com.etotem.cfc.mapper.ButlerAssignmentMapper;
import com.etotem.cfc.service.MemberSubscriptionService;
import com.etotem.cfc.entity.MemberSubscription;

类内字段区新增:

    @Resource
    private ButlerAssignmentMapper butlerAssignmentMapper;

    @Resource
    private MemberSubscriptionService memberSubscriptionService;
  • 步骤 3.2:追加三个家庭端方法

在类末尾(assignMemberToButler(Long familyId, ...) 方法 } 之后、类结束 } 之前)追加:

    // ==================== 家庭端自助选择 ====================

    /**
     * 可选管家列表(浏览开放,不校验会员等级)
     * 条件:approved + tier=L2 + accepting=1 + 有余量;返回脱敏档案
     */
    public Result<List<Map<String, Object>>> getAvailableButlers() {
        LambdaQueryWrapper<ButlerProfile> query = new LambdaQueryWrapper<>();
        query.eq(ButlerProfile::getStatus, "approved")
             .eq(ButlerProfile::getTier, "L2")
             .eq(ButlerProfile::getAccepting, 1)
             .apply("member_count < max_members")
             .orderByAsc(ButlerProfile::getCreatedAt);
        SortUtil.applySort(query);
        List<ButlerProfile> profiles = butlerProfileMapper.selectList(query);

        List<Long> userIds = new java.util.ArrayList<>();
        for (ButlerProfile p : profiles) {
            userIds.add(p.getUserId());
        }
        Map<Long, User> userMap = new HashMap<>();
        if (!userIds.isEmpty()) {
            for (User u : userMapper.selectBatchIds(userIds)) {
                userMap.put(u.getId(), u);
            }
        }

        List<Map<String, Object>> result = new java.util.ArrayList<>();
        for (ButlerProfile p : profiles) {
            User u = userMap.get(p.getUserId());
            Map<String, Object> item = new HashMap<>();
            item.put("butlerUserId", p.getUserId());
            item.put("nickname", u != null ? u.getNickname() : "管家");
            item.put("avatar", u != null ? u.getAvatar() : "");
            item.put("description", p.getDescription());
            item.put("certificates", p.getCertificates());
            item.put("remainingSlots", (p.getMaxMembers() == null ? 0 : p.getMaxMembers())
                    - (p.getMemberCount() == null ? 0 : p.getMemberCount()));
            result.add(item);
        }
        return Result.success(result);
    }

    /**
     * 当前家庭的管家绑定信息(无绑定时 data 为 null)
     */
    public Result<Map<String, Object>> getMyButler(Long userId) {
        Long familyId = requireFamilyId(userId);
        if (familyId == null) {
            return Result.error("未找到家庭信息");
        }
        ButlerAssignment assignment = butlerAssignmentMapper.selectOne(
                new LambdaQueryWrapper<ButlerAssignment>()
                        .eq(ButlerAssignment::getFamilyId, familyId)
                        .last("LIMIT 1"));
        if (assignment == null) {
            return Result.success(null);
        }
        return Result.success(buildButlerSummary(assignment));
    }

    /**
     * 自助绑定/更换管家:L2 校验 + 条件扣容 + INSERT/UPDATE 双路径
     */
    @Transactional(rollbackFor = Exception.class)
    public Result<Map<String, Object>> selectButler(Long userId, Long butlerUserId) {
        Long familyId = requireFamilyId(userId);
        if (familyId == null) {
            return Result.error("未找到家庭信息");
        }
        // ① L2 有效订阅校验
        MemberSubscription sub = memberSubscriptionService.getActiveSubscription(familyId);
        boolean active = sub != null && sub.getExpireTime() != null
                && sub.getExpireTime().after(new Date());
        if (!active || !"L2".equals(sub.getLevel())) {
            return Result.error("该功能面向久久一生(L2)会员家庭");
        }
        // ② 目标管家校验
        ButlerProfile butler = butlerProfileMapper.selectOne(
                new LambdaQueryWrapper<ButlerProfile>()
                        .eq(ButlerProfile::getUserId, butlerUserId)
                        .eq(ButlerProfile::getStatus, "approved")
                        .eq(ButlerProfile::getTier, "L2")
                        .eq(ButlerProfile::getAccepting, 1)
                        .last("LIMIT 1"));
        if (butler == null) {
            return Result.error("该管家不可选择");
        }
        // ③ 条件原子扣容(防并发超卖)
        LambdaUpdateWrapper<ButlerProfile> incr = new LambdaUpdateWrapper<>();
        incr.eq(ButlerProfile::getId, butler.getId())
            .apply("member_count < max_members")
            .setSql("member_count = member_count + 1");
        int affected = butlerProfileMapper.update(null, incr);
        if (affected == 0) {
            return Result.error("该管家名额已满");
        }
        // ④ assignments INSERT/UPDATE 双路径(激活自动分配只加计数不建行)
        ButlerAssignment existing = butlerAssignmentMapper.selectOne(
                new LambdaQueryWrapper<ButlerAssignment>()
                        .eq(ButlerAssignment::getFamilyId, familyId)
                        .last("LIMIT 1"));
        if (existing != null) {
            // 旧管家计数回退
            LambdaUpdateWrapper<ButlerProfile> decr = new LambdaUpdateWrapper<>();
            decr.eq(ButlerProfile::getUserId, existing.getButlerUserId())
                .setSql("member_count = GREATEST(member_count - 1, 0)");
            butlerProfileMapper.update(null, decr);

            existing.setButlerUserId(butlerUserId);
            existing.setLevel(sub.getLevel());
            existing.setAssignedAt(new Date());
            butlerAssignmentMapper.updateById(existing);
        } else {
            ButlerAssignment na = new ButlerAssignment();
            na.setFamilyId(familyId);
            na.setButlerUserId(butlerUserId);
            na.setSubscriptionId(sub.getId());
            na.setLevel(sub.getLevel());
            na.setAssignedAt(new Date());
            butlerAssignmentMapper.insert(na);
        }
        log.info("Family {} selected butler {}, level={}", familyId, butlerUserId, sub.getLevel());

        ButlerAssignment saved = butlerAssignmentMapper.selectOne(
                new LambdaQueryWrapper<ButlerAssignment>()
                        .eq(ButlerAssignment::getFamilyId, familyId)
                        .last("LIMIT 1"));
        return Result.success(buildButlerSummary(saved));
    }

    /**
     * userId -> familyId(与 MembershipController 同源:users.family_id)
     */
    private Long requireFamilyId(Long userId) {
        User user = userMapper.selectById(userId);
        return user != null ? user.getFamilyId() : null;
    }

    /**
     * 组装绑定摘要(脱敏:不含联系方式)
     */
    private Map<String, Object> buildButlerSummary(ButlerAssignment assignment) {
        Map<String, Object> summary = new HashMap<>();
        summary.put("butlerUserId", assignment.getButlerUserId());
        summary.put("level", assignment.getLevel());
        summary.put("assignedAt", assignment.getAssignedAt());
        User bu = userMapper.selectById(assignment.getButlerUserId());
        summary.put("nickname", bu != null ? bu.getNickname() : "管家");
        summary.put("avatar", bu != null ? bu.getAvatar() : "");
        ButlerProfile bp = butlerProfileMapper.selectOne(
                new LambdaQueryWrapper<ButlerProfile>()
                        .eq(ButlerProfile::getUserId, assignment.getButlerUserId())
                        .last("LIMIT 1"));
        summary.put("description", bp != null ? bp.getDescription() : "");
        return summary;
    }
  • 步骤 3.3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。若报 getAccepting() 不存在,检查任务 1 的实体字段;若报 MemberSubscription import 找不到,确认包路径 com.etotem.cfc.entity.MemberSubscription。

  • [ ] 步骤 3.4:Commit

    GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java
    GIT_MASTER=1 git commit -m "feat: ButlerService新增管家浏览/查询/自助绑定方法"
    

任务 4:FamilyButlerSelectController 家庭端接口

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.java

Bean 名 familyButlerSelectController 与既有 butlerController 无冲突。禁止 Controller 直连 Mapper——全部走 ButlerService(任务 3 已封装)。

  • [ ] 步骤 4.1:创建控制器

    package com.etotem.cfc.controller.butler;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.service.ButlerService;
    import io.swagger.annotations.ApiOperation;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestAttribute;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.List;
    import java.util.Map;
    
    /**
    * 家庭端管家自助选择(仅 L2 绑定受限,浏览开放)
    */
    @RestController
    @RequestMapping("/api/butler")
    public class FamilyButlerSelectController {
    
    @Resource
    private ButlerService butlerService;
    
    @ApiOperation("可选管家列表(浏览开放)")
    @PostMapping("/available-list")
    public Result<List<Map<String, Object>>> availableList() {
        return butlerService.getAvailableButlers();
    }
    
    @ApiOperation("我家的管家绑定信息")
    @PostMapping("/my-butler")
    public Result<Map<String, Object>> myButler(@RequestAttribute("userId") Long userId) {
        return butlerService.getMyButler(userId);
    }
    
    @ApiOperation("绑定/更换管家(仅L2)")
    @PostMapping("/select")
    public Result<Map<String, Object>> select(
            @RequestAttribute("userId") Long userId,
            @RequestBody Map<String, Object> params) {
        Object butlerUserIdObj = params.get("butlerUserId");
        if (butlerUserIdObj == null || butlerUserIdObj.toString().trim().isEmpty()) {
            return Result.error("缺少 butlerUserId 参数");
        }
        Long butlerUserId = Long.valueOf(butlerUserIdObj.toString());
        return butlerService.selectButler(userId, butlerUserId);
    }
    }
    

注意:Result 的实际包路径以 ButlerService.java 现有 import 为准(com.etotem.cfc.common.Result)。若 ButlerService 中三方法返回的 Result 泛型与此不一致,以 ButlerService 定义为准同步控制器签名。

  • 步骤 4.2:路由冲突检查

运行:grep -rn '"/api/butler' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u 预期:新增的 /available-list、/my-butler、/select 不与任何既有路径重复。

  • 步骤 4.3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4.4:Commit

    GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.java
    GIT_MASTER=1 git commit -m "feat: 家庭端管家自助选择接口"
    

任务 5:管理端接口(分配列表/强制解绑/停接开关)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java

  • [ ] 步骤 5.1:确认管理员鉴权模式

运行:sed -n '1,60p' cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java 记录该控制器如何校验管理员(通常 @RequestAttribute("role") + "admin".equals(role) 判断)。后续端点完全复制该守卫模式。

  • 步骤 5.2:ButlerService 追加管理端方法

在任务 3 追加代码之后继续追加(import 额外需要 com.baomidou.mybatisplus.extension.plugins.pagination.Page,该文件头部已 import):

    // ==================== 管理端分配管理 ====================

    /**
     * 分配关系分页列表
     */
    public Result<Map<String, Object>> listAssignments(Integer pageNum, Integer pageSize) {
        int pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
        int ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
        Page<ButlerAssignment> page = butlerAssignmentMapper.selectPage(new Page<>(pn, ps),
                new LambdaQueryWrapper<ButlerAssignment>()
                        .orderByDesc(ButlerAssignment::getAssignedAt));

        List<Map<String, Object>> rows = new java.util.ArrayList<>();
        for (ButlerAssignment a : page.getRecords()) {
            Map<String, Object> row = new HashMap<>();
            row.put("id", a.getId());
            row.put("familyId", a.getFamilyId());
            row.put("butlerUserId", a.getButlerUserId());
            row.put("level", a.getLevel());
            row.put("assignedAt", a.getAssignedAt());
            User bu = userMapper.selectById(a.getButlerUserId());
            row.put("butlerNickname", bu != null ? bu.getNickname() : "");
            rows.add(row);
        }
        Map<String, Object> data = new HashMap<>();
        data.put("total", page.getTotal());
        data.put("records", rows);
        return Result.success(data);
    }

    /**
     * 强制解绑(物理删除分配行 + 回退管家计数)
     */
    @Transactional(rollbackFor = Exception.class)
    public Result<Void> unassign(Long assignmentId) {
        ButlerAssignment assignment = butlerAssignmentMapper.selectById(assignmentId);
        if (assignment == null) {
            return Result.error("分配记录不存在");
        }
        butlerAssignmentMapper.deleteById(assignmentId);

        LambdaUpdateWrapper<ButlerProfile> decr = new LambdaUpdateWrapper<>();
        decr.eq(ButlerProfile::getUserId, assignment.getButlerUserId())
            .setSql("member_count = GREATEST(member_count - 1, 0)");
        butlerProfileMapper.update(null, decr);

        log.info("Admin unassigned assignmentId={}, family={}, butler={}",
                assignmentId, assignment.getFamilyId(), assignment.getButlerUserId());
        return Result.success(null);
    }

    /**
     * 管家停接/恢复接单开关(不影响存量绑定)
     */
    public Result<Void> toggleAccepting(Long butlerUserId, Integer accepting) {
        if (accepting == null || (accepting != 0 && accepting != 1)) {
            return Result.error("accepting 取值只能为 0 或 1");
        }
        ButlerProfile profile = butlerProfileMapper.selectOne(
                new LambdaQueryWrapper<ButlerProfile>()
                        .eq(ButlerProfile::getUserId, butlerUserId)
                        .last("LIMIT 1"));
        if (profile == null) {
            return Result.error("管家不存在");
        }
        profile.setAccepting(accepting);
        profile.setUpdatedAt(new Date());
        butlerProfileMapper.updateById(profile);
        return Result.success(null);
    }
  • 步骤 5.3:AdminButlerController 追加端点

在该控制器最后一个方法之后追加(守卫代码从步骤 5.1 观察到的现有方法中逐字复制;下例假设标准模式):

    @Operation(summary = "分配关系分页列表")
    @PostMapping("/assignments")
    public Result<Map<String, Object>> assignments(@RequestBody Map<String, Object> params) {
        // ← 此处粘贴步骤 5.1 观察到的管理员守卫代码(与现有方法一致)
        Integer pageNum = params.get("pageNum") != null ? Integer.valueOf(params.get("pageNum").toString()) : 1;
        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 10;
        return butlerService.listAssignments(pageNum, pageSize);
    }

    @Operation(summary = "强制解绑")
    @PostMapping("/unassign")
    public Result<Void> unassign(@RequestBody Map<String, Object> params) {
        // ← 同上粘贴守卫代码
        if (params.get("assignmentId") == null) {
            return Result.error("缺少 assignmentId");
        }
        return butlerService.unassign(Long.valueOf(params.get("assignmentId").toString()));
    }

    @Operation(summary = "管家停接开关")
    @PostMapping("/toggle-accepting")
    public Result<Void> toggleAccepting(@RequestBody Map<String, Object> params) {
        // ← 同上粘贴守卫代码
        if (params.get("butlerUserId") == null || params.get("accepting") == null) {
            return Result.error("缺少参数");
        }
        return butlerService.toggleAccepting(
                Long.valueOf(params.get("butlerUserId").toString()),
                Integer.valueOf(params.get("accepting").toString()));
    }

同时确认控制器中已存在 @Resource private ButlerService butlerService;(AdminButlerController 现有 assign-member 功能应已注入;若无则补上,字段名必须为 butlerService)。@Operation 注解包路径以该文件现有用法为准。

  • 步骤 5.4:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 5.5:Commit

    GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java \
    cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java
    GIT_MASTER=1 git commit -m "feat: 管理端分配管理与停接开关接口"
    

任务 6:/health-coach/send 注入教练人格

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java(sendHealthCoachMessage 方法内,约 :420-447)

现状:该方法只放 child_id,缺 mascot 注入(/chat/send :76-109 已有完整范式)。

  • 步骤 6.1:定位并插入注入代码

找到 sendHealthCoachMessage 内这一段:

        Map<String, Object> inputs = new LinkedHashMap<>();
        if (memberId != null) {
            inputs.put("child_id", memberId);
        }
        Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);

在 inputs.put("child_id", memberId); 的 } 之后、Map<String, Object> resp = ... 之前插入:

        // 注入教练人格(对齐 /chat/send 的 mascot 范式;coach_id 用于 LangGraph prompt 路由)
        com.etotem.cfc.entity.User hcUser = userService.getUserInfo(userId);
        String hcMascotCode = hcUser != null ? hcUser.getMascot() : null;
        if (hcMascotCode != null && !hcMascotCode.isEmpty()) {
            com.etotem.cfc.enums.MascotEnum hcMascot = com.etotem.cfc.enums.MascotEnum.fromCode(hcMascotCode);
            if (hcMascot != null) {
                inputs.put("coach_id", hcMascot.getCode());
                inputs.put("mascot_name", hcMascot.name());
                inputs.put("mascot_gender", hcMascot.gender());
                inputs.put("mascot_persona", hcMascot.persona());
            }
        } else {
            inputs.put("coach_id", "");
            inputs.put("mascot_name", "健康教练");
            inputs.put("mascot_gender", "");
            inputs.put("mascot_persona", "");
        }

说明:变量名用 hcUser/hcMascot/hcMascotCode 防止与本方法其他局部变量冲突;mascot.name() 保持与 /chat/send 既有行为一致(不修无关行为);AiGateway 会将 String 类型键值放入 body.context 透传(AiGateway.java:162-168)。

  • 步骤 6.2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS(若 userService 未注入报错,检查类头 @Resource private UserService userService; 是否存在——/chat/send 已在使用,必然存在)

  • [ ] 步骤 6.3:Commit

    GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
    GIT_MASTER=1 git commit -m "feat: 健康教练对话注入教练人格标识"
    

任务 7:LangGraph 人格 prompt 路由

文件:

  • 修改:cfc-langgraph/app/graphs/health_coach_graph.py(generate_answer 函数开头,约 :87-90)

  • [ ] 步骤 7.1:改写 generate_answer 开头

将现有:

    async def generate_answer(state: HealthCoachState) -> dict:
        """带健康知识检索的 LLM 生成"""
        messages = [SystemMessage(content=await get_prompt("health_coach") or DEFAULT_COACH_PROMPT)]

替换为:

    async def generate_answer(state: HealthCoachState) -> dict:
        """带健康知识检索的 LLM 生成"""
        # 人格路由:context.coach_id (xibao/fubao) -> 专属prompt,三级回退保证可用性
        _ctx = state.get("context") or {}
        _coach_id = _ctx.get("coach_id")
        if _coach_id not in ("xibao", "fubao"):
            _coach_id = None
        _persona_prompt = await get_prompt(f"health_coach_{_coach_id}") if _coach_id else None
        _base_prompt = await get_prompt("health_coach")
        messages = [SystemMessage(content=_persona_prompt or _base_prompt or DEFAULT_COACH_PROMPT)]

函数其余部分(ctx 家庭上下文、检索、save_memory 等)一律不动。

  • 步骤 7.2:语法验证

运行:cd cfc-langgraph && python3 -m py_compile app/graphs/health_coach_graph.py && echo OK 预期:输出 OK

  • 步骤 7.3:运营配置两段提示词(人工/管理端操作,非代码)

登录 Web 管理端「系统提示词管理」(SystemPromptManagement 页),新增两条记录:

promptKey 内容要点
health_coach_xibao 浠宝·温暖知心风:倾听共情先行、温和提问引导、鼓励性语言、建议委婉分层给出;保留默认 prompt 的就医红线与中文要求
health_coach_fubao 福宝·活泼行动派:语气轻快直接、优先给可执行行动清单、打卡式督促口吻、建议具体到频次;保留就医红线与中文要求

两段均以现有 health_coach 默认提示词(DEFAULT_COACH_PROMPT 同款结构:核心原则/职责/回答结构/原则四段)为骨架改写语气即可。未配置时三级回退自动生效,不阻塞上线。

  • [ ] 步骤 7.4:Commit

    GIT_MASTER=1 git add cfc-langgraph/app/graphs/health_coach_graph.py
    GIT_MASTER=1 git commit -m "feat: 健康教练graph按coach_id路由人格prompt"
    

任务 8:小程序 API 封装 + 管家选择页 + 路由注册

文件:

  • 修改:cfc-frontend/utils/api.js(文件尾部追加)
  • 创建:cfc-frontend/pages/butler/select.vue
  • 修改:cfc-frontend/pages.json(butler 子包,约 :628-638)

  • [ ] 步骤 8.1:api.js 追加封装

在 utils/api.js 末尾追加:

// ── 管家自助选择 ──
export function getAvailableButlers() {
  return request('/api/butler/available-list', 'POST', {})
}
export function getMyButler() {
  return request('/api/butler/my-butler', 'POST', {})
}
export function selectButler(butlerUserId) {
  return request('/api/butler/select', 'POST', { butlerUserId })
}
  • 步骤 8.2:pages.json 注册

将 butler 子包改为:

    {
      "root": "pages/butler",
      "pages": [
        {
          "path": "apply",
          "style": {
            "navigationBarTitleText": "管家入驻"
          }
        },
        {
          "path": "select",
          "style": {
            "navigationBarTitleText": "选择管家"
          }
        }
      ]
    },
  • 步骤 8.3:创建 select.vue(完整页面)

pages/butler/select.vue(Vue2 Options API;无可选链;flexbox;:key 用方法;时间用字符串截取格式化):

<template>
  <view class="select-page">
    <!-- 顶部状态区 -->
    <view class="status-card" v-if="pageState === 'bound'">
      <view class="current-label">我的管家</view>
      <view class="current-row">
        <image class="current-avatar" :src="myButler.avatar || '/static/logo.png'" mode="aspectFill" />
        <view class="current-info">
          <text class="current-name">{{ myButler.nickname }}</text>
          <text class="current-date">{{ formatDate(myButler.assignedAt) }} 绑定</text>
        </view>
      </view>
      <view class="current-desc">{{ myButler.description }}</view>
      <button class="btn-change" @click="scrollToList">更换管家</button>
    </view>
    <view class="upgrade-banner" v-if="pageState === 'locked'" @click="goUpgrade">
      <text class="upgrade-text">久久一生(L2)会员专享,升级后可选择专属管家</text>
      <text class="upgrade-arrow">›</text>
    </view>

    <!-- 管家列表 -->
    <view class="list-section" id="butlerList">
      <view class="section-title">可选管家</view>
      <view class="butler-card" v-for="(item, idx) in butlers" :key="getItemKey(idx)"
        :class="{ active: myButler && myButler.butlerUserId === item.butlerUserId }">
        <image class="card-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill" />
        <view class="card-body">
          <view class="card-top">
            <text class="card-name">{{ item.nickname }}</text>
            <text class="card-slots">剩余名额 {{ item.remainingSlots }}</text>
          </view>
          <text class="card-desc">{{ item.description || '专业家庭健康管家' }}</text>
        </view>
        <button class="btn-select"
          :disabled="pageState === 'locked' || item.remainingSlots <= 0"
          @click="onSelect(item)">
          {{ myButler && myButler.butlerUserId === item.butlerUserId ? '当前' : '选择' }}
        </button>
      </view>
      <view class="empty-tip" v-if="butlers.length === 0 && !loading">暂无可选管家</view>
    </view>
  </view>
</template>

<script>
import { getAvailableButlers, getMyButler, selectButler } from '@/utils/api.js'

export default {
  data() {
    return {
      loading: false,
      pageState: 'browse', // browse=可浏览未绑定 / bound=已绑定 / locked=非L2
      lockReason: '',
      myButler: null,
      butlers: [],
      keySeq: 0
    }
  },
  onLoad() {
    this.initPage()
  },
  onShow() {
    if (this.myButler || this.pageState === 'bound') {
      this.refreshMyButler()
    }
  },
  methods: {
    getItemKey(idx) {
      var item = this.butlers[idx]
      return item && item.butlerUserId ? 'b_' + item.butlerUserId : 'idx_' + idx
    },
    formatDate(iso) {
      if (!iso) return ''
      return iso.substring(0, 19).replace('T', ' ').substring(0, 16)
    },
    initPage() {
      var that = this
      that.loading = true
      Promise.all([getAvailableButlers(), getMyButler()])
        .then(function (resArr) {
          var listRes = resArr[0]
          var myRes = resArr[1]
          if (listRes && listRes.code === 200) {
            that.butlers = listRes.data || []
          }
          if (myRes && myRes.code === 200 && myRes.data) {
            that.myButler = myRes.data
            that.pageState = 'bound'
          }
        })
        .catch(function () {
          uni.showToast({ title: '加载失败', icon: 'none' })
        })
        .then(function () { that.loading = false })
    },
    refreshMyButler() {
      var that = this
      getMyButler().then(function (res) {
        if (res && res.code === 200) {
          that.myButler = res.data
          that.pageState = res.data ? 'bound' : 'browse'
        }
      })
    },
    markLocked(msg) {
      this.pageState = 'locked'
      this.lockReason = msg || ''
    },
    onSelect(item) {
      var that = this
      if (that.myButler && that.myButler.butlerUserId === item.butlerUserId) return
      uni.showModal({
        title: '确认选择',
        content: '确定选择「' + item.nickname + '」作为您的专属管家?',
        success: function (mr) {
          if (!mr.confirm) return
          selectButler(item.butlerUserId).then(function (res) {
            if (res && res.code === 200) {
              uni.showToast({ title: '绑定成功', icon: 'success' })
              that.initPage()
            } else if (res && res.message && res.message.indexOf('久久一生') !== -1) {
              that.markLocked(res.message)
              uni.showModal({
                title: '会员权益',
                content: res.message,
                showCancel: false,
                success: function () { that.goUpgrade() }
              })
            } else {
              uni.showToast({ title: (res && res.message) || '绑定失败', icon: 'none' })
            }
          })
        }
      })
    },
    scrollToList() {
      uni.pageScrollTo({ selector: '#butlerList', duration: 300 })
    },
    goUpgrade() {
      uni.navigateTo({ url: '/pages/membership/upgrade' })
    }
  }
}
</script>

<style scoped>
.select-page { min-height: 100vh; background: #f5fafе; padding: 24rpx; box-sizing: border-box; display: flex; flex-direction: column; }
.status-card { background: #fff; border-radius: 20rpx; padding: 32rpx; margin-bottom: 24rpx; }
.current-label { font-size: 24rpx; color: #999; margin-bottom: 16rpx; }
.current-row { display: flex; align-items: center; }
.current-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; margin-right: 20rpx; }
.current-info { display: flex; flex-direction: column; }
.current-name { font-size: 34rpx; font-weight: bold; color: #333; }
.current-date { font-size: 22rpx; color: #999; margin-top: 6rpx; }
.current-desc { font-size: 26rpx; color: #666; margin-top: 20rpx; line-height: 1.6; }
.btn-change { margin-top: 24rpx; background: #4a9bd7; color: #fff; font-size: 28rpx; border-radius: 40rpx; }
.upgrade-banner { display: flex; justify-content: space-between; align-items: center; background: #fff7ed; border: 1rpx solid #ffb56b; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; }
.upgrade-text { font-size: 26rpx; color: #d4770a; flex: 1; }
.upgrade-arrow { font-size: 36rpx; color: #d4770a; margin-left: 12rpx; }
.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin: 8rpx 0 20rpx; }
.butler-card { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 28rpx; margin-bottom: 20rpx; }
.butler-card.active { border: 2rpx solid #4a9bd7; }
.card-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; margin-right: 20rpx; flex-shrink: 0; }
.card-body { flex: 1; min-width: 0; }
.card-top { display: flex; align-items: center; justify-content: space-between; }
.card-name { font-size: 30rpx; font-weight: bold; color: #333; }
.card-slots { font-size: 22rpx; color: #10b981; }
.card-desc { display: block; font-size: 24rpx; color: #888; margin-top: 8rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-select { width: 120rpx; height: 60rpx; line-height: 60rpx; padding: 0; font-size: 26rpx; background: #4a9bd7; color: #fff; border-radius: 30rpx; flex-shrink: 0; margin-left: 16rpx; }
.btn-select[disabled] { background: #ccc; color: #fff; }
.empty-tip { text-align: center; color: #999; font-size: 26rpx; padding: 60rpx 0; }
</style>

⚠️ 注意上面样式第一行的 background: #f5fafе; —— 其中 е 必须是半角英文字母 e(此处若粘贴出现异常字符请手动改正为 #f5fafe)。

  • 步骤 8.4:语法验证

运行:

cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' pages/butler/select.vue | sed '1d;$d' > /tmp/opencode/select_check.js && node --check /tmp/opencode/select_check.js && echo SYNTAX_OK

预期:SYNTAX_OK

  • [ ] 步骤 8.5:Commit

    GIT_MASTER=1 git add cfc-frontend/utils/api.js cfc-frontend/pages/butler/select.vue cfc-frontend/pages.json
    GIT_MASTER=1 git commit -m "feat: 小程序管家选择页与接口封装"
    

任务 9:chat.vue 欢迎语 + 会员中心入口卡片

文件:

  • 修改:cfc-frontend/pages/ai/chat.vue(welcome-title,:41)
  • 修改:cfc-frontend/pages/membership/index.vue(入口卡片 + 方法)

  • [ ] 步骤 9.1:chat.vue 欢迎语带教练名

将 :41 行整行:

          <text class="welcome-title">{{ isNutrition ? '你好!我是营养助手' : (isHealthCoach ? '你好!我是健康教练' : (isButler ? '你好!我是健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>

替换为:

          <text class="welcome-title">{{ isNutrition ? '你好!我是营养助手' : (isHealthCoach ? ('你好!我是' + (mascotName || '健康教练')) : (isButler ? '你好!我是健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>
  • 步骤 9.2:membership/index.vue 定位插入点

运行:grep -n "class=\"\(benefits\|cards\|banner\|section\)\|<view class=\"benefit\|权益" cfc-frontend/pages/membership/index.vue | head 选择一个主内容区容器(如权益卡片区之前),在其后插入下方卡片。若该页有现成"特权入口"横向卡片区,跟随其结构放置。

  • [ ] 步骤 9.3:插入「我的管家」卡片(模板)

    <!-- 我的管家入口 -->
    <view class="butler-entry" @click="goButlerSelect">
      <view class="butler-entry-icon">🤵</view>
      <view class="butler-entry-main">
        <text class="butler-entry-title">{{ butlerEntryTitle }}</text>
        <text class="butler-entry-sub">{{ butlerEntrySub }}</text>
      </view>
      <text class="butler-entry-arrow">›</text>
    </view>
    
  • [ ] 步骤 9.4:membership/index.vue 脚本接线

data 中追加:

      myButlerInfo: null,
      isL2Active: false,

methods 中追加(并确认该页已引入 getMyButler——若无则在顶部 api 引入行加入 getMyButler):

    goButlerSelect() {
      uni.navigateTo({ url: '/pages/butler/select' })
    },
    loadButlerEntry() {
      var that = this
      getMyButler().then(function (res) {
        if (res && res.code === 200 && res.data) {
          that.myButlerInfo = res.data
        }
      }).catch(function () {})
    },

computed 中追加:

    butlerEntryTitle() {
      return this.myButlerInfo ? ('我的管家 · ' + this.myButlerInfo.nickname) : '我的管家'
    },
    butlerEntrySub() {
      return this.myButlerInfo ? '点击查看或更换' : '久久一生会员可自助选择专属管家'
    },

onLoad/onShow 中调用一次 this.loadButlerEntry()。

样式追加:

.butler-entry { display: flex; align-items: center; background: #fff; border-radius: 16rpx; padding: 28rpx; margin: 20rpx 0; }
.butler-entry-icon { font-size: 48rpx; margin-right: 20rpx; }
.butler-entry-main { flex: 1; display: flex; flex-direction: column; }
.butler-entry-title { font-size: 30rpx; font-weight: bold; color: #333; }
.butler-entry-sub { font-size: 24rpx; color: #999; margin-top: 6rpx; }
.butler-entry-arrow { font-size: 40rpx; color: #ccc; }
  • 步骤 9.5:语法验证

运行:

cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' pages/ai/chat.vue | sed '1d;$d' > /tmp/opencode/chat_check.js && node --check /tmp/opencode/chat_check.js && sed -n '/<script>/,/<\/script>/p' pages/membership/index.vue | sed '1d;$d' > /tmp/opencode/memb_check.js && node --check /tmp/opencode/memb_check.js && echo BOTH_OK

预期:BOTH_OK

  • [ ] 步骤 9.6:Commit

    GIT_MASTER=1 git add cfc-frontend/pages/ai/chat.vue cfc-frontend/pages/membership/index.vue
    GIT_MASTER=1 git commit -m "feat: 健康教练欢迎语个性化与我的管家入口"
    

任务 10:Web 管理端(封装 + 分配管理视图 + 开关列 + 路由权限)

文件:

  • 修改:cfc-web/src/api/butler.js(尾部追加)
  • 创建:cfc-web/src/views/admin/ButlerAssignments.vue
  • 修改:cfc-web/src/views/admin/review/ButlerAudit.vue(开关列)
  • 修改:cfc-web/src/router/index.js 及权限配置(定位后照抄邻居条目)

  • [ ] 步骤 10.1:api/butler.js 追加封装

    // 分配关系列表
    export function getButlerAssignments(params) {
    return request({
    url: '/api/admin/butler/assignments',
    method: 'post',
    data: params
    })
    }
    
    // 强制解绑
    export function unassignButler(data) {
    return request({
    url: '/api/admin/butler/unassign',
    method: 'post',
    data: data
    })
    }
    
    // 停接开关
    export function toggleButlerAccepting(data) {
    return request({
    url: '/api/admin/butler/toggle-accepting',
    method: 'post',
    data: data
    })
    }
    
  • [ ] 步骤 10.2:创建 ButlerAssignments.vue(完整视图)

views/admin/ButlerAssignments.vue:

<template>
  <div class="app-container">
    <el-card shadow="never">
      <div slot="header">
        <span>管家分配管理</span>
      </div>
      <el-table v-loading="loading" :data="records" border stripe>
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="familyId" label="家庭ID" width="100" />
        <el-table-column prop="butlerNickname" label="管家" min-width="140" />
        <el-table-column prop="butlerUserId" label="管家用户ID" width="110" />
        <el-table-column prop="level" label="订阅级别" width="100">
          <template slot-scope="{ row }">
            <el-tag :type="row.level === 'L2' ? 'warning' : 'info'" size="small">{{ row.level }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="绑定时间" width="170">
          <template slot-scope="{ row }">{{ formatTime(row.assignedAt) }}</template>
        </el-table-column>
        <el-table-column label="操作" width="120" fixed="right">
          <template slot-scope="{ row }">
            <el-button size="mini" type="danger" @click="onUnassign(row)">强制解绑</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-pagination
        style="margin-top: 16px; text-align: right;"
        layout="total, prev, pager, next"
        :total="total"
        :page-size="pageSize"
        :current-page.sync="pageNum"
        @current-change="loadData"
      />
    </el-card>
  </div>
</template>

<script>
import { getButlerAssignments, unassignButler } from '@/api/butler'

export default {
  name: 'ButlerAssignments',
  data() {
    return {
      loading: false,
      records: [],
      total: 0,
      pageNum: 1,
      pageSize: 10
    }
  },
  created() {
    this.loadData()
  },
  methods: {
    formatTime(iso) {
      if (!iso) return '-'
      return iso.substring(0, 19).replace('T', ' ')
    },
    loadData() {
      this.loading = true
      getButlerAssignments({ pageNum: this.pageNum, pageSize: this.pageSize })
        .then(res => {
          const data = res.data || {}
          this.records = data.records || []
          this.total = data.total || 0
        })
        .finally(() => { this.loading = false })
    },
    onUnassign(row) {
      this.$confirm(
        `确定解除家庭 ${row.familyId} 与管家「${row.butlerNickname}」的绑定?`,
        '强制解绑',
        { type: 'warning' }
      ).then(() => unassignButler({ assignmentId: row.id }))
        .then(res => {
          if (res.code === 200) {
            this.$message.success('已解绑')
            this.loadData()
          } else {
            this.$message.error(res.message || '操作失败')
          }
        }).catch(() => {})
    }
  }
}
</script>

响应包装取值方式(res.data / res.code)先对照 api/butler.js 现有消费方(如 ButlerAudit.vue 中 getButlerList(...).then(...) 的取值层级)并保持一致,若全局拦截器已剥壳则同步调整。

  • 步骤 10.3:ButlerAudit.vue 加停接开关列

运行:grep -n "el-table-column" cfc-web/src/views/admin/review/ButlerAudit.vue | tail -5 找到表格最后一列的位置,其后插入(prop 名以该表格数据字段为准,若列表项含 userId 则如下):

        <el-table-column label="接单状态" width="110">
          <template slot-scope="{ row }">
            <el-switch
              :value="row.accepting !== 0"
              active-color="#13ce66"
              @change="onToggleAccepting(row)" />
            <div v-if="row.accepting === 0" style="font-size: 12px; color: #f56c6c;">已停接</div>
          </template>
        </el-table-column>

methods 中追加(并顶部 import toggleButlerAccepting):

    async onToggleAccepting(row) {
      const next = row.accepting === 0 ? 1 : 0
      try {
        const res = await toggleButlerAccepting({ butlerUserId: row.userId, accepting: next })
        if (res.code === 200) {
          this.$set(row, 'accepting', next)
          this.$message.success(next === 1 ? '已恢复接单' : '已停接新单')
        } else {
          this.$message.error(res.message || '操作失败')
        }
      } catch (e) {
        this.$message.error('操作失败')
      }
    },

若管家列表接口未返回 accepting 字段,需回查后端 list 接口的组装逻辑把 profile.getAccepting() 放进行数据(在 AdminButlerController/相关 Service 中搜索现有 list 的组装点,加一行 row.put("accepting", p.getAccepting()) 同级代码),并在本计划内完成该最小改动后再进入下一任务。

  • 步骤 10.4:路由与权限注册(定位式)

运行:grep -rn "ButlerAudit" cfc-web/src/router/ cfc-web/src/permissions.js 2>/dev/null ; grep -rn "path: '/admin" cfc-web/src/router/index.js | head -8

  • 若找到 ButlerAudit 的注册行:紧随其后照抄一条 ButlerAssignments 路由(component 改为 () => import('@/views/admin/ButlerAssignments.vue'),name/path/meta 结构逐字段镜像邻居条目)。
  • 若 router 中搜不到但页面可访问(动态注册机制):在 src/router/index.js 里找任一 admin 视图条目作为模板,同样镜像新增。
  • 若存在独立权限清单文件(步骤命令会暴露路径):为新路由追加同名权限码。

  • [ ] 步骤 10.5:Commit

    GIT_MASTER=1 git add cfc-web/src/api/butler.js cfc-web/src/views/admin/ButlerAssignments.vue \
    cfc-web/src/views/admin/review/ButlerAudit.vue cfc-web/src/router/index.js
    GIT_MASTER=1 git commit -m "feat: 管理端分配管理与停接开关页面"
    

任务 11:API 文档登记 + 全量验收

文件:

  • 修改:docs/superpowers/api/API_REFERENCE.md

  • [ ] 步骤 11.1:登记 6 个新接口

运行:grep -n "api/butler" docs/superpowers/api/API_REFERENCE.md | head -3 定位管家章节,按该文档既有表格格式追加:

接口 说明 权限
POST /api/butler/available-list 可选管家列表(浏览开放,脱敏) 登录
POST /api/butler/my-butler 我家当前管家绑定信息 登录
POST /api/butler/select 绑定/更换管家(仅L2有效订阅) 登录
POST /api/admin/butler/assignments 分配关系分页列表 管理员
POST /api/admin/butler/unassign 强制解绑 管理员
POST /api/admin/butler/toggle-accepting 管家停接新单开关 管理员

另在 AI 章节给 /api/ai/health-coach/send 的请求体补充一行说明:新增可选字段 coach_id 由服务端自动注入(客户端无需传)。

  • [ ] 步骤 11.2:最终验证清单(对照规格 §十一 验收标准)

    cd cfc-backend && mvn clean compile          # EXIT 0
    cd ../cfc-langgraph && python3 -m py_compile app/graphs/health_coach_graph.py && echo LG_OK
    for f in pages/butler/select.vue pages/ai/chat.vue pages/membership/index.vue; do
    cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' "$f" | sed '1d;$d' > /tmp/opencode/final_check.js && node --check /tmp/opencode/final_check.js && echo "OK: $f" && cd ..
    done
    

预期:BUILD SUCCESS / LG_OK / 三行 OK。

  • [ ] 步骤 11.3:Commit

    GIT_MASTER=1 git add docs/superpowers/api/API_REFERENCE.md
    GIT_MASTER=1 git commit -m "docs: 登记管家自选与健康教练接口文档"
    

自检记录(编写者已完成)

  1. 规格覆盖度:§1 数据模型→任务1;§2 家庭端接口→任务3/4;§2 AI端→任务6;§2 管理端→任务5;§3 LangGraph→任务7;§4 前端→任务8/9;§5 Web→任务10;§6 边界(条件扣容/GREATEST回退/双路径/三级回退/停接双拦截/物理删除)→任务3/5/7 对应实现;§十一 验收→任务11.2。无遗漏。
  2. 占位符扫描:全文无"待定/TODO/类似任务N";两处"定位式步骤"(5.1/10.4)均为给出确切命令+两种已知形态的完整代码,属可执行指令而非缺口。
  3. 类型一致性:getAvailableButlers/getMyButler/selectButler/listAssignments/unassign/toggleAccepting 在服务与控制器两侧签名一致;accepting 字段名贯穿迁移/schema/实体/接口/前端;butlerUserId 前后端统一驼峰;coach_id Java inputs 键与 Python ctx.get("coach_id") 一致。