Преглед на файлове

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

E2E Test Bot преди 2 дни
родител
ревизия
2a72cd267c
променени са 23 файла, в които са добавени 730 реда и са изтрити 30 реда
  1. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 25 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java
  3. 15 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
  4. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  5. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusKeyPerson.java
  6. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
  7. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java
  8. 94 3
      cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
  9. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  10. 3 0
      cfc-backend/src/main/resources/schema.sql
  11. 17 3
      cfc-frontend/pages/action-detail/components/OctopusDiagram.vue
  12. 102 2
      cfc-frontend/pages/action-detail/octopus-add-effect.vue
  13. 160 4
      cfc-frontend/pages/action-detail/octopus-add-key-person.vue
  14. 3 0
      cfc-frontend/pages/health/health-plan-summary.vue
  15. 2 2
      cfc-frontend/pages/teacher/team.vue
  16. 12 3
      cfc-frontend/utils/api.js
  17. 1 1
      cfc-web/.last_build_commit
  18. 2 2
      cfc-web/package-lock.json
  19. 1 1
      cfc-web/package.json
  20. 30 0
      cfc-web/public/CHANGELOG-v1.0.md
  21. 31 1
      cfc-web/public/CHANGELOG.md
  22. 1 1
      docs/superpowers/PROJECT-OVERVIEW.md
  23. 87 1
      docs/superpowers/api/API_REFERENCE.md

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

@@ -11487,6 +11487,14 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
 
         ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
+
+        // 迁移342: octopus_key_person 添加 first_meet_time / referrer_id / system_user_id 列(感恩记录新增字段)
+        ensureColumn("octopus_key_person", "first_meet_time", "DATETIME COMMENT '相识具体时间'");
+        log.info("迁移342: octopus_key_person.first_meet_time 列已添加");
+        ensureColumn("octopus_key_person", "referrer_id", "BIGINT DEFAULT NULL COMMENT '介绍人关键人ID(指向同用户另一条关键人)'");
+        log.info("迁移342: octopus_key_person.referrer_id 列已添加");
+        ensureColumn("octopus_key_person", "system_user_id", "BIGINT DEFAULT NULL COMMENT '匹配到的系统用户ID(如有)'");
+        log.info("迁移342: octopus_key_person.system_user_id 列已添加");
     }
 
     /**

+ 25 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.service.KeyPersonService;
 import org.springframework.web.bind.annotation.*;
 
@@ -74,16 +75,19 @@ public class KeyPersonController {
     // ===== 2. 找关键人 =====
     @PostMapping("/key-person/add")
     public Result<com.etotem.cfc.entity.OctopusKeyPerson> addKeyPerson(@RequestBody Map<String, Object> params,
-                                                                       @RequestAttribute("userId") Long userId) {
+                                                                        @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 firstMeetTime = (String) params.get("firstMeetTime");
         String knowWay = (String) params.get("knowWay");
         String contactInfo = (String) params.get("contactInfo");
+        Long referrerId = params.get("referrerId") != null
+                ? Long.valueOf(params.get("referrerId").toString()) : null;
         return keyPersonService.addKeyPerson(userId, name, organization, department, title,
-                firstMeetScene, knowWay, contactInfo);
+                firstMeetScene, firstMeetTime, knowWay, contactInfo, referrerId);
     }
 
     @PostMapping("/key-person/update")
@@ -123,4 +127,23 @@ public class KeyPersonController {
     public Result<List<Map<String, Object>>> expansionSuggest(@RequestAttribute("userId") Long userId) {
         return keyPersonService.expansionSuggest(userId);
     }
+
+    /** 通过手机号匹配系统内用户(用于关键人绑定真实用户) */
+    @PostMapping("/key-person/match-by-phone")
+    public Result<Map<String, Object>> matchKeyPersonByPhone(@RequestBody Map<String, Object> params,
+                                                              @RequestAttribute("userId") Long userId) {
+        String phone = params.get("phone") != null ? params.get("phone").toString().trim() : null;
+        return keyPersonService.matchKeyPersonByPhone(userId, phone);
+    }
+
+    /** 将关键人与系统用户绑定(持久化匹配状态) */
+    @PostMapping("/key-person/bind-system-user")
+    public Result<OctopusKeyPerson> bindSystemUser(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("userId") Long userId) {
+        Long keyPersonId = params.get("keyPersonId") != null
+                ? Long.valueOf(params.get("keyPersonId").toString()) : null;
+        Long systemUserId = params.get("systemUserId") != null
+                ? Long.valueOf(params.get("systemUserId").toString()) : null;
+        return keyPersonService.bindSystemUser(userId, keyPersonId, systemUserId);
+    }
 }

+ 15 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java

