面向 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;禁止运行整包 buildGIT_MASTER=1 前缀;commit message 用中文 SEMANTIC 风格(feat:/fix:/docs:)| # | 文件 | 动作 | 职责 |
|---|---|---|---|
| 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 个新接口 |
文件:
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)。
在 CREATE TABLE IF NOT EXISTS butler_profiles ( 块内,commission_rate_l2 INT DEFAULT 5000, 行之后插入一行:
accepting TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单',
在 ButlerProfile.java 的 private Integer commissionRateL2; 之后加:
/**
* 是否接受新家庭绑定: 0=停接/1=接单
*/
private Integer accepting;
运行: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停接开关列"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.javacfc-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
运行: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 与其保持完全一致的风格。
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。
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> {
}
运行: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"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java已有依赖:@Resource UserMapper userMapper; @Resource ButlerProfileMapper butlerProfileMapper; @Resource UserService userService;
需要新增依赖:ButlerAssignmentMapper、MemberSubscriptionService(Bean 名与类名首字母小写一致,符合注入规范)。
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;
在类末尾(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;
}
运行: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新增管家浏览/查询/自助绑定方法"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.javaBean 名 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 定义为准同步控制器签名。
运行: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 不与任何既有路径重复。
运行: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: 家庭端管家自助选择接口"
文件:
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) 判断)。后续端点完全复制该守卫模式。
在任务 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.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 注解包路径以该文件现有用法为准。
运行: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: 管理端分配管理与停接开关接口"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java(sendHealthCoachMessage 方法内,约 :420-447)现状:该方法只放 child_id,缺 mascot 注入(/chat/send :76-109 已有完整范式)。
找到 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)。
运行: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: 健康教练对话注入教练人格标识"
文件:
修改: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 等)一律不动。
运行:cd cfc-langgraph && python3 -m py_compile app/graphs/health_coach_graph.py && echo OK
预期:输出 OK
登录 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"
文件:
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 })
}
将 butler 子包改为:
{
"root": "pages/butler",
"pages": [
{
"path": "apply",
"style": {
"navigationBarTitleText": "管家入驻"
}
},
{
"path": "select",
"style": {
"navigationBarTitleText": "选择管家"
}
}
]
},
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)。
运行:
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: 小程序管家选择页与接口封装"
文件:
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>
运行: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; }
运行:
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: 健康教练欢迎语个性化与我的管家入口"
文件:
cfc-web/src/api/butler.js(尾部追加)cfc-web/src/views/admin/ButlerAssignments.vuecfc-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(...) 的取值层级)并保持一致,若全局拦截器已剥壳则同步调整。
运行: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()) 同级代码),并在本计划内完成该最小改动后再进入下一任务。
运行: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
() => import('@/views/admin/ButlerAssignments.vue'),name/path/meta 结构逐字段镜像邻居条目)。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: 管理端分配管理与停接开关页面"
文件:
修改: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: 登记管家自选与健康教练接口文档"
getAvailableButlers/getMyButler/selectButler/listAssignments/unassign/toggleAccepting 在服务与控制器两侧签名一致;accepting 字段名贯穿迁移/schema/实体/接口/前端;butlerUserId 前后端统一驼峰;coach_id Java inputs 键与 Python ctx.get("coach_id") 一致。