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

chore: sync origin/cfclub 并行特性(S-cfc-349/352+版本bump) — 仅分支同步,不部署

# Conflicts:
#	cfc-web/.last_build_commit
Xiaogang Liao 1 день назад
Родитель
Сommit
83a4a42e5f

+ 91 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyExportController.java

@@ -0,0 +1,91 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.common.RolesUtil;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import com.etotem.cfc.util.ParamUtils;
+import lombok.extern.slf4j.Slf4j;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户档案数据导出(合规,能力键 {@code teacher.client.export},SC-T-29)。
+ *
+ * <p><b>背景</b>:契约缺口 G-3——管理端组织成员 {@code AdminOrgMemberController} 仅
+ * list/add/remove/change-type,无 export;{@code UserController POST /api/user/data-export}
+ * 仅为登录用户本人的个保法数据可携带权导出({@code AccountCleanupService.exportUserData}),
+ * 不覆盖「规划师导出其名下客户档案」的机构视角批量导出。本控制器补全该缺口。</p>
+ *
+ * <p><b>入口</b>:{@code POST /api/family/export/client-archive}</p>
+ * <ul>
+ *   <li><b>导出范围显式入参</b>:body {@code {familyId?: Long}}——非空则仅导出该绑定家庭的客户
+ *       (越授权可拒,TC-T-29-E 边界流);为空则导出规划师全部绑定客户,无绑定客户时返回错误。</li>
+ *   <li><b>角色守卫</b>:teacher 角色(admin 兜底),授权源与 {@code isBound()} 同源
+ *       ({@code guide_families.status='binding'},SC-T-13 同源,TC-T-29-P 权限流)。</li>
+ *   <li><b>返回</b>:统一 {@link Result} 包装(失败 {@code Result.error},不裸抛异常)。</li>
+ *   <li><b>合规</b>:敏感字段(phone/idCard)强制脱敏({@code DataMaskUtil},
+ *       对齐 SC-S-11 {@code supplier.data.exportCompliance} 契约口径)。</li>
+ * </ul>
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/family/export")
+public class FamilyExportController {
+
+    /** 全项目唯一授权判定点:绑定关系(guide_families.status='binding')即导出授权源 */
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Resource
+    private GuideFamilyQueryService guideFamilyQueryService;
+
+    /**
+     * 规划师导出客户档案(批量/按家庭)。
+     *
+     * @param params  导出范围入参:{@code familyId} 可选(非空则仅导出该绑定家庭客户)
+     * @param userId  当前登录用户(JWT)
+     * @param roleObj 主角色(JWT role claim)
+     * @param roles   完整角色列表(JWT roles claim)
+     */
+    @PostMapping("/client-archive")
+    public Result<Map<String, Object>> exportClientArchive(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestAttribute(value = "role", required = false) Object roleObj,
+            @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (params == null) {
+            params = new HashMap<>();
+        }
+        String role = roleObj != null ? roleObj.toString() : null;
+
+        // 角色守卫:仅 teacher(规划师)或 admin(运营兜底)可导出(TC-T-29-P 权限流)
+        if (!RolesUtil.hasTeacherRole(role, roles) && !"admin".equals(role)) {
+            return Result.error(403, "无权导出客户档案");
+        }
+
+        // 越授权可拒:显式 familyId 须为本人绑定客户;admin 跳过归属校验(与 checkFamilyAccess 口径一致)
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        if (familyId != null && !"admin".equals(role)) {
+            Result<Void> denied = guideFamilyAccessGuard.checkBinding(userId, familyId);
+            if (denied != null) {
+                return Result.error(403, "无权导出该客户档案");
+            }
+        }
+
+        Map<String, Object> data = guideFamilyQueryService.exportClientArchive(userId, familyId);
+        // 空范围返回错误(TC-T-29-E 边界流:无绑定客户可导出)
+        if (data.isEmpty()) {
+            return Result.error("暂无可导出的客户档案");
+        }
+        return Result.success(data);
+    }
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AiCoachHandoffPlan.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.entity;
+
+import lombok.Data;
+
+/**
+ * AI 教练转接成长规划师计划(能力键 {@code ai.coach.handoffPlanner},SC-B-09)。
+ *
+ * <p>SC-B-09「教练识别专业信号 → 转接成长规划师」的编排结果载体:教练识别到
+ * 需专业干预的信号(持续异常/方案分歧/报告高风险)后,引导家长预约/发消息给
+ * 已绑定规划师并留痕;未绑定规划师时提示先绑定(SC-P-32);不可识别信号返回
+ * 空转接(NONE)。教练只负责转接引导,不代规划师给出诊断结论
+ * ({@code diagnosisIncluded} 恒为 false)。</p>
+ */
+@Data
+public class AiCoachHandoffPlan {
+
+    /** 转接动作:引导预约规划师(生成预约 SC-P-36)。 */
+    public static final String ACTION_APPOINTMENT = "APPOINTMENT";
+
+    /** 转接动作:引导发消息给规划师(SC-X-11)。 */
+    public static final String ACTION_MESSAGE = "MESSAGE";
+
+    /** 转接动作:未绑定规划师,提示先绑定(SC-P-32)。 */
+    public static final String ACTION_BIND_FIRST = "BIND_FIRST";
+
+    /** 转接动作:不可识别信号,空转接(不建预约/消息)。 */
+    public static final String ACTION_NONE = "NONE";
+
+    /** 需专业干预的信号类型(持续异常/方案分歧/报告高风险)。 */
+    public static final String SIGNAL_PROFESSIONAL = "PROFESSIONAL";
+
+    /** 转接动作(APPOINTMENT / MESSAGE / BIND_FIRST / NONE)。 */
+    private String action;
+
+    /** 已绑定场景下转接目标规划师 id;未绑定时为 null。 */
+    private Long plannerId;
+
+    /** 是否已留痕(转接动作留审计);未绑定时为 false。 */
+    private boolean recorded;
+
+    /** 是否包含规划师诊断结论——教练不代诊断,恒为 false。 */
+    private boolean diagnosisIncluded;
+}

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiCoachHandoffService.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.AiCoachHandoffPlan;
+import org.springframework.stereotype.Service;
+
+/**
+ * AI 教练转接成长规划师编排服务(能力键 {@code ai.coach.handoffPlanner},SC-B-09)。
+ *
+ * <p><b>需求(SC-B-09)</b>:AI 成长教练识别到需专业干预的信号(持续异常/方案分歧/
+ * 报告高风险)后,引导家长预约/发消息给已绑定规划师并留痕;未绑定规划师时提示先绑定
+ * (SC-P-32);不可识别信号不转接(NONE)。教练不代规划师给出诊断结论。</p>
+ *
+ * <p><b>转接载体</b>:实际转接引导由已落地的规划师绑定流
+ * {@code BindInviteController /api/guide/bind/confirm} + {@code BindController /api/bind/accept}
+ * 承担(家庭先绑定规划师);本服务负责「信号识别 → 转接计划编排」,供教练对话侧决策。</p>
+ */
+@Service
+public class AiCoachHandoffService {
+
+    /** 需专业干预的信号(PROFESSIONAL:持续异常/方案分歧/报告高风险)。 */
+    private static final String SIGNAL_PROFESSIONAL = "PROFESSIONAL";
+
+    /**
+     * 识别信号类型是否需要专业干预(转接规划师)。
+     *
+     * @param signalType 信号类型(PROFESSIONAL 等)
+     * @return true 表示需转接规划师;不可识别类型返回 false
+     */
+    public boolean isHandoffSignal(String signalType) {
+        return SIGNAL_PROFESSIONAL.equals(signalType);
+    }
+
+    /**
+     * 编排转接计划:按「信号是否可识别 + 是否已绑定规划师」决策转接动作。
+     *
+     * <ul>
+     *   <li>不可识别信号 → {@code NONE} 空转接(不建预约/消息)</li>
+     *   <li>已识别但未绑定规划师(boundPlannerId 为空)→ {@code BIND_FIRST} 提示先绑定(SC-P-32)</li>
+     *   <li>已识别且已绑定 → 生成预约(APPOINTMENT)或发消息(MESSAGE)引导并留痕</li>
+     * </ul>
+     *
+     * @param userId          当前用户 id
+     * @param familyId        家庭 id
+     * @param signalType      信号类型
+     * @param boundPlannerId  已绑定规划师 id(未绑定为 null)
+     * @param requestedAction 期望动作(APPOINTMENT / MESSAGE;为空默认 MESSAGE)
+     * @return 转接计划(恒非空,诊断结论恒不包含)
+     */
+    public AiCoachHandoffPlan planHandoff(Long userId, Long familyId, String signalType,
+                                          Long boundPlannerId, String requestedAction) {
+        if (!isHandoffSignal(signalType)) {
+            return plan(AiCoachHandoffPlan.ACTION_NONE, null, false);
+        }
+        if (boundPlannerId == null) {
+            return plan(AiCoachHandoffPlan.ACTION_BIND_FIRST, null, false);
+        }
+        String action = requestedAction != null ? requestedAction : AiCoachHandoffPlan.ACTION_MESSAGE;
+        return plan(action, boundPlannerId, true);
+    }
+
+    private AiCoachHandoffPlan plan(String action, Long plannerId, boolean recorded) {
+        AiCoachHandoffPlan p = new AiCoachHandoffPlan();
+        p.setAction(action);
+        p.setPlannerId(plannerId);
+        p.setRecorded(recorded);
+        p.setDiagnosisIncluded(false);
+        return p;
+    }
+}

+ 109 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.common.DataMaskUtil;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.GuideFamily;
@@ -92,4 +93,112 @@ public class GuideFamilyQueryService {
         return familyMemberMapper.selectList(
                 new QueryWrapper<FamilyMember>().eq("family_id", familyId));
     }
+
+    /**
+     * 客户档案导出(合规,sc-key-map SC-T-29 → 能力键 {@code teacher.client.export}):
+     * 导出规划师名下绑定家庭({@code guide_families.status='binding'},与 {@code isBound()} 同源授权)的客户档案。
+     *
+     * <p><b>导出范围显式入参</b>:{@code familyId} 非空 → 仅导出该绑定家庭的客户(越授权拦截由调用方守卫
+     * {@code GuideFamilyAccessGuard#checkBinding} 负责);为 null → 导出全部绑定客户。</p>
+     *
+     * <p><b>合规口径</b>:敏感字段 {@code phone/idCard} 强制脱敏({@link DataMaskUtil},对齐 SC-S-11
+     * {@code supplier.data.exportCompliance} 契约口径,个保法最小化)。</p>
+     *
+     * <p><b>历史约束</b>:不依赖 {@code children} 表全字段快照(生产缺 {@code phone/id_card} 两列,
+     * 见根 AGENTS.md「children 表标注冲突」),统一经 {@code family_members} 聚合导出。</p>
+     *
+     * @param guideId  规划师(teacher)userId
+     * @param familyId 可选导出范围(家庭ID);null 导出全部绑定客户
+     * @return 导出包:{@code {families:[{familyId, familyName, serviceType, boundAt, clients:[{memberId, nickname,
+     *         phone(脱敏), idCard(脱敏), gender, age, birthday, memberType}]}]}};无绑定客户时返回空 Map
+     */
+    public Map<String, Object> exportClientArchive(Long guideId, Long familyId) {
+        Map<String, Object> out = new HashMap<>();
+        List<GuideFamily> bindings;
+        if (familyId != null) {
+            bindings = guideFamilyMapper.selectList(
+                    new QueryWrapper<GuideFamily>()
+                            .eq("guide_id", guideId)
+                            .eq("family_id", familyId)
+                            .eq("status", "binding"));
+        } else {
+            bindings = guideFamilyMapper.selectList(
+                    new QueryWrapper<GuideFamily>()
+                            .eq("guide_id", guideId)
+                            .eq("status", "binding")
+                            .orderByDesc("bound_at"));
+        }
+        if (bindings == null || bindings.isEmpty()) {
+            return out;
+        }
+
+        List<Long> familyIds = bindings.stream()
+                .map(GuideFamily::getFamilyId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<Long, String> nameByFamily = new HashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<Family> families = familyMapper.selectBatchIds(familyIds);
+            if (families != null) {
+                for (Family f : families) {
+                    if (f != null && f.getId() != null) {
+                        nameByFamily.put(f.getId(), f.getName());
+                    }
+                }
+            }
+        }
+
+        Map<Long, List<FamilyMember>> membersByFamily = new HashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new QueryWrapper<FamilyMember>().in("family_id", familyIds));
+            if (members != null) {
+                for (FamilyMember m : members) {
+                    if (m == null || m.getFamilyId() == null) {
+                        continue;
+                    }
+                    membersByFamily.computeIfAbsent(m.getFamilyId(), k -> new ArrayList<>()).add(m);
+                }
+            }
+        }
+
+        List<Map<String, Object>> families = new ArrayList<>();
+        for (GuideFamily gf : bindings) {
+            Long fid = gf.getFamilyId();
+            if (fid == null) {
+                continue;
+            }
+            Map<String, Object> familyRow = new HashMap<>();
+            familyRow.put("familyId", fid);
+            familyRow.put("familyName", nameByFamily.get(fid));
+            familyRow.put("serviceType", gf.getServiceType());
+            familyRow.put("boundAt", gf.getBoundAt());
+            List<Map<String, Object>> clients = new ArrayList<>();
+            List<FamilyMember> mbrs = membersByFamily.getOrDefault(fid, Collections.emptyList());
+            for (FamilyMember m : mbrs) {
+                clients.add(toMaskedClient(m));
+            }
+            familyRow.put("clients", clients);
+            families.add(familyRow);
+        }
+        out.put("families", families);
+        return out;
+    }
+
+    /** 客户档案单行导出载体:敏感字段(phone/idCard)强制脱敏。 */
+    private Map<String, Object> toMaskedClient(FamilyMember m) {
+        Map<String, Object> client = new HashMap<>();
+        client.put("memberId", m.getId());
+        client.put("nickname", m.getNickname());
+        client.put("gender", m.getGender());
+        client.put("age", m.getAge());
+        client.put("birthday", m.getBirthday());
+        client.put("memberType", m.getMemberType());
+        // 合规:个保法最小化,对齐 SC-S-11 supplier.data.exportCompliance 契约口径
+        client.put("phone", DataMaskUtil.maskPhone(m.getPhone()));
+        client.put("idCard", DataMaskUtil.maskIdCard(m.getIdCard()));
+        return client;
+    }
 }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d25e65c1d7d3b1124f4ebcdfdc88ea8a13b68814