@@ -90,7 +90,11 @@ public class ServiceRoleApplicationController {
 
     @PostMapping("/admin/list")
     @Operation(summary = "管理端: 获取申请列表")
-    public Result adminList(@RequestParam(required = false, defaultValue = "0") Integer status) {
+    public Result adminList(@RequestParam(required = false, defaultValue = "0") Integer status,
+                            @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅管理员/运营可查看服务角色申请列表");
+        }
         List<ServiceRoleApplication> list = serviceRoleApplicationService.listApplications(status);
         return Result.success(list);
     }
@@ -98,7 +102,11 @@ public class ServiceRoleApplicationController {
     @PostMapping("/admin/approve")
     @Operation(summary = "管理端: 批准申请")
     public Result adminApprove(@RequestBody Map<String, Object> params,
-                               @RequestAttribute("userId") Long adminId) {
+                               @RequestAttribute("userId") Long adminId,
+                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅管理员/运营可审批服务角色申请");
+        }
         Long id = ParamUtils.getLong(params.get("id"));
         return serviceRoleApplicationService.approve(id, adminId);
     }
@@ -106,7 +114,11 @@ public class ServiceRoleApplicationController {
     @PostMapping("/admin/reject")
     @Operation(summary = "管理端: 驳回申请")
     public Result adminReject(@RequestBody Map<String, Object> params,
-                              @RequestAttribute("userId") Long adminId) {
+                              @RequestAttribute("userId") Long adminId,
+                              @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅管理员/运营可审批服务角色申请");
+        }
         Long id = ParamUtils.getLong(params.get("id"));
         String reason = params.get("reason") != null ? params.get("reason").toString() : "";
         return serviceRoleApplicationService.reject(id, reason, adminId);

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -8,11 +8,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.TaskService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -23,6 +25,7 @@ import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.*;
 import java.util.stream.Collectors;
+import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "指导师-家庭任务管理", description = "指导师查看家庭任务、审核任务等接口")
 @RestController
@@ -44,6 +47,35 @@ public class GuideFamilyTaskController {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
+    /**
+     * 规划师解除家庭绑定(仅限解除属于自己的绑定)
+     */
+    @Operation(summary = "规划师解除家庭绑定")
+    @PostMapping("/unbind")
+    public Result<Map<String, Object>> unbindFamily(@RequestAttribute("userId") Long guideId,
+                                                   @RequestAttribute("role") String role,
+                                                   @RequestBody Map<String, Object> params) {
+        if (!"teacher".equals(role)) {
+            return Result.error("Access denied");
+        }
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) {
+            return Result.error("缺少绑定记录ID");
+        }
+        try {
+            GuideFamily gf = guideFamilyService.unbindByGuide(id, guideId);
+            Map<String, Object> result = new HashMap<>();
+            result.put("familyId", gf.getFamilyId());
+            result.put("serviceType", gf.getServiceType());
+            return Result.success(result);
+        } catch (RuntimeException e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+
     /**
      * 会员任务校验:task.memberOnly==1 且家庭非会员 → 拒绝(提示开通会员)
      */

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusKeyPerson.java

@@ -31,6 +31,12 @@ public class OctopusKeyPerson implements Serializable {
 
     private String contactInfo;
 
+    private Date firstMeetTime;
+
+    private Long referrerId;
+
+    private Long systemUserId; // 匹配到的系统用户ID(如有)
+
     private Date createdAt;
 
     // 非持久化:角色名

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -3,10 +3,16 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.OctopusEffectKeyPerson;
+import com.etotem.cfc.entity.OctopusEffectRecord;
+import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
+import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
+import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -49,6 +55,12 @@ public class CfCommissionService {
     private SysConfigService sysConfigService;
     @Resource
     private ProductMapper productMapper;
+    @Resource
+    private OctopusKeyPersonMapper octopusKeyPersonMapper;
+    @Resource
+    private OctopusEffectRecordMapper octopusEffectRecordMapper;
+    @Resource
+    private OctopusEffectKeyPersonMapper octopusEffectKeyPersonMapper;
 
     /**
      * 通用分佣(双返):当前人 + 推荐人
@@ -148,6 +160,8 @@ public class CfCommissionService {
                 platformPointsService.earn(referrerId, share, "referral_dist", orderId,
                         "推荐分润:" + orderType);
                 record(referrerId, null, referrer.getFamilyId(), share, "allocate", "referral_dist", orderId, "推荐分润");
+                // 自动创建感恩记录:邀请人的被邀请人购买后,记录为关键人关系
+                autoCreateGratitudeEffect(referrerId, buyerUserId, share, orderId);
             } catch (Exception e) {
                 log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
             }
@@ -199,4 +213,68 @@ public class CfCommissionService {
             log.warn("写流转记录失败: {}", e.getMessage());
         }
     }
+
+    /**
+     * 当邀请人的被邀请人(系统用户)消费产生 CF 时,自动在邀请人侧创建感恩记录。
+     * 将买家作为关键人,CF 金额作为成效金额,角色固定为"其他"。
+     */
+    private void autoCreateGratitudeEffect(Long referrerId, Long buyerUserId, int cfAmount, Long orderId) {
+        try {
+            // 1. 查找或创建买家为关键人
+            Long buyerKeyPersonId = findOrCreateBuyerKeyPerson(referrerId, buyerUserId);
+            if (buyerKeyPersonId == null) return;
+
+            // 2. 创建 OctopusEffectRecord
+            OctopusEffectRecord effect = new OctopusEffectRecord();
+            effect.setUserId(referrerId);
+            effect.setMemberOrderId(orderId);
+            effect.setEffectType(4); // 新成交
+            effect.setEffectAmount(java.math.BigDecimal.valueOf(cfAmount));
+            effect.setEffectDesc("邀请人消费返CF:" + cfAmount + "分");
+            effect.setStatus(1);
+            effect.setCreatedAt(new Date());
+            octopusEffectRecordMapper.insert(effect);
+
+            // 3. 关联关键人
+            OctopusEffectKeyPerson rel = new OctopusEffectKeyPerson();
+            rel.setEffectId(effect.getId());
+            rel.setKeyPersonId(buyerKeyPersonId);
+            rel.setRole(3); // 其他
+            rel.setCreatedAt(new Date());
+            octopusEffectKeyPersonMapper.insert(rel);
+        } catch (Exception e) {
+            log.warn("自动创建感恩记录失败: referrerId={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
+        }
+    }
+
+    private Long findOrCreateBuyerKeyPerson(Long referrerId, Long buyerUserId) {
+        // 查找是否已有关键人关联到该 buyerUserId
+        List<OctopusKeyPerson> existing = octopusKeyPersonMapper.selectList(
+                new LambdaQueryWrapper<OctopusKeyPerson>()
+                        .eq(OctopusKeyPerson::getUserId, referrerId)
+                        .eq(OctopusKeyPerson::getContactInfo, resolveBuyerPhone(buyerUserId)));
+        if (!existing.isEmpty()) return existing.get(0).getId();
+
+        // 查找用户信息
+        User buyer = userMapper.selectById(buyerUserId);
+        if (buyer == null) return null;
+
+        // 创建关键人
+        OctopusKeyPerson kp = new OctopusKeyPerson();
+        kp.setUserId(referrerId);
+        kp.setName(buyer.getNickname() != null ? buyer.getNickname() : "用户" + buyerUserId);
+        kp.setContactInfo(buyer.getPhone());
+        kp.setCreatedAt(new Date());
+        octopusKeyPersonMapper.insert(kp);
+        return kp.getId();
+    }
+
+    private String resolveBuyerPhone(Long buyerUserId) {
+        try {
+            User u = userMapper.selectById(buyerUserId);
+            return u != null ? u.getPhone() : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
 }

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java

@@ -116,6 +116,25 @@ public class GuideFamilyService {
         return gf;
     }
 
+    /**
+     * 规划师解除绑定(仅允许解除属于自己的绑定)
+     */
+    public GuideFamily unbindByGuide(Long id, Long guideId) {
+        GuideFamily gf = guideFamilyMapper.selectOne(
+            new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<GuideFamily>()
+                .eq("id", id)
+                .eq("guide_id", guideId)
+                .eq("status", "binding")
+        );
+        if (gf == null) {
+            throw new RuntimeException("绑定关系不存在或已解除");
+        }
+        gf.setStatus("cancelled");
+        gf.setUpdatedAt(new Date());
+        guideFamilyMapper.updateById(gf);
+        return gf;
+    }
+
     /**
      * 解除绑定
      */

+ 94 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -3,12 +3,16 @@ 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.FamilyMember;
 import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusKeyPerson;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -25,6 +29,12 @@ public class KeyPersonService {
     private OctopusKeyPersonMapper keyPersonMapper;
     @Resource
     private OctopusEffectKeyPersonMapper ekpMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+    @Resource
+    private EnergyService energyService;
 
     // ========== 1. 盘点项目 ==========
 
@@ -174,6 +184,21 @@ public class KeyPersonService {
                 ekpMapper.insert(rel);
             }
         }
+        // 行维度能量奖励:每填一条感恩记录 +1,逢5条再额外+1
+        try {
+            Long newCount = effectRecordMapper.selectCount(
+                    new LambdaQueryWrapper<OctopusEffectRecord>().eq(OctopusEffectRecord::getUserId, userId));
+            int bonus = 1;
+            if (newCount != null && newCount % 5 == 0) bonus++;
+            // 查找当前用户的 family_member 记录作为 memberId
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, userId).last("LIMIT 1"));
+            if (!members.isEmpty()) {
+                energyService.awardEnergyByCode(members.get(0).getId(), "action", bonus, "填写感恩记录奖励");
+            }
+        } catch (Exception e) {
+            log.warn("发放感恩记录能量奖励失败 userId={}: {}", userId, e.getMessage());
+        }
         return Result.success(r);
     }
 
@@ -198,6 +223,13 @@ public class KeyPersonService {
                         .orderByDesc(OctopusKeyPerson::getCreatedAt)
         );
         List<Map<String, Object>> list = new ArrayList<>();
+        Map<Long, String> refNameMap = new HashMap<>();
+        for (OctopusKeyPerson kp : kps) {
+            if (kp.getReferrerId() != null) {
+                OctopusKeyPerson ref = keyPersonMapper.selectById(kp.getReferrerId());
+                if (ref != null) refNameMap.put(kp.getReferrerId(), ref.getName());
+            }
+        }
         for (OctopusKeyPerson kp : kps) {
             Map<String, Object> m = new LinkedHashMap<>();
             m.put("id", kp.getId());
@@ -206,18 +238,73 @@ public class KeyPersonService {
             m.put("department", kp.getDepartment());
             m.put("title", kp.getTitle());
             m.put("firstMeetScene", kp.getFirstMeetScene());
+            m.put("firstMeetTime", kp.getFirstMeetTime() != null ? kp.getFirstMeetTime().toString() : "");
             m.put("knowWay", kp.getKnowWay());
             m.put("contactInfo", kp.getContactInfo());
+            m.put("referrerId", kp.getReferrerId());
+            m.put("referrerName", kp.getReferrerId() != null ? refNameMap.get(kp.getReferrerId()) : "");
+            m.put("systemUserId", kp.getSystemUserId());
+            m.put("matched", kp.getSystemUserId() != null);
             m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
             list.add(m);
         }
         return Result.success(list);
     }
 
-    public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String organization,
+    /** 通过手机号匹配系统内用户(用于关键人绑定真实用户) */
+    public Result<Map<String, Object>> matchKeyPersonByPhone(Long userId, String phone) {
+        if (phone == null || phone.trim().isEmpty()) {
+            return Result.error("手机号不能为空");
+        }
+        phone = phone.trim();
+        // 1. 查 users 表
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>().eq(User::getPhone, phone).last("LIMIT 1")
+        );
+        User matchedUser = (users != null && !users.isEmpty()) ? users.get(0) : null;
+        Map<String, Object> data = new LinkedHashMap<>();
+        if (matchedUser != null) {
+            data.put("userId", matchedUser.getId());
+            data.put("nickname", matchedUser.getNickname());
+            data.put("realName", matchedUser.getRealName());
+            data.put("avatar", matchedUser.getAvatar());
+            data.put("familyId", matchedUser.getFamilyId());
+            data.put("phone", matchedUser.getPhone());
+            // 尝试通过家庭成员表找到 memberId(能力图/能量发放需要)
+            if (matchedUser.getFamilyId() != null) {
+                List<FamilyMember> members = familyMemberMapper.selectList(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getFamilyId, matchedUser.getFamilyId())
+                                .eq(FamilyMember::getUserId, matchedUser.getId())
+                                .last("LIMIT 1")
+                );
+                if (members != null && !members.isEmpty()) {
+                    data.put("memberId", members.get(0).getId());
+                    data.put("memberName", members.get(0).getNickname());
+                }
+            }
+            data.put("systemUserId", matchedUser.getId());
+        }
+        data.put("matched", matchedUser != null);
+        return Result.success(data);
+    }
+
+    /** 将关键人与系统用户绑定(持久化匹配状态) */
+    public Result<OctopusKeyPerson> bindSystemUser(Long userId, Long keyPersonId, Long systemUserId) {
+        OctopusKeyPerson kp = keyPersonMapper.selectById(keyPersonId);
+        if (kp == null || !kp.getUserId().equals(userId)) {
+            return Result.error("关键人不存在或无权操作");
+        }
+        kp.setSystemUserId(systemUserId);
+        keyPersonMapper.updateById(kp);
+        return Result.success(kp);
+    }
+
+public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String organization,
                                                  String department, String title,
-                                                 String firstMeetScene, String knowWay,
-                                                 String contactInfo) {
+                                                 String firstMeetScene, String firstMeetTime,
+                                                 String knowWay, String contactInfo,
+                                                 Long referrerId) {
         if (name == null || name.trim().isEmpty()) {
             return Result.error("关键人姓名不能为空");
         }
@@ -228,8 +315,10 @@ public class KeyPersonService {
         kp.setDepartment(department != null ? department.trim() : null);
         kp.setTitle(title != null ? title.trim() : null);
         kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
+        kp.setFirstMeetTime(firstMeetTime != null ? java.sql.Timestamp.valueOf(firstMeetTime) : null);
         kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
         kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
+        kp.setReferrerId(referrerId);
         kp.setCreatedAt(new Date());
         keyPersonMapper.insert(kp);
         return Result.success(kp);
@@ -292,6 +381,8 @@ public class KeyPersonService {
             m.put("contactInfo", kp.getContactInfo());
             m.put("role", roleMap.get(kpId));
             m.put("roleName", roleName(roleMap.get(kpId)));
+            m.put("systemUserId", kp.getSystemUserId());
+            m.put("matched", kp.getSystemUserId() != null);
             m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
             list.add(m);
         }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -558,7 +558,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<HealthPlan> w = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
         w.eq("family_id", familyId);
         w.in("status", "draft", "pending_review");
-        if (teacherId != null) w.eq("teacher_id", teacherId);
+        if (teacherId != null) w.and(q -> q.eq("teacher_id", teacherId).or().isNull("teacher_id"));
         w.orderByDesc("created_at");
         return healthPlanMapper.selectList(w);
     }

+ 3 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5382,6 +5382,9 @@ CREATE TABLE IF NOT EXISTS octopus_key_person (
     first_meet_scene VARCHAR(128) COMMENT '初识场合',
     know_way VARCHAR(255) COMMENT '如何认识(认识路径)',
     contact_info VARCHAR(255) COMMENT '联系方式(可选)',
+    first_meet_time DATETIME DEFAULT NULL COMMENT '相识具体时间',
+    referrer_id BIGINT DEFAULT NULL COMMENT '介绍人关键人ID(指向同用户另一条关键人)',
+    system_user_id BIGINT DEFAULT NULL COMMENT '匹配到的系统用户ID(如有)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_okp_user (user_id),
     INDEX idx_okp_org (organization)

+ 17 - 3
cfc-frontend/pages/action-detail/components/OctopusDiagram.vue

@@ -21,9 +21,10 @@
         </view>
         <view class="popup-keypersons" v-if="keyPersons[selectedEffect.id] && keyPersons[selectedEffect.id].length > 0">
           <text class="popup-title">关键人</text>
-          <text class="kp-item" v-for="kp in keyPersons[selectedEffect.id]" :key="kp.id">
-            {{ kp.name }} · {{ kp.title || kp.organization || '' }}
-          </text>
+          <view class="kp-item-row" v-for="kp in keyPersons[selectedEffect.id]" :key="kp.id">
+            <text class="kp-item">{{ kp.name }} · {{ kp.title || kp.organization || '' }}</text>
+            <text class="kp-sys-badge" v-if="kp.matched">✓ 系统用户</text>
+          </view>
         </view>
         <view class="popup-empty" v-else>
           <text class="empty-text">暂无关键人,点击添加</text>
@@ -663,6 +664,19 @@ export default {
   display: block;
   margin-bottom: 4rpx;
 }
+.kp-item-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+.kp-sys-badge {
+  font-size: 18rpx;
+  color: #10B981;
+  background: #ECFDF5;
+  padding: 2rpx 10rpx;
+  border-radius: 6rpx;
+  flex-shrink: 0;
+}
 .popup-empty {
   padding: 8rpx 0;
 }

+ 102 - 2
cfc-frontend/pages/action-detail/octopus-add-effect.vue

@@ -44,6 +44,9 @@
             <text class="kp-name">{{ kp.name }}</text>
             <text class="kp-org">{{ kp.organization || kp.title || '' }}</text>
           </view>
+          <!-- 已有联系方式 → 可匹配系统用户 -->
+          <text class="kp-match-btn" v-if="kp.contactInfo" @click.stop="onMatchByPhone(kp)">匹配用户</text>
+          <text class="kp-sys-badge" v-if="kp.matched">✓ 系统用户</text>
           <!-- 角色选择(选中时显示) -->
           <view class="kp-role" v-if="selectedMap[kp.id]" @click.stop>
             <text class="role-tag" :class="{active: roleMap[kp.id] === 1}" @click="setRole(kp.id, 1)">引荐</text>
@@ -55,6 +58,16 @@
       <text class="empty-hint" v-else>暂无关键人,点击「+ 新建」添加</text>
     </view>
 
+    <!-- 按手机号快速建关键人 -->
+    <view class="form-section">
+      <text class="section-label">手机号匹配(选填)</text>
+      <view class="phone-match-row">
+        <input class="phone-input" v-model="matchPhone" placeholder="输入手机号自动匹配" maxlength="11" type="number" />
+        <text class="phone-match-btn" @tap="doPhoneMatch">匹配</text>
+      </view>
+      <text class="phone-match-result" v-if="matchResult">{{ matchResult }}</text>
+    </view>
+
     <!-- 提交按钮 -->
     <view class="submit-bar">
       <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存感恩记录</button>
@@ -63,7 +76,7 @@
 </template>
 
 <script>
-import { addOctopusEffect, getOctopusKeyPersons } from '../../utils/api.js'
+import { addOctopusEffect, getOctopusKeyPersons, matchOctopusKeyPersonByPhone, bindOctopusKeyPersonSystemUser } from '../../utils/api.js'
 
 export default {
   data() {
@@ -81,7 +94,9 @@ export default {
       keyPersons: [],
       selectedMap: {},   // kpId -> true
       roleMap: {},        // kpId -> role (1引荐/2决策/3其他)
-      _onLoadFired: false
+      _onLoadFired: false,
+      matchPhone: '',
+      matchResult: ''
     }
   },
   computed: {
@@ -109,6 +124,43 @@ export default {
         }
       } catch (e) { /* 静默 */ }
     },
+    /** 按关键人联系方式匹配系统用户 */
+    onMatchByPhone: function(kp) {
+      if (!kp.contactInfo) return
+      this.doPhoneMatchInternal(kp.contactInfo, kp.id)
+    },
+    /** 手机号匹配输入框触发 */
+    doPhoneMatch: function() {
+      var phone = this.matchPhone.trim()
+      if (!phone) {
+        this.matchResult = '请输入手机号'
+        return
+      }
+      this.matchResult = '匹配中...'
+      this.doPhoneMatchInternal(phone, null)
+    },
+    async doPhoneMatchInternal(phone, kpId) {
+      try {
+        var res = await matchOctopusKeyPersonByPhone({ phone: phone })
+        if (res.code === 200 && res.data && res.data.matched) {
+          var matchedName = res.data.nickname || res.data.realName || res.data.memberName || ''
+          var extra = res.data.memberId ? ' (memberId:' + res.data.memberId + ')' : ''
+          this.matchResult = '已匹配: ' + matchedName + extra
+          if (kpId && res.data.systemUserId) {
+            // 持久化:将关键人与系统用户绑定
+            var bindRes = await bindOctopusKeyPersonSystemUser({ keyPersonId: kpId, systemUserId: res.data.systemUserId })
+            if (bindRes.code === 200) {
+              this.$set(this.keyPersons[this.keyPersons.findIndex(function(k) { return k.id === kpId })], 'matched', true)
+              this.matchResult = '已匹配并保存: ' + matchedName + extra
+            }
+          }
+        } else {
+          this.matchResult = res.message || '未找到该手机号对应的用户'
+        }
+      } catch (e) {
+        this.matchResult = '匹配失败,请稍后重试'
+      }
+    },
     goAddKeyPerson() {
       uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person' })
     },
@@ -249,6 +301,23 @@ export default {
   font-size: 22rpx;
   color: #999;
 }
+.kp-match-btn {
+  font-size: 22rpx;
+  color: #0EA5E9;
+  padding: 4rpx 12rpx;
+  border: 1rpx solid #0EA5E9;
+  border-radius: 8rpx;
+  background: #F0F9FF;
+  flex-shrink: 0;
+}
+.kp-sys-badge {
+  font-size: 20rpx;
+  color: #10B981;
+  background: #ECFDF5;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+  margin-left: 8rpx;
+}
 .kp-role {
   display: flex;
   margin-top: 12rpx;
@@ -306,4 +375,35 @@ export default {
   font-size: 28rpx;
   color: #ccc;
 }
+/* 手机号匹配 */
+.phone-match-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.phone-input {
+  flex: 1;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.phone-match-btn {
+  padding: 0 28rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: #0EA5E9;
+  color: #fff;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  flex-shrink: 0;
+}
+.phone-match-result {
+  font-size: 24rpx;
+  color: #0EA5E9;
+  margin-top: 12rpx;
+  display: block;
+}
 </style>

+ 160 - 4
cfc-frontend/pages/action-detail/octopus-add-key-person.vue

@@ -29,11 +29,28 @@
       <input class="name-input" v-model="firstMeetScene" placeholder="如:家长会、行业峰会、朋友介绍" maxlength="50" />
     </view>
 
+    <view class="form-section">
+      <text class="section-label">相识时间(选填)</text>
+      <picker mode="date" :value="firstMeetTime" @change="onDateChange">
+        <view class="form-picker">{{ firstMeetTime || '选择日期' }}</view>
+      </picker>
+    </view>
+
     <view class="form-section">
       <text class="section-label">如何认识</text>
       <textarea class="desc-input" v-model="knowWay" placeholder="如:孩子班主任介绍认识、同事推荐" maxlength="200"></textarea>
     </view>
 
+    <view class="form-section">
+      <text class="section-label">介绍人(选填,从现有关键人中选择)</text>
+      <view class="referrer-select" v-if="existingKeyPersons.length > 0" @tap="showReferrerPicker = true">
+        <text class="referrer-selected" v-if="referrerId">{{ getReferrerName(referrerId) }}</text>
+        <text class="referrer-placeholder" v-else>选择介绍人...</text>
+        <text class="picker-arrow">›</text>
+      </view>
+      <text class="empty-referrer" v-else>暂无其他关键人可作为介绍人</text>
+    </view>
+
     <view class="form-section">
       <text class="section-label">联系方式(选填)</text>
       <input class="name-input" v-model="contactInfo" placeholder="电话/微信/邮箱" maxlength="50" />
@@ -42,11 +59,28 @@
     <view class="submit-bar">
       <button class="btn-submit" @click="onSubmit" :disabled="!name.trim()">保存关键人</button>
     </view>
+
+    <!-- 介绍人选择弹窗 -->
+    <view class="referrer-modal-mask" v-if="showReferrerPicker" @tap="showReferrerPicker = false">
+      <view class="referrer-modal" @tap.stop>
+        <view class="referrer-modal-header">
+          <text class="referrer-modal-title">选择介绍人</text>
+          <text class="referrer-modal-close" @tap="showReferrerPicker = false">✕</text>
+        </view>
+        <view class="referrer-option" @tap="selectReferrer(null)">
+          <text>不选择</text>
+        </view>
+        <view class="referrer-option" v-for="kp in existingKeyPersons" :key="kp.id" @tap="selectReferrer(kp.id)">
+          <text class="referrer-option-name">{{ kp.name }}</text>
+          <text class="referrer-option-org">{{ kp.organization || kp.title || '' }}</text>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
-import { addOctopusKeyPerson } from '../../utils/api.js'
+import { addOctopusKeyPerson, getOctopusKeyPersons } from '../../utils/api.js'
 
 export default {
   data() {
@@ -56,8 +90,12 @@ export default {
       department: '',
       title: '',
       firstMeetScene: '',
+      firstMeetTime: '',
       knowWay: '',
-      contactInfo: ''
+      contactInfo: '',
+      referrerId: null,
+      existingKeyPersons: [],
+      showReferrerPicker: false
     }
   },
   onLoad(options) {
@@ -67,8 +105,30 @@ export default {
         Object.assign(this.$data, prefill)
       } catch (e) { /* 忽略 */ }
     }
+    this.loadExistingKeyPersons()
   },
   methods: {
+    async loadExistingKeyPersons() {
+      try {
+        var res = await getOctopusKeyPersons()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.existingKeyPersons = res.data
+        }
+      } catch (e) { /* 静默 */ }
+    },
+    getReferrerName(id) {
+      for (var i = 0; i < this.existingKeyPersons.length; i++) {
+        if (this.existingKeyPersons[i].id === id) return this.existingKeyPersons[i].name
+      }
+      return ''
+    },
+    selectReferrer(id) {
+      this.referrerId = id
+      this.showReferrerPicker = false
+    },
+    onDateChange(e) {
+      this.firstMeetTime = e.detail.value
+    },
     async onSubmit() {
       if (!this.name.trim()) {
         uni.showToast({ title: '请填写姓名', icon: 'none' })
@@ -80,8 +140,10 @@ export default {
         department: this.department.trim(),
         title: this.title.trim(),
         firstMeetScene: this.firstMeetScene.trim(),
+        firstMeetTime: this.firstMeetTime,
         knowWay: this.knowWay.trim(),
-        contactInfo: this.contactInfo.trim()
+        contactInfo: this.contactInfo.trim(),
+        referrerId: this.referrerId || null
       }
       try {
         var res = await addOctopusKeyPerson(data)
@@ -147,6 +209,100 @@ export default {
   font-size: 28rpx;
   box-sizing: border-box;
 }
+.form-picker {
+  width: 100%;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+}
+/* 介绍人选择器 */
+.referrer-select {
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+}
+.referrer-selected {
+  font-size: 28rpx;
+  color: #F97316;
+}
+.referrer-placeholder {
+  font-size: 28rpx;
+  color: #ccc;
+}
+.picker-arrow {
+  font-size: 32rpx;
+  color: #999;
+}
+.empty-referrer {
+  font-size: 26rpx;
+  color: #999;
+}
+/* 介绍人弹窗 */
+.referrer-modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.4);
+  display: flex;
+  align-items: flex-end;
+  z-index: 100;
+}
+.referrer-modal {
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  width: 100%;
+  max-height: 60vh;
+  overflow-y: auto;
+  padding-bottom: env(safe-area-inset-bottom);
+}
+.referrer-modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 24rpx 28rpx;
+  border-bottom: 1rpx solid #E5E7EB;
+  position: sticky;
+  top: 0;
+  background: #fff;
+}
+.referrer-modal-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.referrer-modal-close {
+  font-size: 32rpx;
+  color: #999;
+}
+.referrer-option {
+  padding: 24rpx 28rpx;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.referrer-option-name {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+}
+.referrer-option-org {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
 .submit-bar {
   margin-top: 40rpx;
 }
@@ -162,4 +318,4 @@ export default {
 .btn-submit[disabled] {
   background: #ccc;
 }
-</style>
+</style>

+ 3 - 0
cfc-frontend/pages/health/health-plan-summary.vue

@@ -1012,6 +1012,9 @@ selectedMembers: function() {
       saveHealthPlan(saveData).then(function(res) {
         self.loading = false
         if (res && res.code === 200) {
+          // 清理本次规划师选择缓存,避免下次生成方案时误用上一次的 teacherId
+          uni.removeStorageSync('healthPlan_teacherId')
+          uni.removeStorageSync('healthPlan_serviceType')
           uni.showToast({ title: '方案已生成', icon: 'none', duration: 2000 })
           setTimeout(function() { uni.navigateBack() }, 1200)
           self.taskSubmitted = true

+ 2 - 2
cfc-frontend/pages/teacher/team.vue

@@ -52,7 +52,7 @@
 </template>
 
 <script>
-import { getTeamMembers, getTeamStats, generateInviteCode } from '../../utils/api.js'
+import { getTeamMembers, getTeamStats, generateTeamInviteCode } from '../../utils/api.js'
 
 export default {
   data() {
@@ -93,7 +93,7 @@ export default {
     },
     async generateInviteCode() {
       try {
-        const res = await generateInviteCode()
+        const res = await generateTeamInviteCode()
         const code = res && res.data ? res.data.code : null
         if (!code) {
           uni.showToast({ title: '生成失败,请重试', icon: 'none' })

+ 12 - 3
cfc-frontend/utils/api.js

@@ -896,9 +896,9 @@ export const confirmBind = (data) => {
   return request('/api/guide/bind/confirm', 'POST', data)
 }
 
-// 解除绑定
+// 规划师解除家庭绑定
 export const unbindFamily = (id) => {
-  return request(`/api/guide/bind/${id}`, 'POST', { _method: 'DELETE' })
+  return request('/api/guide/families/unbind', 'POST', { id })
 }
 
 // 创建任务模板
@@ -982,8 +982,15 @@ export const registerGuide = (data) => {
   return request('/api/guide/register', 'POST', data)
 }
 
-// 生成邀请码(团队/规划师端)
+// 生成邀请码(规划师管理端,与 getInviteCodes 同属 GuideManagementController)
 export const generateInviteCode = () => {
+  return request('/api/guide/invite-code/generate', 'POST')
+}
+
+// 生成团队邀请码(成长规划师团队端 GuideTeamController)
+// 与管理端邀请码不同:GuideTeamController.getTeamMembers 按 User.teacherNo == 邀请码 反查成员,
+// 故团队页必须用本端点生成,否则生成的码与成员查询不匹配
+export const generateTeamInviteCode = () => {
   return request('/api/guide/team/invite-code/generate', 'POST')
 }
 
@@ -2476,6 +2483,8 @@ export const deleteOctopusEffect = (data) => request('/api/octopus/effect/delete
 
 export const addOctopusKeyPerson = (data) => request('/api/octopus/key-person/add', 'POST', data)
 export const getOctopusKeyPersons = (data) => request('/api/octopus/key-person/list', 'POST', data)
+export const matchOctopusKeyPersonByPhone = (data) => request('/api/octopus/key-person/match-by-phone', 'POST', data)
+export const bindOctopusKeyPersonSystemUser = (data) => request('/api/octopus/key-person/bind-system-user', '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)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-0320615b6f69b54f96dcb8bec40f5a9b0b713a12
+f1212c5502a59ff4213e2c7f83d659d699c2276c

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1489",
+  "version": "1.0.1491",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1489",
+      "version": "1.0.1491",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1489",
+  "version": "1.0.1491",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 30 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,36 @@
 
 ---
 
+## v1.0.1491 (2026-10-04)
+
+### 新功能
+- 章鱼图感恩记录增强 - 关键人新字段/手机号匹配/能量奖励/自动感恩记录
+
+### 其他
+-    - first_meet_time 相识具体时间(date picker)
+-    - referrer_id 介绍人(从现有关键人下拉选择,可为空仅可选)
+-    - system_user_id 匹配到的系统用户ID(持久化匹配状态)
+- 2. 手机号匹配系统用户:
+-    - 新增 POST /api/octopus/key-person/match-by-phone
+-    - 新增 POST /api/octopus/key-person/bind-system-user(持久化绑定)
+-    - 前端 octopus-add-effect.vue 增加手机号匹配输入框 + KP 匹配按钮 + '✓ 系统用户'徽标
+-    - 章鱼图弹窗关键人列表显示系统用户徽标
+- 3. 行维度能量奖励:
+-    - 每填一条感恩记录 +1 点行维度能量,逢5条再+1(KeyPersonService.addEffect)
+- 4. 自动感恩记录(CF 触发):
+-    - CfCommissionService 推荐人分润成功后自动创建 OctopusEffectRecord
+-    - 将买家自动建为邀请人关键人,CF金额作为成效金额
+- 5. 数据库迁移342:octopus_key_person 添加 first_meet_time/referrer_id/system_user_id 列(schema.sql 同步)
+- 验证:后端编译通过(仅剩既有 McpController 缺失 McpAuthService 的预存错误);前端语法 OK;API 去重审计 0 error
+- 
+
+
+## v1.0.1490 (2026-10-04)
+
+### 重构
+- move share QR to floating button on profile page - remove invite share menu item from ProfileMenu - remove share.vue page and its registration - add floating QR code button in profile-main/profile.vue - add share poster modal with QR code, copy and share actions - add support for ref code on homepage onLoad
+
+
 ## v1.0.1489 (2026-10-04)
 
 ### 新功能

+ 31 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1489
+> 当前版本: v1.0.1491
 
 ## 历史版本
 
@@ -8,6 +8,36 @@
 
 ---
 
+## v1.0.1491 (2026-10-04)
+
+### 新功能
+- 章鱼图感恩记录增强 - 关键人新字段/手机号匹配/能量奖励/自动感恩记录
+
+### 其他
+-    - first_meet_time 相识具体时间(date picker)
+-    - referrer_id 介绍人(从现有关键人下拉选择,可为空仅可选)
+-    - system_user_id 匹配到的系统用户ID(持久化匹配状态)
+- 2. 手机号匹配系统用户:
+-    - 新增 POST /api/octopus/key-person/match-by-phone
+-    - 新增 POST /api/octopus/key-person/bind-system-user(持久化绑定)
+-    - 前端 octopus-add-effect.vue 增加手机号匹配输入框 + KP 匹配按钮 + '✓ 系统用户'徽标
+-    - 章鱼图弹窗关键人列表显示系统用户徽标
+- 3. 行维度能量奖励:
+-    - 每填一条感恩记录 +1 点行维度能量,逢5条再+1(KeyPersonService.addEffect)
+- 4. 自动感恩记录(CF 触发):
+-    - CfCommissionService 推荐人分润成功后自动创建 OctopusEffectRecord
+-    - 将买家自动建为邀请人关键人,CF金额作为成效金额
+- 5. 数据库迁移342:octopus_key_person 添加 first_meet_time/referrer_id/system_user_id 列(schema.sql 同步)
+- 验证:后端编译通过(仅剩既有 McpController 缺失 McpAuthService 的预存错误);前端语法 OK;API 去重审计 0 error
+- 
+
+
+## v1.0.1490 (2026-10-04)
+
+### 重构
+- move share QR to floating button on profile page - remove invite share menu item from ProfileMenu - remove share.vue page and its registration - add floating QR code button in profile-main/profile.vue - add share poster modal with QR code, copy and share actions - add support for ref code on homepage onLoad
+
+
 ## v1.0.1489 (2026-10-04)
 
 ### 新功能

+ 1 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -374,7 +374,7 @@
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
-| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`) | 后台接口参考文档 |
+| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则)) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |

+ 87 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -77,6 +77,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `OctopusController` | `/api/octopus` | 章鱼图(帮助记录/触手聚合) | — |
 | `PearlController` | `/api/pearl` | 珍珠图(25 类社会连接聚合 + 互动/提醒/优先级) | — |
 | `AbilityController` | `/api/ability` | 能力图(SKILL 帮助记录聚合为能力节点) | — |
+| `ServiceRoleApplicationController` | `/api/service-role` | 统一服务角色申请(多角色合一 + 管理端审批) | — |
+| `GuideTeamController` | `/api/guide/team` | 规划师团队/邀请码/上下级 | — |
+| `GuideFamilyTaskController` | `/api/guide/families` | 规划师侧家庭任务管理 + 家庭解绑 | — |
 
 ---
 
@@ -1397,4 +1400,87 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 ---
 
-*文档最后更新:2026-09-29*
+### 4.44 统一服务角色申请(`/api/service-role/*`)
+
+多角色(规划师/营养师/管家/供应商/内容管理员/活动策划)统一申请入口,一次提交可含多个角色,管理端审批通过后回写 `users.roles` 并按角色初始化附属记录。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/service-role/apply` | 提交服务角色申请(`roles` 必填) |
+| `POST /api/service-role/my-apply` | 查询我的最新申请记录 |
+| `POST /api/service-role/upload-image` | 上传身份证/资质证书图片(multipart,字段名 `file`) |
+| `POST /api/service-role/admin/list` | 管理端:申请列表(`status` 查询参数,默认 `0`) |
+| `POST /api/service-role/admin/approve` | 管理端:批准(`{ id }`) |
+| `POST /api/service-role/admin/reject` | 管理端:驳回(`{ id, reason }`) |
+
+> **权限**:`admin/list`、`admin/approve`、`admin/reject` 三个端点校验 `@RequestAttribute("role")`,仅 `role=admin` 或 `role=operator` 可访问(与 `AdminHealthPlanController` 审核类端点口径一致),其他角色返回「无权限」。前端菜单由 `perm: 'audit:service-role'` 控制。
+
+**状态语义**(`service_role_applications.status`)
+
+| 值 | 含义 |
+|----|------|
+| `0` | 待审核(同一 userId 仅允许一条待审,重复提交返回「您已提交过申请,请等待审核」) |
+| `1` | 已通过 |
+| `2` | 已驳回(原因存 `reject_reason`) |
+
+**POST `/api/service-role/apply`** 请求体(`ServiceRoleApplyDTO`)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `roles` | List\<String\> | 申请角色,`@NotEmpty` 至少一个 |
+| `idCard` | String | 身份证号 |
+| `idCardImages` | List\<String\> | 身份证图片 URL 数组(落库转 JSON 字符串) |
+| `certificateImages` | List\<String\> | 资质证书图片 URL 数组 |
+| `province` / `city` / `district` / `street` | String | 四级地址 |
+| `addressDetail` | String | 详细地址 |
+
+**审批通过后的角色落地规则**(`ServiceRoleApplicationServiceImpl.approve`)
+
+| 申请角色 | 副作用 |
+|----------|--------|
+| `planner` | `guides` 新增一行(`status=active`)+ `users.roles` 追加 `teacher` |
+| `nutritionist` | `users.nutritionist_status=approved` + `roles` 追加 `nutritionist` |
+| `butler` | `users.butler_status=approved` + `butler_profiles` 新增一行 + `roles` 追加 `butler` |
+| `vendor` | `users.vendor_status=approved` + `roles` 追加 `supplier` |
+| `article_admin` | `users.article_admin_status=approved` + `roles` 追加 `knowledge_admin` |
+| `activity_provider` | `users.activity_provider_status=approved` + `roles` 追加 `activity_manager` |
+
+> **注意**:approve/reject 仅对 `status=0` 的记录生效,已处理返回「该申请已处理」。
+> `upload-image` 对图片类型文件先走 `wechatService.checkImage()` 内容安全检测,未通过直接拒绝。
+
+---
+
+### 4.45 规划师解除家庭绑定(`POST /api/guide/families/unbind`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/guide/families/unbind` | 规划师解除自己名下的家庭绑定(请求体 `{ id }`,即 `guide_families.id`) |
+
+- 角色校验:仅 `role=teacher` 可调用,否则返回 `Access denied`。
+- 归属校验:`GuideFamilyService.unbindByGuide()` 按 `id + guide_id + status='binding'` 定位,只能解除属于自己的绑定。
+- 副作用:将 `guide_families.status` 置为 `cancelled`。
+- 家长侧解绑为独立接口 `POST /api/bind/unbind`(按 familyId 定位,并通知规划师)。
+
+> **注意**:前端 `unbindFamily(id)` 需传 `guide_families.id`(绑定记录 ID),不是 `familyId`。
+
+---
+
+### 4.46 规划师方案审核(`/api/health/plan/pending-review/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/health/plan/pending-review/list` | 待审核方案列表(`teacher` 取当前登录人;`admin` 可传 `teacherId` 过滤,不传则返回该家庭全部) |
+| `POST /api/health/plan/pending-review/update` | 编辑方案内容(仅 `status` 为 `draft`/`pending_review` 允许) |
+| `POST /api/health/plan/pending-review/approve` | 审核通过并发布 |
+| `POST /api/health/plan/pending-review/reject` | 驳回 |
+
+- 角色校验:仅 `role=teacher` 或 `role=admin`,且必须携带 `familyId`,否则返回 `familyId不能为空`。
+- **未分配池规则**:`listPendingReviewPlans()` 在 `teacherId` 非空时按 `(teacher_id = ? OR teacher_id IS NULL)` 过滤 —— 规划师既能看到分配给自己的方案,也能看到本家庭内尚未指定规划师的方案(`health_plans.teacher_id` 为 `NULL`),避免用户未选规划师时保存的 `pending_review` 方案成为无人可见的孤儿记录。
+- 入选范围限定 `status IN ('draft','pending_review')`,按 `created_at` 倒序。
+
+> **注意**:`health_plans.teacher_id` 列由迁移 251 建立(`DatabaseInitializer`),`schema.sql` 已同步。
+> 前端 `getPendingReviewPlans()`(`utils/api.js`)目前尚无页面消费,规划师审核页未接通。
+
+---
+
+*文档最后更新:2026-10-04*