# 关键人拓展章鱼图替换 实现计划 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 替换现有"帮助记录型章鱼图"(contact_help_logs + OctopusController + OctopusDiagram.vue),上线"关键人拓展型章鱼图":成效记录(会员客户) → 类型bitmask(金额大/频次高/新成交) → 关键人画像(单位/部门/职务/初识场合/如何认识) → 四步法 + 高频词分析 → ≤8 个拓展方向。 **架构:** - 后端:Spring Boot 2.7.18 + MyBatis-Plus,3 张新表 + KeyPersonController(新建,不复用 OctopusController),Service 层聚合逻辑,统一 POST + Result。 - 前端:uni-app Vue 2 Options API,Canvas 2d 绘图(type="2d" + uni.createSelectorQuery + dpr 自适应),替换 action-detail 中的 OctopusDiagram.vue。 - 数据隔离:纯 `user_id` 租户键,无 child/family 概念。 **技术栈:** Java 8, Spring Boot 2.7.18, MyBatis-Plus, MySQL 8.0, uni-app (Vue 2 Options API), Canvas 2d, uni.createSelectorQuery。 --- ### 任务 1:数据库迁移 + schema.sql 同步 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java:10549`(在迁移304后追加) - 修改:`cfc-backend/src/main/resources/schema.sql`(在 resource_items 表后追加 3 表) - [ ] **步骤 1:编写失败的测试(验证迁移不存在)** ```bash # 验证当前无这 3 表 grep -n "octopus_effect_record\|octopus_key_person\|octopus_effect_key_person" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java # 预期:无匹配 ``` - [ ] **步骤 2:在 DatabaseInitializer 追加迁移 305-307** ```java // 迁移305: 创建 octopus_effect_record 表(关键人拓展-成效记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_record (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " + "member_order_id BIGINT COMMENT '关联 MemberSubscriptionOrder.id(会员订单)', " + "effect_type TINYINT UNSIGNED NOT NULL COMMENT '成效类型 bitmask:1=金额大,2=频次高,4=新成交(支持多选)', " + "effect_amount DECIMAL(10,2) DEFAULT 0 COMMENT '成效金额(元)', " + "effect_desc VARCHAR(500) COMMENT '成效描述', " + "status TINYINT DEFAULT 1 COMMENT '1=有效,0=无效', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_oer_user (user_id), " + "INDEX idx_oer_order (member_order_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人拓展-成效记录'"); log.info("已创建octopus_effect_record表"); } catch (Exception e) { log.warn("创建octopus_effect_record表失败: {}", e.getMessage()); } // 迁移306: 创建 octopus_key_person 表(关键人画像) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_key_person (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " + "name VARCHAR(64) NOT NULL COMMENT '关键人姓名', " + "organization VARCHAR(128) COMMENT '单位/公司', " + "department VARCHAR(64) COMMENT '部门', " + "title VARCHAR(64) COMMENT '职务/职位', " + "first_meet_scene VARCHAR(128) COMMENT '初识场合', " + "know_way VARCHAR(255) COMMENT '如何认识(认识路径)', " + "contact_info VARCHAR(255) COMMENT '联系方式(可选)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_okp_user (user_id), " + "INDEX idx_okp_org (organization) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人画像'"); log.info("已创建octopus_key_person表"); } catch (Exception e) { log.warn("创建octopus_key_person表失败: {}", e.getMessage()); } // 迁移307: 创建 octopus_effect_key_person 表(成效-关键人 多对多) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_key_person (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "effect_id BIGINT NOT NULL COMMENT '关联 octopus_effect_record.id', " + "key_person_id BIGINT NOT NULL COMMENT '关联 octopus_key_person.id', " + "role TINYINT NOT NULL COMMENT '1=引荐,2=决策,3=其他', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "INDEX idx_oekp_effect (effect_id), " + "INDEX idx_oekp_person (key_person_id), " + "UNIQUE KEY uk_oekp (effect_id,key_person_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成效-关键人关联'"); log.info("已创建octopus_effect_key_person表"); } catch (Exception e) { log.warn("创建octopus_effect_key_person表失败: {}", e.getMessage()); } ``` - [ ] **步骤 3:同步 schema.sql(在 resource_items 表后追加)** ```sql -- 关键人拓展-成效记录 CREATE TABLE IF NOT EXISTS octopus_effect_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', member_order_id BIGINT COMMENT '关联 MemberSubscriptionOrder.id(会员订单)', effect_type TINYINT UNSIGNED NOT NULL COMMENT '成效类型 bitmask:1=金额大,2=频次高,4=新成交(支持多选)', effect_amount DECIMAL(10,2) DEFAULT 0 COMMENT '成效金额(元)', effect_desc VARCHAR(500) COMMENT '成效描述', status TINYINT DEFAULT 1 COMMENT '1=有效,0=无效', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_oer_user (user_id), INDEX idx_oer_order (member_order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人拓展-成效记录'; -- 关键人画像 CREATE TABLE IF NOT EXISTS octopus_key_person ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', name VARCHAR(64) NOT NULL COMMENT '关键人姓名', organization VARCHAR(128) COMMENT '单位/公司', department VARCHAR(64) COMMENT '部门', title VARCHAR(64) COMMENT '职务/职位', first_meet_scene VARCHAR(128) COMMENT '初识场合', know_way VARCHAR(255) COMMENT '如何认识(认识路径)', contact_info VARCHAR(255) COMMENT '联系方式(可选)', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_okp_user (user_id), INDEX idx_okp_org (organization) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人画像'; -- 成效-关键人关联 CREATE TABLE IF NOT EXISTS octopus_effect_key_person ( id BIGINT AUTO_INCREMENT PRIMARY KEY, effect_id BIGINT NOT NULL COMMENT '关联 octopus_effect_record.id', key_person_id BIGINT NOT NULL COMMENT '关联 octopus_key_person.id', role TINYINT NOT NULL COMMENT '1=引荐,2=决策,3=其他', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_oekp_effect (effect_id), INDEX idx_oekp_person (key_person_id), UNIQUE KEY uk_oekp (effect_id,key_person_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成效-关键人关联'; ``` - [ ] **步骤 4:验证编译** ```bash cd cfc-backend && mvn clean compile -DskipTests # 预期:BUILD SUCCESS ``` - [ ] **步骤 5:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql git commit -m "db: 迁移305-307 关键人拓展 3 表(octopus_effect_record/octopus_key_person/octopus_effect_key_person)" ``` --- ### 任务 2:后端 Entity / Mapper **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusEffectRecord.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusKeyPerson.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusEffectKeyPerson.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/OctopusEffectRecordMapper.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/OctopusKeyPersonMapper.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/OctopusEffectKeyPersonMapper.java` - [ ] **步骤 1:OctopusEffectRecord.java** ```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.io.Serializable; import java.math.BigDecimal; import java.util.Date; @Data @TableName("octopus_effect_record") public class OctopusEffectRecord implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long userId; private Long memberOrderId; private Integer effectType; // bitmask private BigDecimal effectAmount; private String effectDesc; private Integer status; private Date createdAt; // 非持久化:解析后的类型标签列表 private transient java.util.List effectTypeList; } ``` - [ ] **步骤 2:OctopusKeyPerson.java** ```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.io.Serializable; import java.util.Date; @Data @TableName("octopus_key_person") public class OctopusKeyPerson implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long userId; private String name; private String organization; private String department; private String title; private String firstMeetScene; private String knowWay; private String contactInfo; private Date createdAt; // 非持久化:角色名 private transient String roleName; } ``` - [ ] **步骤 3:OctopusEffectKeyPerson.java** ```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.io.Serializable; import java.util.Date; @Data @TableName("octopus_effect_key_person") public class OctopusEffectKeyPerson implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long effectId; private Long keyPersonId; private Integer role; // 1=引荐,2=决策,3=其他 private Date createdAt; // 非持久化 private transient String roleName; } ``` - [ ] **步骤 4:3 个 Mapper 接口(仅继承 BaseMapper)** ```java // OctopusEffectRecordMapper.java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.OctopusEffectRecord; import org.apache.ibatis.annotations.Mapper; @Mapper public interface OctopusEffectRecordMapper extends BaseMapper { } ``` ```java // OctopusKeyPersonMapper.java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.OctopusKeyPerson; import org.apache.ibatis.annotations.Mapper; @Mapper public interface OctopusKeyPersonMapper extends BaseMapper { } ``` ```java // OctopusEffectKeyPersonMapper.java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.OctopusEffectKeyPerson; import org.apache.ibatis.annotations.Mapper; @Mapper public interface OctopusEffectKeyPersonMapper extends BaseMapper { } ``` - [ ] **步骤 5:验证编译 + Commit** ```bash cd cfc-backend && mvn clean compile -DskipTests git add cfc-backend/src/main/java/com/etotem/cfc/entity/Octopus*.java cfc-backend/src/main/java/com/etotem/cfc/mapper/Octopus*Mapper.java git commit -m "feat: 新增 3 实体 + 3 Mapper(octopus_effect_record/octopus_key_person/octopus_effect_key_person)" ``` --- ### 任务 3:KeyPersonService 核心业务逻辑 **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java` - [ ] **步骤 1:编写失败的测试(验证类不存在)** ```bash ls cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java # 预期:No such file ``` - [ ] **步骤 2:实现 KeyPersonService.java(参考 ResourceService 模式)** ```java package com.etotem.cfc.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.etotem.cfc.common.Result; import com.etotem.cfc.entity.MemberSubscriptionOrder; import com.etotem.cfc.entity.OctopusEffectKeyPerson; import com.etotem.cfc.entity.OctopusEffectRecord; import com.etotem.cfc.entity.OctopusKeyPerson; import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper; import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper; import com.etotem.cfc.mapper.OctopusEffectRecordMapper; import com.etotem.cfc.mapper.OctopusKeyPersonMapper; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @Service public class KeyPersonService { @Resource private OctopusEffectRecordMapper effectRecordMapper; @Resource private OctopusKeyPersonMapper keyPersonMapper; @Resource private OctopusEffectKeyPersonMapper ekpMapper; @Resource private MemberSubscriptionOrderMapper memberOrderMapper; // ========== 1. 盘点项目 ========== public Result> listEffects(Long userId, Integer effectType, Integer page, Integer pageSize) { Page p = new Page<>(page, pageSize); LambdaQueryWrapper q = new LambdaQueryWrapper() .eq(OctopusEffectRecord::getUserId, userId); if (effectType != null && effectType > 0) { q.apply("effect_type & {0}", effectType); } q.orderByDesc(OctopusEffectRecord::getCreatedAt); effectRecordMapper.selectPage(p, q); List> list = p.getRecords().stream().map(this::toEffectVO).collect(Collectors.toList()); Map data = new LinkedHashMap<>(); data.put("list", list); data.put("total", p.getTotal()); data.put("page", page); data.put("pageSize", pageSize); return Result.success(data); } private Map toEffectVO(OctopusEffectRecord r) { Map m = new LinkedHashMap<>(); m.put("id", r.getId()); m.put("memberOrderId", r.getMemberOrderId()); m.put("effectType", r.getEffectType()); m.put("effectTypeList", parseEffectType(r.getEffectType())); m.put("effectAmount", r.getEffectAmount()); m.put("effectDesc", r.getEffectDesc()); m.put("status", r.getStatus()); m.put("createdAt", r.getCreatedAt() != null ? r.getCreatedAt().toString() : ""); return m; } private List parseEffectType(Integer type) { List list = new ArrayList<>(); if (type == null) return list; if ((type & 1) != 0) list.add("金额大"); if ((type & 2) != 0) list.add("频次高"); if ((type & 4) != 0) list.add("新成交"); return list; } public Result addEffect(Long userId, Long memberOrderId, Integer effectType, BigDecimal effectAmount, String effectDesc) { // 可选:校验 memberOrderId 归属 if (memberOrderId != null) { MemberSubscriptionOrder order = memberOrderMapper.selectById(memberOrderId); if (order == null || !order.getUserId().equals(userId)) { return Result.error("会员订单不存在"); } } OctopusEffectRecord r = new OctopusEffectRecord(); r.setUserId(userId); r.setMemberOrderId(memberOrderId); r.setEffectType(effectType != null ? effectType : 0); r.setEffectAmount(effectAmount != null ? effectAmount : BigDecimal.ZERO); r.setEffectDesc(effectDesc != null ? effectDesc.trim() : null); r.setStatus(1); r.setCreatedAt(new Date()); effectRecordMapper.insert(r); return Result.success(r); } public Result deleteEffect(Long userId, Long effectId) { OctopusEffectRecord r = effectRecordMapper.selectById(effectId); if (r == null || !r.getUserId().equals(userId)) { return Result.error("成效记录不存在"); } // 级联删除关联关系 ekpMapper.delete(new LambdaQueryWrapper().eq(OctopusEffectKeyPerson::getEffectId, effectId)); effectRecordMapper.deleteById(effectId); return Result.success(null); } // ========== 2. 找关键人 ========== public Result addKeyPerson(Long userId, String name, String organization, String department, String title, String firstMeetScene, String knowWay, String contactInfo) { if (name == null || name.trim().isEmpty()) { return Result.error("关键人姓名不能为空"); } OctopusKeyPerson kp = new OctopusKeyPerson(); kp.setUserId(userId); kp.setName(name.trim()); kp.setOrganization(organization != null ? organization.trim() : null); kp.setDepartment(department != null ? department.trim() : null); kp.setTitle(title != null ? title.trim() : null); kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null); kp.setKnowWay(knowWay != null ? knowWay.trim() : null); kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null); kp.setCreatedAt(new Date()); keyPersonMapper.insert(kp); return Result.success(kp); } public Result updateKeyPerson(Long userId, Long id, Map params) { OctopusKeyPerson kp = keyPersonMapper.selectById(id); if (kp == null || !kp.getUserId().equals(userId)) { return Result.error("关键人不存在"); } if (params.containsKey("name") && params.get("name") != null) kp.setName(params.get("name").toString().trim()); if (params.containsKey("organization")) kp.setOrganization(params.get("organization") != null ? params.get("organization").toString().trim() : null); if (params.containsKey("department")) kp.setDepartment(params.get("department") != null ? params.get("department").toString().trim() : null); if (params.containsKey("title")) kp.setTitle(params.get("title") != null ? params.get("title").toString().trim() : null); if (params.containsKey("firstMeetScene")) kp.setFirstMeetScene(params.get("firstMeetScene") != null ? params.get("firstMeetScene").toString().trim() : null); if (params.containsKey("knowWay")) kp.setKnowWay(params.get("knowWay") != null ? params.get("knowWay").toString().trim() : null); if (params.containsKey("contactInfo")) kp.setContactInfo(params.get("contactInfo") != null ? params.get("contactInfo").toString().trim() : null); keyPersonMapper.updateById(kp); return Result.success(kp); } public Result deleteKeyPerson(Long userId, Long id) { OctopusKeyPerson kp = keyPersonMapper.selectById(id); if (kp == null || !kp.getUserId().equals(userId)) { return Result.error("关键人不存在"); } // 级联删除关联关系 ekpMapper.delete(new LambdaQueryWrapper().eq(OctopusEffectKeyPerson::getKeyPersonId, id)); keyPersonMapper.deleteById(id); return Result.success(null); } public Result>> listKeyPersonsByEffect(Long userId, Long effectId) { // 校验成效归属 OctopusEffectRecord eff = effectRecordMapper.selectById(effectId); if (eff == null || !eff.getUserId().equals(userId)) { return Result.error("成效记录不存在"); } List rels = ekpMapper.selectList( new LambdaQueryWrapper().eq(OctopusEffectKeyPerson::getEffectId, effectId) ); if (rels.isEmpty()) return Result.success(new ArrayList<>()); List kpIds = rels.stream().map(OctopusEffectKeyPerson::getKeyPersonId).collect(Collectors.toList()); Map kpMap = keyPersonMapper.selectBatchIds(kpIds).stream() .collect(Collectors.toMap(OctopusKeyPerson::getId, k -> k)); Map roleMap = rels.stream().collect(Collectors.toMap(OctopusEffectKeyPerson::getKeyPersonId, OctopusEffectKeyPerson::getRole)); List> list = new ArrayList<>(); for (Long kpId : kpIds) { OctopusKeyPerson kp = kpMap.get(kpId); if (kp == null) continue; Map m = new LinkedHashMap<>(); m.put("id", kp.getId()); m.put("name", kp.getName()); m.put("organization", kp.getOrganization()); m.put("department", kp.getDepartment()); m.put("title", kp.getTitle()); m.put("firstMeetScene", kp.getFirstMeetScene()); m.put("knowWay", kp.getKnowWay()); m.put("contactInfo", kp.getContactInfo()); m.put("role", roleMap.get(kpId)); m.put("roleName", roleName(roleMap.get(kpId))); m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : ""); list.add(m); } return Result.success(list); } // ========== 3. 分析关键人 ========== public Result> analyzeKeywords(Long userId) { List kps = keyPersonMapper.selectList( new LambdaQueryWrapper().eq(OctopusKeyPerson::getUserId, userId) ); // 统计各维度词频 Map orgFreq = new HashMap<>(); Map deptFreq = new HashMap<>(); Map titleFreq = new HashMap<>(); Map sceneFreq = new HashMap<>(); Map wayFreq = new HashMap<>(); for (OctopusKeyPerson kp : kps) { inc(orgFreq, kp.getOrganization()); inc(deptFreq, kp.getDepartment()); inc(titleFreq, kp.getTitle()); inc(sceneFreq, kp.getFirstMeetScene()); inc(wayFreq, kp.getKnowWay()); } Map data = new LinkedHashMap<>(); data.put("organization", topN(orgFreq, 20)); data.put("department", topN(deptFreq, 20)); data.put("title", topN(titleFreq, 20)); data.put("firstMeetScene", topN(sceneFreq, 20)); data.put("knowWay", topN(wayFreq, 20)); return Result.success(data); } private void inc(Map map, String val) { if (val == null || val.trim().isEmpty()) return; map.put(val, map.getOrDefault(val, 0) + 1); } private List> topN(Map map, int n) { return map.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(n) .map(e -> { Map m = new LinkedHashMap<>(); m.put("keyword", e.getKey()); m.put("count", e.getValue()); return m; }).collect(Collectors.toList()); } public Result> analysisSummary(Long userId) { List kps = keyPersonMapper.selectList( new LambdaQueryWrapper().eq(OctopusKeyPerson::getUserId, userId) ); // 聚合:按 organization/title/department/firstMeetScene/knowWay 维度 List> directions = new ArrayList<>(); addDirections(directions, kps, "organization", "单位", 8); addDirections(directions, kps, "title", "职务", 8); addDirections(directions, kps, "department", "部门", 8); addDirections(directions, kps, "firstMeetScene", "初识场合", 8); addDirections(directions, kps, "knowWay", "认识路径", 8); // 全局排序取 Top 8 directions.sort((a, b) -> Integer.compare((Integer) b.get("count"), (Integer) a.get("count"))); if (directions.size() > 8) directions = directions.subList(0, 8); Map data = new LinkedHashMap<>(); data.put("directions", directions); return Result.success(data); } private void addDirections(List> directions, List kps, String field, String dimName, int maxPerDim) { Map freq = new HashMap<>(); for (OctopusKeyPerson kp : kps) { String val = getField(kp, field); if (val != null && !val.trim().isEmpty()) { freq.put(val, freq.getOrDefault(val, 0) + 1); } } freq.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(maxPerDim) .forEach(e -> { Map m = new LinkedHashMap<>(); m.put("dimension", field); m.put("keyword", e.getKey()); m.put("count", e.getValue()); m.put("reason", genReason(field, e.getKey())); directions.add(m); }); } private String getField(OctopusKeyPerson kp, String field) { switch (field) { case "organization": return kp.getOrganization(); case "title": return kp.getTitle(); case "department": return kp.getDepartment(); case "firstMeetScene": return kp.getFirstMeetScene(); case "knowWay": return kp.getKnowWay(); default: return null; } } private String genReason(String field, String keyword) { switch (field) { case "organization": return "该单位关键人高度聚集"; case "title": return "该职务人脉密集"; case "department": return "该部门关键人多"; case "firstMeetScene": return "该场合易识别关键人"; case "knowWay": return "该认识路径高频"; default: return ""; } } // ========== 4. 拓展关键人 ========== public Result>> expansionSuggest(Long userId) { // 复用 analysisSummary 的方向,为每个方向生成预填模板 Result> summaryRes = analysisSummary(userId); if (summaryRes.getCode() != 200) return Result.success(new ArrayList<>()); @SuppressWarnings("unchecked") List> dirs = (List>) summaryRes.getData().get("directions"); List> suggests = new ArrayList<>(); for (Map d : dirs) { Map tpl = new LinkedHashMap<>(); tpl.put("dimension", d.get("dimension")); tpl.put("keyword", d.get("keyword")); tpl.put("prefill", buildPrefill((String) d.get("dimension"), (String) d.get("keyword"))); suggests.add(tpl); } return Result.success(suggests); } private Map buildPrefill(String dim, String keyword) { Map p = new LinkedHashMap<>(); // 只预填对应维度字段,其余留空供用户补充 switch (dim) { case "organization": p.put("organization", keyword); break; case "title": p.put("title", keyword); break; case "department": p.put("department", keyword); break; case "firstMeetScene": p.put("firstMeetScene", keyword); break; case "knowWay": p.put("knowWay", keyword); break; } return p; } private String roleName(Integer role) { if (role == null) return ""; switch (role) { case 1: return "引荐"; case 2: return "决策"; case 3: return "其他"; default: return ""; } } } ``` - [ ] **步骤 2:验证编译 + Commit** ```bash cd cfc-backend && mvn clean compile -DskipTests git add cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java git commit -m "feat: KeyPersonService 核心业务逻辑(四步法全接口实现)" ``` --- ### 任务 4:KeyPersonController(新建,不复用 OctopusController) **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java` - [ ] **步骤 1:实现 Controller(完全遵循 PearlController 模式)** ```java package com.etotem.cfc.controller; import com.etotem.cfc.common.Result; import com.etotem.cfc.service.KeyPersonService; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.math.BigDecimal; import java.util.Map; @RestController @RequestMapping("/api/octopus") public class KeyPersonController { @Resource private KeyPersonService keyPersonService; // ===== 1. 盘点项目 ===== @PostMapping("/effect/list") public Result> listEffects(@RequestBody(required = false) Map params, @RequestAttribute("userId") Long userId) { Integer effectType = params != null && params.get("effectType") != null ? Integer.valueOf(params.get("effectType").toString()) : null; Integer page = params != null && params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1; Integer pageSize = params != null && params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 20; return keyPersonService.listEffects(userId, effectType, page, pageSize); } @PostMapping("/effect/add") public Result addEffect(@RequestBody Map params, @RequestAttribute("userId") Long userId) { Long memberOrderId = params.get("memberOrderId") != null ? Long.valueOf(params.get("memberOrderId").toString()) : null; Integer effectType = params.get("effectType") != null ? Integer.valueOf(params.get("effectType").toString()) : 0; BigDecimal effectAmount = params.get("effectAmount") != null ? new BigDecimal(params.get("effectAmount").toString()) : BigDecimal.ZERO; String effectDesc = (String) params.get("effectDesc"); return keyPersonService.addEffect(userId, memberOrderId, effectType, effectAmount, effectDesc); } @PostMapping("/effect/delete") public Result deleteEffect(@RequestBody Map params, @RequestAttribute("userId") Long userId) { Long effectId = params.get("effectId") != null ? Long.valueOf(params.get("effectId").toString()) : null; return keyPersonService.deleteEffect(userId, effectId); } // ===== 2. 找关键人 ===== @PostMapping("/key-person/add") public Result addKeyPerson(@RequestBody Map params, @RequestAttribute("userId") Long userId) { String name = (String) params.get("name"); String organization = (String) params.get("organization"); String department = (String) params.get("department"); String title = (String) params.get("title"); String firstMeetScene = (String) params.get("firstMeetScene"); String knowWay = (String) params.get("knowWay"); String contactInfo = (String) params.get("contactInfo"); return keyPersonService.addKeyPerson(userId, name, organization, department, title, firstMeetScene, knowWay, contactInfo); } @PostMapping("/key-person/update") public Result updateKeyPerson(@RequestBody Map params, @RequestAttribute("userId") Long userId) { Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null; return keyPersonService.updateKeyPerson(userId, id, params); } @PostMapping("/key-person/delete") public Result deleteKeyPerson(@RequestBody Map params, @RequestAttribute("userId") Long userId) { Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null; return keyPersonService.deleteKeyPerson(userId, id); } @PostMapping("/effect/key-persons") public Result>> effectKeyPersons(@RequestBody Map params, @RequestAttribute("userId") Long userId) { Long effectId = params.get("effectId") != null ? Long.valueOf(params.get("effectId").toString()) : null; return keyPersonService.listKeyPersonsByEffect(userId, effectId); } // ===== 3. 分析关键人 ===== @PostMapping("/analysis/keywords") public Result> analysisKeywords(@RequestAttribute("userId") Long userId) { return keyPersonService.analyzeKeywords(userId); } @PostMapping("/analysis/summary") public Result> analysisSummary(@RequestAttribute("userId") Long userId) { return keyPersonService.analysisSummary(userId); } // ===== 4. 拓展关键人 ===== @PostMapping("/expansion/suggest") public Result>> expansionSuggest(@RequestAttribute("userId") Long userId) { return keyPersonService.expansionSuggest(userId); } } ``` - [ ] **步骤 2:验证编译 + Commit** ```bash cd cfc-backend && mvn clean compile -DskipTests git add cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java git commit -m "feat: KeyPersonController 10 个接口(/api/octopus/effect/*, /key-person/*, /analysis/*, /expansion/suggest)" ``` --- ### 任务 5:废弃旧 OctopusController + 清理 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/OctopusController.java` - 删除:`cfc-frontend/components/OctopusDiagram.vue` - 删除:`cfc-frontend/pages/action-detail/octopus-add-help.vue` - 删除:`cfc-frontend/pages/action-detail/octopus-records.vue` - 修改:`cfc-frontend/pages.json`(移除 octopus-add-help, octopus-records 路由) - 修改:`cfc-frontend/utils/api.js`(移除旧 4 个 octopus 封装) - 修改:`docs/superpowers/api/API_REFERENCE.md`(标记旧接口废弃) - [ ] **步骤 1:OctopusController 标记 410** ```java // 修改每个方法第一行返回 410 @PostMapping("/tentacles") public Result>> tentacles(@RequestAttribute("userId") Long userId) { return Result.error(410, "该接口已废弃,请使用 /api/octopus/effect/list 等新接口"); } @PostMapping("/add") public Result addHelpLog(...) { return Result.error(410, "..."); } @PostMapping("/delete") public Result deleteHelpLog(...) { return Result.error(410, "..."); } @PostMapping("/records") public Result> records(...) { return Result.error(410, "..."); } ``` - [ ] **步骤 2:前端删除旧文件 + 更新 pages.json + api.js** ```bash # 删除文件 rm cfc-frontend/components/OctopusDiagram.vue rm cfc-frontend/pages/action-detail/octopus-add-help.vue rm cfc-frontend/pages/action-detail/octopus-records.vue # pages.json 移除 "octopus-add-help", "octopus-records" 两项 # api.js 删除 getOctopusTentacles/addOctopusHelpLog/deleteOctopusHelpLog/getOctopusRecords 4 行 ``` - [ ] **步骤 3:API_REFERENCE.md 标记旧接口废弃** 在 "4.37 情绪识别" 后或废弃列表中加: ``` ### 4.xx 章鱼图(旧,已废弃 410) | 路径 | 说明 | 替代 | |------|------|------| | POST /api/octopus/tentacles | 触手榜 | /api/octopus/effect/list | | POST /api/octopus/add | 新增帮助记录 | /api/octopus/effect/add | | POST /api/octopus/delete | 删除帮助记录 | /api/octopus/effect/delete | | POST /api/octopus/records | 某联系人帮助明细 | /api/octopus/effect/key-persons | ``` - [ ] **步骤 4:验证编译 + Commit** ```bash cd cfc-backend && mvn clean compile -DskipTests git add -A git commit -m "refactor: 废弃旧章鱼图接口/组件/页面/文档(410 Gone),清理 3 旧文件" ``` --- ### 任务 6:前端 api.js 新增 10 个封装 **文件:** - 修改:`cfc-frontend/utils/api.js`(在 getAbilityMap 后追加) - [ ] **步骤 1:追加 10 个函数** ```javascript // ===== 关键人拓展章鱼图 ===== export const getOctopusEffects = (data) => request('/api/octopus/effect/list', 'POST', data) export const addOctopusEffect = (data) => request('/api/octopus/effect/add', 'POST', data) export const deleteOctopusEffect = (data) => request('/api/octopus/effect/delete', 'POST', data) export const addOctopusKeyPerson = (data) => request('/api/octopus/key-person/add', 'POST', data) export const updateOctopusKeyPerson = (data) => request('/api/octopus/key-person/update', 'POST', data) export const deleteOctopusKeyPerson = (data) => request('/api/octopus/key-person/delete', 'POST', data) export const getOctopusEffectKeyPersons = (data) => request('/api/octopus/effect/key-persons', 'POST', data) export const getOctopusAnalysisKeywords = (data) => request('/api/octopus/analysis/keywords', 'POST', data) export const getOctopusAnalysisSummary = (data) => request('/api/octopus/analysis/summary', 'POST', data) export const getOctopusExpansionSuggest = (data) => request('/api/octopus/expansion/suggest', 'POST', data) ``` - [ ] **步骤 2:Commit** ```bash git add cfc-frontend/utils/api.js git commit -m "feat: api.js 新增 10 个关键人拓展接口封装" ``` --- ### 任务 7:前端页面——成效录入页 **文件:** - 创建:`cfc-frontend/pages/action-detail/octopus-add-effect.vue` - [ ] **步骤 1:参考 pearl-add-resource.vue 风格实现** ```vue ``` - [ ] **步骤 2:注册 pages.json** ```json // pages.json action-detail 分包中,在 pearl-add-resource 后加: { "path": "octopus-add-effect", "style": { "navigationBarTitleText": "记录成效" } } ``` - [ ] **步骤 3:Commit** ```bash git add cfc-frontend/pages/action-detail/octopus-add-effect.vue cfc-frontend/pages.json git commit -m "feat: octopus-add-effect 成效录入页 + pages.json 注册" ``` --- ### 任务 8:前端页面——关键人录入页 **文件:** - 创建:`cfc-frontend/pages/action-detail/octopus-add-key-person.vue` - [ ] **步骤 1:实现(支持预填 prefill 参数)** ```vue ``` - [ ] **步骤 2:注册 pages.json** ```json { "path": "octopus-add-key-person", "style": { "navigationBarTitleText": "添加关键人" } } ``` - [ ] **步骤 3:Commit** ```bash git add cfc-frontend/pages/action-detail/octopus-add-key-person.vue cfc-frontend/pages.json git commit -m "feat: octopus-add-key-person 关键人录入页(支持 prefill 预填)+ pages.json" ``` --- ### 任务 9:前端新组件 OctopusDiagram.vue(全新实现,替换旧组件) **文件:** - 创建:`cfc-frontend/components/OctopusDiagram.vue` - [ ] **步骤 1:实现 Canvas 2d 三层画布(参考 PearlDiagram/AbilityDiagram 模式)** 关键要点: - `props: effects, keyPersons, expansions, selfId, canvasWidth, canvasHeight, interactive` - `data: dpr, ctx, _destroyed, _canvasNode, _canvasRect, canvasReady, centerNode, effectNodes, keyPersonNodes, expansionNodes, selectedNode, ...` - `mounted: $nextTick x2 → initCanvas(retry 5次) → buildLayout() → drawAll()` - `beforeDestroy: _destroyed = true` - `methods: initCanvas(cb), buildLayout(), drawAll(), _drawCenterNode, _drawEffectNodes, _drawKeyPersonNodes, _drawExpansionNodes, _drawEdges, _drawNode, hitTest, onTouchStart/Move/End, onCanvasTap, formatDate, _hexToRgba, _getCanvasRect` - **三层结构**: 1. 中心 "我" 2. 第1层:成效节点(圆形卡片,标签:金额大/频次高/新成交,点击 → 弹出关键人列表) 3. 第2层:关键人节点(头像+姓名+单位/职务,边标记:引荐/决策,点击 → 弹窗完整画像) 4. 第3层:拓展方向节点(虚线/浅色 ≤8个,点击 → 弹出"建议画像模板" → 跳转 octopus-add-key-person?prefill=...) - **交互**: - `onTouchStart/Move/End` 区分滑动与点击(移动 >10px 取消点击) - `hitTest` 圆形命中测试 - 点击成效节点 → `this.$emit('effect-click', effect)` 父组件处理弹出关键人列表 - 点击关键人 → 弹窗显示完整画像(单位/部门/职务/初识场合/如何认识/联系方式) - 点击拓展节点 → `uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person?prefill=' + encodeURIComponent(JSON.stringify(prefill)) })` - 长按/右上角 "重新分析" → `this.$emit('reanalyze')` - [ ] **步骤 2:样式(复用 PearlDiagram/AbilityDiagram 的 scoped CSS 模式)** - 禁 `?.`、禁 CSS Grid、禁 `:key` 表达式、日期格式化用 `substring(0,10)` - [ ] **步骤 3:Commit** ```bash git add cfc-frontend/components/OctopusDiagram.vue git commit -m "feat: OctopusDiagram.vue 全新关键人拓展画布(三层 Canvas + 触摸交互 + 预填跳转)" ``` --- ### 任务 10:集成到 action-detail/index.vue **文件:** - 修改:`cfc-frontend/pages/action-detail/index.vue` - [ ] **步骤 1:更新 imports + components 注册** ```js // 删除旧 import // import OctopusDiagram from '../../components/OctopusDiagram.vue' // 新 import import OctopusDiagram from '../../components/OctopusDiagram.vue' import { getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest } from '../../utils/api.js' ``` ```js components: { // 删 OctopusDiagram 旧引用,保留新组件 OctopusDiagram, ... } ``` - [ ] **步骤 2:data 新增字段** ```js data() { return { // 旧字段保留... octopusEffects: null, octopusKeyPersons: {}, // effectId -> [{...}, ...] octopusExpansions: [], ... } } ``` - [ ] **步骤 3:onShow 新增加载** ```js onShow() { this.loadOctopusData() // 旧的 loadPearlResources/loadAbilityMap 保留 } ``` - [ ] **步骤 4:methods 新增** ```js loadOctopusData() { var self = this getOctopusEffects().then(function(res) { if (res.code === 200 && res.data) self.octopusEffects = res.data }) getOctopusAnalysisSummary().then(function(res) { if (res.code === 200 && res.data) self.octopusExpansions = res.data.directions || [] }) }, goOctopusAddEffect() { uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' }) }, onEffectClick(effect) { // 加载该成效的关键人 var self = this getOctopusEffectKeyPersons({ effectId: effect.id }).then(function(res) { if (res.code === 200) self.$set(self.octopusKeyPersons, effect.id, res.data) }) }, onExpansionClick(expansion) { // 预填跳转关键人录入页 var prefill = encodeURIComponent(JSON.stringify(expansion.prefill)) uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person?prefill=' + prefill }) }, onReanalyze() { this.loadOctopusData() } ``` - [ ] **步骤 5:模板替换** ```html 🐙 关键人拓展 从成效出发,挖掘关键人,画出拓展路径 + 记录成效 ``` - [ ] **步骤 3:Commit** ```bash git add cfc-frontend/pages/action-detail/index.vue git commit -m "feat: action-detail 集成新 OctopusDiagram(三层画布 + 成效/关键人/拓展三层交互)" ``` --- ### 任务 11:API_REFERENCE.md 同步新接口 **文件:** - 修改:`docs/superpowers/api/API_REFERENCE.md` - [ ] **步骤 1:Controller 总览表新增 KeyPersonController 行** ```markdown | `KeyPersonController` | `/api/octopus` | 关键人拓展(成效/关键人/分析/拓展) | — | ``` - [ ] **步骤 2:在 4.39 后新增 4.40 节** ```markdown ### 4.40 关键人拓展章鱼图(`/api/octopus/*`) 替代旧章鱼图(help_logs)。核心:成效记录 → 关键人画像 → 高频词分析 → 拓展方向。 | 路径 | 说明 | |------|------| | `POST /api/octopus/effect/list` | 成效记录分页列表(支持 effect_type 筛选) | | `POST /api/octopus/effect/add` | 新增成效(member_order_id + effect_type bitmask + amount + desc) | | `POST /api/octopus/effect/delete` | 删除成效(级联关系) | | `POST /api/octopus/key-person/add` | 新增关键人(姓名/单位/部门/职务/初识场合/如何认识) | | `POST /api/octopus/key-person/update` | 修改关键人画像 | | `POST /api/octopus/key-person/delete` | 删除关键人(级联关系) | | `POST /api/octopus/effect/key-persons` | 某成效的关键人列表 | | `POST /api/octopus/analysis/keywords` | 高频词统计(单位/部门/职务/场合/路径) | | `POST /api/octopus/analysis/summary` | 高频词提炼 ≤8 个拓展方向 | | `POST /api/octopus/expansion/suggest` | 基于分析推荐关键人画像模板(预填) | > **已记录关键人拓展 API 端点 — 禁止重复注册。** ``` - [ ] **步骤 3:标记旧 OctopusController 接口废弃** 在 "4.37 情绪识别" 后加旧接口废弃标记(见任务 5 步骤 3)。 - [ ] **步骤 4:Commit** ```bash git add docs/superpowers/api/API_REFERENCE.md git commit -m "docs: API_REFERENCE 同步关键人拓展 10 接口 + 旧章鱼图 4 接口标记废弃" ``` --- ### 任务 12:全链路验证 + 最终 Commit **文件:** - 全部已修改文件 - [ ] **步骤 1:后端编译验证** ```bash cd cfc-backend && mvn clean compile -DskipTests # 预期:BUILD SUCCESS ``` - [ ] **步骤 2:前端语法检查** ```bash cd cfc-frontend && npm run lint 2>&1 | head -50 # 无 Error 级别报错(Warning 可忽略) ``` - [ ] **步骤 3:API_REFERENCE 完整性检查** ```bash grep -c "/api/octopus/" docs/superpowers/api/API_REFERENCE.md # 预期:包含 10 个新接口 + 4 个废弃接口标记 ``` - [ ] **步骤 4:全量 Commit + Push** ```bash git add -A git commit -m "feat: 关键人拓展章鱼图全链路上线(迁移305-307 + 3实体/Mapper/Service/Controller + 新OctopusDiagram + 2新页面 + action-detail集成 + API文档同步 + 旧组件清理)" git push origin cfclub ``` --- ## 执行方式选择 **计划已完成并保存到 `docs/superpowers/plans/2026-09-10-key-person-expansion-octopus.md`。两种执行方式:** **1. 子代理驱动(推荐)** - 每个任务调度一个新的子代理,任务间进行审查,快速迭代 > **必需子技能:** 使用 superpowers:subagent-driven-development > 每个任务一个新子代理 + 两阶段审查 **2. 内联执行** - 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点供审查 > **必需子技能:** 使用 superpowers:executing-plans > 批量执行并设有检查点供审查 **选哪种方式?**