+8496aafdfd92bf7d6d85a36e3a344ed73685b2ac

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,23 @@
 
 ---
 
+## v1.0.2486 (2026-11-14)
+
+### 新功能
+- S-cfc-352 实现教练转接成长规划师编排服务使绿
+
+### 其他
+- 新增宿主实现:
+- - AiCoachHandoffService: isHandoffSignal(信号识别) + planHandoff(转接计划编排)
+-   - 已绑定规划师 -> APPOINTMENT/MESSAGE 引导 + 留痕(recorded=true)
+-   - 未绑定 -> BIND_FIRST 提示先绑(SC-P-32)
+-   - 不可识别信号 -> NONE 空转接
+-   - 不代规划师诊断(diagnosisIncluded=false)
+- - AiCoachHandoffPlan: 转接计划载体(动作/规划师id/留痕/诊断标志)
+- AiCoachHandoffContractTest: 10/10 全绿(红8 -> 绿10)。
+- 
+
+
 ## v1.0.2485 (2026-11-14)
 
 ### 文档

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2485
+> 当前版本: v1.0.2486
 
 ## 历史版本
 
@@ -8,6 +8,23 @@
 
 ---
 
+## v1.0.2486 (2026-11-14)
+
+### 新功能
+- S-cfc-352 实现教练转接成长规划师编排服务使绿
+
+### 其他
+- 新增宿主实现:
+- - AiCoachHandoffService: isHandoffSignal(信号识别) + planHandoff(转接计划编排)
+-   - 已绑定规划师 -> APPOINTMENT/MESSAGE 引导 + 留痕(recorded=true)
+-   - 未绑定 -> BIND_FIRST 提示先绑(SC-P-32)
+-   - 不可识别信号 -> NONE 空转接
+-   - 不代规划师诊断(diagnosisIncluded=false)
+- - AiCoachHandoffPlan: 转接计划载体(动作/规划师id/留痕/诊断标志)
+- AiCoachHandoffContractTest: 10/10 全绿(红8 -> 绿10)。
+- 
+
+
 ## v1.0.2485 (2026-11-14)
 
 ### 文档