Преглед изворни кода

feat(butler): butler.quality.qa 契约转绿——管家质检/满意度/绩效链路(S-cfc-355 / SC-H-07)

- 新增 ButlerQualityService.inspect 质检入口:抽样/全量质检(SLA/合规/态度)逐项通过/不通过与依据,脱敏摘要不展示隐私原文
- 新增 ButlerQualityService.triggerRetrospect 差评复盘入口:幂等标记,关联 SC-H-04 升级应急流程
- 新增 ButlerSatisfactionService.submitSatisfaction 评分提交:家长-管家评分 upsert 幂等以最后一次为准;差评自动触发复盘
- 新增 ButlerPerformanceService 绩效看板入口:质检+满意度按月聚合回流
- ButlerQualityInspection(inspectionItems/desensitizedSummary)、ButlerSatisfaction(rating/badReviewTriggered)、ButlerPerformance 实体+3 Mapper
- DatabaseInitializer 迁移480(幂等 CREATE TABLE)+ schema.sql 同步 butler_quality_inspections/butler_satisfaction/butler_performance
- ButlerQualityQaContractTest:11 红 → 12/12 全绿
E2E Test Bot пре 1 дан
родитељ
комит
aebf211c77

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

@@ -3555,6 +3555,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移479: assessment_materials 加测评类型码列 dimension(能力键 assessment.type.browse,S-cfc-368 / SC-P-31):
         // 材料作为「类型载体」,测评页从后端字典拉取 DAN 8 类(类目来自后端字典)
         addAssessmentMaterialTypeColumn();
+
+        // 迁移480: 真人管家服务质检与满意度链路三表(butler.quality.qa · SC-H-07 质检→满意度回访→回流改进)
+        createButlerQualityTables();
     }
 
     /**
@@ -3670,6 +3673,83 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移480: 真人管家服务质检与满意度链路三表(能力键 {@code butler.quality.qa},SC-H-07)。
+     *
+     * <p>流程:质检({@code butler_quality_inspections} 逐项通过/不通过与依据)→ 满意度回访
+     * ({@code butler_satisfaction} 评分提交,幂等以最后一次为准)→ 回流改进
+     * ({@code butler_performance} 按月聚合质检+满意度,绩效看板仅授权角色可见)。
+     * 边界:质检不展示隐私对话原文;差评触发复盘标记(关联 SC-H-04)。
+     * 幂等:CREATE TABLE IF NOT EXISTS。</p>
+     */
+    private void createButlerQualityTables() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_quality_inspections ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', "
+                    + "butler_user_id BIGINT NOT NULL COMMENT '被质检管家用户ID', "
+                    + "session_id BIGINT NULL COMMENT '质检对象: 会话ID(butler_sessions.id)', "
+                    + "service_record_id BIGINT NULL COMMENT '质检对象: 工单/服务记录ID(butler_service_records.id)', "
+                    + "inspection_type VARCHAR(20) DEFAULT 'sample' COMMENT '质检方式: sample(抽样)/full(全量)', "
+                    + "inspection_items TEXT COMMENT '逐项质检结果JSON: [{\"item\":\"SLA\",\"passed\":true,\"basis\":\"依据\"}]', "
+                    + "passed_count INT DEFAULT 0 COMMENT '通过项数', "
+                    + "failed_count INT DEFAULT 0 COMMENT '未通过项数', "
+                    + "desensitized_summary TEXT COMMENT '脱敏摘要与判定依据(不展示隐私对话原文)', "
+                    + "status VARCHAR(20) DEFAULT 'passed' COMMENT '质检结论: passed/failed', "
+                    + "inspector_id BIGINT NULL COMMENT '质检员用户ID', "
+                    + "inspected_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '质检时间', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', "
+                    + "INDEX idx_butler_id (butler_user_id), "
+                    + "INDEX idx_inspected_at (inspected_at)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真人管家服务质检记录(SC-H-07)'");
+            log.info("迁移480: butler_quality_inspections 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移480: butler_quality_inspections 创建失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_satisfaction ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', "
+                    + "family_id BIGINT NOT NULL COMMENT '家庭ID', "
+                    + "member_user_id BIGINT NOT NULL COMMENT '评分家长用户ID(能登录用户)', "
+                    + "butler_user_id BIGINT NOT NULL COMMENT '被评管家用户ID', "
+                    + "survey_id BIGINT NULL COMMENT '满意度问卷模板ID', "
+                    + "rating INT NOT NULL COMMENT '满意度评分(1-5)', "
+                    + "comment TEXT COMMENT '评价意见', "
+                    + "bad_review_triggered TINYINT(1) DEFAULT 0 COMMENT '差评是否已触发复盘(0/1,幂等标记)', "
+                    + "retrospect_ref VARCHAR(100) COMMENT '复盘关联引用(关联SC-H-04升级应急流程)', "
+                    + "submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', "
+                    + "UNIQUE KEY uk_member_butler (member_user_id, butler_user_id), "
+                    + "INDEX idx_butler_id (butler_user_id), "
+                    + "INDEX idx_family_id (family_id)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真人管家满意度评分(SC-H-07)'");
+            log.info("迁移480: butler_satisfaction 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移480: butler_satisfaction 创建失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_performance ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', "
+                    + "butler_user_id BIGINT NOT NULL COMMENT '管家用户ID', "
+                    + "year_month VARCHAR(7) NOT NULL COMMENT '绩效周期(yyyy-MM)', "
+                    + "inspected_count INT DEFAULT 0 COMMENT '质检单数', "
+                    + "pass_rate DECIMAL(5,2) DEFAULT 0 COMMENT '质检通过率(%)', "
+                    + "satisfaction_count INT DEFAULT 0 COMMENT '满意度评分单数', "
+                    + "avg_rating DECIMAL(3,2) DEFAULT 0 COMMENT '平均评分(1-5)', "
+                    + "bad_review_count INT DEFAULT 0 COMMENT '差评数(已触发复盘)', "
+                    + "performance_score DECIMAL(5,2) DEFAULT 0 COMMENT '绩效综合分(0-100)', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', "
+                    + "UNIQUE KEY uk_butler_month (butler_user_id, year_month), "
+                    + "INDEX idx_year_month (year_month)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真人管家绩效看板(SC-H-07)'");
+            log.info("迁移480: butler_performance 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移480: butler_performance 创建失败: {}", e.getMessage());
+        }
+    }
+
     /** 迁移473(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */
     void addTasksFamilyCreatedIndex() {
         try {

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerPerformance.java

@@ -0,0 +1,52 @@
+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;
+
+/**
+ * 真人管家绩效看板数据(能力键 {@code butler.quality.qa},SC-H-07 回流改进)。
+ *
+ * <p>质检结果与满意度评分按月回流至管家绩效({@code uk_butler_month} 每管家每月一行),
+ * 供绩效看板展示;绩效看板仅授权角色可见(权限控制在 Controller 层,TC-H-07-P)。</p>
+ */
+@Data
+@TableName("butler_performance")
+public class ButlerPerformance implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 管家用户 ID */
+    private Long butlerUserId;
+
+    /** 绩效周期(yyyy-MM) */
+    private String yearMonth;
+
+    /** 质检单数 */
+    private Integer inspectedCount;
+
+    /** 质检通过率(%,通过项数/总项数) */
+    private BigDecimal passRate;
+
+    /** 满意度评分单数 */
+    private Integer satisfactionCount;
+
+    /** 平均评分(1-5) */
+    private BigDecimal avgRating;
+
+    /** 差评数(badReviewTriggered=1 的评分单数) */
+    private Integer badReviewCount;
+
+    /** 绩效综合分(0-100,按质检/满意度加权) */
+    private BigDecimal performanceScore;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerQualityInspection.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 真人管家服务质检记录(能力键 {@code butler.quality.qa},SC-H-07)。
+ *
+ * <p>抽样/全量质检真人管家会话与工单(SLA、合规、态度),逐项给出通过/不通过与依据
+ * ({@link #inspectionItems},JSON 数组);质检报告仅展示脱敏摘要与判定依据
+ * ({@link #desensitizedSummary}),不展示家长隐私对话原文(SC-H-07 边界)。
+ * 质检结果回流至管家绩效看板({@code butler_performance})。</p>
+ */
+@Data
+@TableName("butler_quality_inspections")
+public class ButlerQualityInspection implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 被质检管家用户 ID */
+    private Long butlerUserId;
+
+    /** 质检对象:真人管家会话 ID(butler_sessions.id,可空) */
+    private Long sessionId;
+
+    /** 质检对象:工单/服务记录 ID(butler_service_records.id,可空) */
+    private Long serviceRecordId;
+
+    /** 质检方式:sample(抽样)/full(全量) */
+    private String inspectionType;
+
+    /** 逐项质检结果 JSON:[{"item":"SLA","passed":true,"basis":"..."}, ...](SC-H-07 逐项通过/不通过与依据) */
+    private String inspectionItems;
+
+    /** 通过项数(据 inspectionItems 统计) */
+    private Integer passedCount;
+
+    /** 未通过项数(据 inspectionItems 统计) */
+    private Integer failedCount;
+
+    /** 脱敏摘要与判定依据(不展示隐私对话原文,SC-H-07 边界) */
+    private String desensitizedSummary;
+
+    /** 质检结论:passed(全部通过)/failed(存在未通过项) */
+    private String status;
+
+    /** 质检员用户 ID */
+    private Long inspectorId;
+
+    /** 质检时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date inspectedAt;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerSatisfaction.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 真人管家满意度评分(能力键 {@code butler.quality.qa},SC-H-07)。
+ *
+ * <p>向家长发起满意度回访(问卷/评分),家长提交评分;同一家长对同一管家重复提交评分
+ * 以最后一次为准(幂等,不重复计数,按 {@code uk_member_butler} 唯一键 upsert)。
+ * 差评(评分 ≤ {@code ButlerQualityService.BAD_REVIEW_THRESHOLD})自动触发复盘并关联
+ * SC-H-04 升级应急流程({@link #badReviewTriggered} 幂等标记,只触发一次)。</p>
+ */
+@Data
+@TableName("butler_satisfaction")
+public class ButlerSatisfaction implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭 ID */
+    private Long familyId;
+
+    /** 评分家长用户 ID(能登录用户 users.id) */
+    private Long memberUserId;
+
+    /** 被评管家用户 ID */
+    private Long butlerUserId;
+
+    /** 满意度问卷模板 ID(survey 模板,可空) */
+    private Long surveyId;
+
+    /** 满意度评分(1-5) */
+    private Integer rating;
+
+    /** 评价意见 */
+    private String comment;
+
+    /** 差评是否已触发复盘(0/1,幂等标记,重复提交不重复触发、不重复计数,SC-H-07 异常流) */
+    private Integer badReviewTriggered;
+
+    /** 复盘关联引用(关联 SC-H-04 升级应急流程) */
+    private String retrospectRef;
+
+    /** 提交时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date submittedAt;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerPerformanceMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ButlerPerformance;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ButlerPerformanceMapper extends BaseMapper<ButlerPerformance> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerQualityInspectionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ButlerQualityInspection;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ButlerQualityInspectionMapper extends BaseMapper<ButlerQualityInspection> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerSatisfactionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ButlerSatisfaction;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ButlerSatisfactionMapper extends BaseMapper<ButlerSatisfaction> {
+}

+ 158 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerPerformanceService.java

@@ -0,0 +1,158 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ButlerPerformance;
+import com.etotem.cfc.entity.ButlerQualityInspection;
+import com.etotem.cfc.entity.ButlerSatisfaction;
+import com.etotem.cfc.mapper.ButlerPerformanceMapper;
+import com.etotem.cfc.mapper.ButlerQualityInspectionMapper;
+import com.etotem.cfc.mapper.ButlerSatisfactionMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 真人管家绩效看板(能力键 {@code butler.quality.qa},SC-H-07 回流改进)。
+ *
+ * <p>质检结果与满意度评分回流至管家绩效看板({@code butler_performance},每管家每月一行);
+ * 绩效综合分按 质检通过率 50% + 满意度评分 50% 加权,差评数量计入扣减。
+ * 看板仅授权角色可见(权限控制在 Controller 层,TC-H-07-P)。</p>
+ */
+@Slf4j
+@Service
+public class ButlerPerformanceService {
+
+    private static final DateTimeFormatter YEAR_MONTH = DateTimeFormatter.ofPattern("yyyy-MM");
+
+    @Resource
+    private ButlerPerformanceMapper performanceMapper;
+
+    @Resource
+    private ButlerQualityInspectionMapper qualityInspectionMapper;
+
+    @Resource
+    private ButlerSatisfactionMapper satisfactionMapper;
+
+    /**
+     * 绩效看板入口:查询管家某自然月绩效(不存在时按质检/满意度回流聚合生成)。
+     *
+     * @param butlerUserId 管家用户 ID
+     * @param yearMonth    绩效周期(yyyy-MM,空则取当前月)
+     */
+    public Result<ButlerPerformance> getPerformance(Long butlerUserId, String yearMonth) {
+        if (butlerUserId == null) {
+            return Result.badRequest("管家用户 ID 不能为空");
+        }
+        String month = yearMonth != null && !yearMonth.trim().isEmpty() ? yearMonth.trim()
+                : LocalDate.now().format(YEAR_MONTH);
+        ButlerPerformance existing = performanceMapper.selectOne(new LambdaQueryWrapper<ButlerPerformance>()
+                .eq(ButlerPerformance::getButlerUserId, butlerUserId)
+                .eq(ButlerPerformance::getYearMonth, month)
+                .last("LIMIT 1"));
+        if (existing != null) {
+            return Result.success(existing);
+        }
+        return Result.success(refresh(butlerUserId, month));
+    }
+
+    /**
+     * 回流改进入口:将质检与满意度结果按月聚合回流至绩效看板(SC-H-07「结果回流」)。
+     *
+     * <p>幂等:每管家每月仅一行(uk_butler_month),重复回流更新聚合值。</p>
+     */
+    @Transactional
+    public ButlerPerformance refresh(Long butlerUserId, Date date) {
+        if (date == null) {
+            date = new Date();
+        }
+        LocalDate localDate = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()).toLocalDate();
+        return refresh(butlerUserId, localDate.format(YEAR_MONTH));
+    }
+
+    @Transactional
+    ButlerPerformance refresh(Long butlerUserId, String yearMonth) {
+        LocalDate monthStart = LocalDate.parse(yearMonth + "-01");
+        LocalDate nextMonth = monthStart.plusMonths(1);
+        Date start = toDate(monthStart.atStartOfDay());
+        Date end = toDate(nextMonth.atStartOfDay());
+
+        // 质检聚合:按检验时间落在当月
+        List<ButlerQualityInspection> inspections = qualityInspectionMapper.selectList(
+                new LambdaQueryWrapper<ButlerQualityInspection>()
+                        .eq(ButlerQualityInspection::getButlerUserId, butlerUserId)
+                        .ge(ButlerQualityInspection::getInspectedAt, start)
+                        .lt(ButlerQualityInspection::getInspectedAt, end));
+        int inspectedCount = inspections.size();
+        int totalItems = inspections.stream()
+                .mapToInt(i -> (i.getPassedCount() == null ? 0 : i.getPassedCount())
+                        + (i.getFailedCount() == null ? 0 : i.getFailedCount()))
+                .sum();
+        int passedItems = inspections.stream()
+                .mapToInt(i -> i.getPassedCount() == null ? 0 : i.getPassedCount())
+                .sum();
+        BigDecimal passRate = totalItems > 0
+                ? BigDecimal.valueOf(passedItems * 100.0 / totalItems).setScale(2, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+
+        // 满意度聚合:按提交时间落在当月
+        List<ButlerSatisfaction> satisfactions = satisfactionMapper.selectList(
+                new LambdaQueryWrapper<ButlerSatisfaction>()
+                        .eq(ButlerSatisfaction::getButlerUserId, butlerUserId)
+                        .ge(ButlerSatisfaction::getSubmittedAt, start)
+                        .lt(ButlerSatisfaction::getSubmittedAt, end));
+        int satisfactionCount = satisfactions.size();
+        BigDecimal avgRating = satisfactions.isEmpty() ? BigDecimal.ZERO
+                : BigDecimal.valueOf(satisfactions.stream()
+                        .mapToInt(s -> s.getRating() == null ? 0 : s.getRating())
+                        .average().orElse(0.0))
+                .setScale(2, RoundingMode.HALF_UP);
+        int badReviewCount = (int) satisfactions.stream()
+                .filter(s -> s.getBadReviewTriggered() != null && s.getBadReviewTriggered() == 1)
+                .count();
+
+        // 绩效综合分:质检通过率 50% + 满意度评分均分折百分 50%,差评每单 -5 分,下限 0
+        double score = passRate.doubleValue() * 0.5 + (avgRating.doubleValue() / 5.0 * 100.0) * 0.5;
+        score = Math.max(0, score - badReviewCount * 5.0);
+        BigDecimal performanceScore = BigDecimal.valueOf(score).setScale(2, RoundingMode.HALF_UP);
+
+        ButlerPerformance performance = performanceMapper.selectOne(new LambdaQueryWrapper<ButlerPerformance>()
+                .eq(ButlerPerformance::getButlerUserId, butlerUserId)
+                .eq(ButlerPerformance::getYearMonth, yearMonth)
+                .last("LIMIT 1"));
+        Date now = new Date();
+        if (performance == null) {
+            performance = new ButlerPerformance();
+            performance.setButlerUserId(butlerUserId);
+            performance.setYearMonth(yearMonth);
+            performance.setCreatedAt(now);
+        }
+        performance.setInspectedCount(inspectedCount);
+        performance.setPassRate(passRate);
+        performance.setSatisfactionCount(satisfactionCount);
+        performance.setAvgRating(avgRating);
+        performance.setBadReviewCount(badReviewCount);
+        performance.setPerformanceScore(performanceScore);
+        performance.setUpdatedAt(now);
+        if (performance.getId() == null) {
+            performanceMapper.insert(performance);
+        } else {
+            performanceMapper.updateById(performance);
+        }
+        return performance;
+    }
+
+    private Date toDate(LocalDateTime localDateTime) {
+        return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+    }
+}

+ 210 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerQualityService.java

@@ -0,0 +1,210 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ButlerQualityInspection;
+import com.etotem.cfc.entity.ButlerSatisfaction;
+import com.etotem.cfc.mapper.ButlerQualityInspectionMapper;
+import com.etotem.cfc.mapper.ButlerSatisfactionMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 真人管家服务质检与差评复盘(能力键 {@code butler.quality.qa},SC-H-07)。
+ *
+ * <p>正常流:抽样/全量质检真人管家会话与工单(SLA、合规、态度)→ 质检报告逐项给出通过/不通过与依据;
+ * 边界流:质检报告不展示家长隐私对话原文,仅展示脱敏摘要与判定依据;
+ * 差评自动触发复盘并关联 SC-H-04 升级应急流程(复盘入口 {@link #triggerRetrospect})。</p>
+ *
+ * <p>质检项以 JSON 数组存 {@link ButlerQualityInspection#getInspectionItems()},
+ * 每项形如 {@code {"item":"SLA","passed":true,"basis":"首次响应 5 分钟内达标"}}。</p>
+ */
+@Slf4j
+@Service
+public class ButlerQualityService {
+
+    /** 差评评分阈值:评分 ≤ 该值视为差评,自动触发复盘(SC-H-07 异常流) */
+    public static final int BAD_REVIEW_RATING_THRESHOLD = 2;
+
+    /** 复盘关联引用前缀(关联 SC-H-04 升级应急流程) */
+    private static final String RETROSPECT_REF_PREFIX = "SC-H-04-RETROSPECT-";
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Resource
+    private ButlerQualityInspectionMapper qualityInspectionMapper;
+
+    @Resource
+    private ButlerSatisfactionMapper satisfactionMapper;
+
+    /**
+     * 质检执行入口(SC-H-07 正常流步骤1):对指定管家会话/工单执行抽样或全量质检。
+     *
+     * <p>逐项给出通过/不通过与依据并落库;仅保存脱敏摘要与判定依据,
+     * <b>不写入家长隐私对话原文</b>(SC-H-07 边界)。</p>
+     *
+     * @param butlerUserId       被质检管家用户 ID
+     * @param sessionId          质检对象会话 ID(可空)
+     * @param serviceRecordId    质检对象工单/服务记录 ID(可空)
+     * @param inspectionType     质检方式:sample(抽样)/full(全量),空则按 sample
+     * @param inspectionItems    逐项质检结果,每项含 item(维度)/passed(通过与否)/basis(依据)
+     * @param desensitizedSummary 脱敏摘要(留空则自动按质检项生成)
+     */
+    @Transactional
+    public Result<ButlerQualityInspection> inspect(Long butlerUserId,
+                                                   Long sessionId,
+                                                   Long serviceRecordId,
+                                                   String inspectionType,
+                                                   List<Map<String, Object>> inspectionItems,
+                                                   String desensitizedSummary) {
+        if (butlerUserId == null) {
+            return Result.badRequest("被质检管家不能为空");
+        }
+        if (sessionId == null && serviceRecordId == null) {
+            return Result.badRequest("质检对象不能为空(会话或工单至少提供一个)");
+        }
+
+        List<Map<String, Object>> items = inspectionItems != null ? inspectionItems : Collections.emptyList();
+        int passedCount = 0;
+        for (Map<String, Object> item : items) {
+            if (Boolean.TRUE.equals(toBoolean(item.get("passed")))) {
+                passedCount++;
+            }
+        }
+        int failedCount = items.size() - passedCount;
+
+        ButlerQualityInspection inspection = new ButlerQualityInspection();
+        inspection.setButlerUserId(butlerUserId);
+        inspection.setSessionId(sessionId);
+        inspection.setServiceRecordId(serviceRecordId);
+        inspection.setInspectionType(inspectionType != null && !inspectionType.trim().isEmpty()
+                ? inspectionType.trim() : "sample");
+        inspection.setInspectionItems(toJson(items));
+        inspection.setPassedCount(passedCount);
+        inspection.setFailedCount(failedCount);
+        inspection.setDesensitizedSummary(desensitizedSummary != null && !desensitizedSummary.trim().isEmpty()
+                ? desensitizedSummary.trim()
+                : buildDefaultSummary(items, passedCount, failedCount));
+        inspection.setStatus(failedCount == 0 ? "passed" : "failed");
+        Date now = new Date();
+        inspection.setInspectedAt(now);
+        inspection.setCreatedAt(now);
+        inspection.setUpdatedAt(now);
+        qualityInspectionMapper.insert(inspection);
+        return Result.success(inspection);
+    }
+
+    /**
+     * 差评触发复盘入口(SC-H-07 异常流步骤2):差评自动触发复盘并关联 SC-H-04 升级应急流程。
+     *
+     * <p>幂等:同一条满意度评分仅触发一次复盘({@code badReviewTriggered} 标记),
+     * 重复调用不重复计数、不重复生成复盘引用。</p>
+     *
+     * @param satisfactionId 满意度评分 ID
+     * @return 复盘关联引用(未触发或非差评时返回 null)
+     */
+    @Transactional
+    public Result<String> triggerRetrospect(Long satisfactionId) {
+        if (satisfactionId == null) {
+            return Result.badRequest("满意度评分 ID 不能为空");
+        }
+        ButlerSatisfaction satisfaction = satisfactionMapper.selectById(satisfactionId);
+        if (satisfaction == null) {
+            return Result.notFound("满意度评分不存在");
+        }
+        // 非差评不触发复盘
+        if (satisfaction.getRating() == null
+                || satisfaction.getRating() > BAD_REVIEW_RATING_THRESHOLD) {
+            return Result.success(null);
+        }
+        // 幂等:已触发过复盘则直接返回既有引用,不重复计数
+        if (satisfaction.getBadReviewTriggered() != null && satisfaction.getBadReviewTriggered() == 1) {
+            return Result.success(satisfaction.getRetrospectRef());
+        }
+        String retrospectRef = RETROSPECT_REF_PREFIX + satisfactionId;
+        satisfaction.setBadReviewTriggered(1);
+        satisfaction.setRetrospectRef(retrospectRef);
+        satisfaction.setUpdatedAt(new Date());
+        satisfactionMapper.updateById(satisfaction);
+        log.info("差评触发复盘:satisfactionId={}, butlerUserId={}, ref={}",
+                satisfactionId, satisfaction.getButlerUserId(), retrospectRef);
+        return Result.success(retrospectRef);
+    }
+
+    /**
+     * 查询某管家的质检记录(仅返回脱敏摘要与逐项依据,不含隐私对话原文)。
+     */
+    public Result<List<ButlerQualityInspection>> listInspections(Long butlerUserId, Long sessionId) {
+        LambdaQueryWrapper<ButlerQualityInspection> wrapper = new LambdaQueryWrapper<ButlerQualityInspection>()
+                .eq(ButlerQualityInspection::getButlerUserId, butlerUserId)
+                .orderByDesc(ButlerQualityInspection::getInspectedAt);
+        if (sessionId != null) {
+            wrapper.eq(ButlerQualityInspection::getSessionId, sessionId);
+        }
+        return Result.success(qualityInspectionMapper.selectList(wrapper));
+    }
+
+    // ------------------------------------------------------------------
+    // 内部辅助
+    // ------------------------------------------------------------------
+
+    /** 默认脱敏摘要:仅汇总质检维度与判定结果,不含任何对话原文。 */
+    private String buildDefaultSummary(List<Map<String, Object>> items, int passedCount, int failedCount) {
+        List<String> failedDimensions = new ArrayList<>();
+        for (Map<String, Object> item : items) {
+            if (!Boolean.TRUE.equals(toBoolean(item.get("passed")))) {
+                String dimension = asString(item.get("item"));
+                failedDimensions.add(dimension != null ? dimension : "未命名维度");
+            }
+        }
+        StringBuilder summary = new StringBuilder("质检共 ").append(items.size())
+                .append(" 项,通过 ").append(passedCount).append(" 项,未通过 ").append(failedCount).append(" 项");
+        if (!failedDimensions.isEmpty()) {
+            summary.append(";未通过维度:").append(String.join("、", failedDimensions));
+        }
+        summary.append("(仅展示脱敏摘要与判定依据)");
+        return summary.toString();
+    }
+
+    private String toJson(Object value) {
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            log.warn("质检项序列化失败,回退为空数组: {}", e.getMessage());
+            return "[]";
+        }
+    }
+
+    private Boolean toBoolean(Object value) {
+        if (value instanceof Boolean) {
+            return (Boolean) value;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue() != 0;
+        }
+        return value != null && "true".equalsIgnoreCase(value.toString().trim());
+    }
+
+    private String asString(Object value) {
+        return value != null ? value.toString() : null;
+    }
+
+    /** 质检维度常量(SLA/合规/态度),供上层构造质检项使用。 */
+    public static Map<String, Object> buildInspectionItem(String dimension, boolean passed, String basis) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("item", dimension);
+        item.put("passed", passed);
+        item.put("basis", basis);
+        return item;
+    }
+}

+ 120 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerSatisfactionService.java

@@ -0,0 +1,120 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ButlerSatisfaction;
+import com.etotem.cfc.mapper.ButlerSatisfactionMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 真人管家满意度回访与评分提交(能力键 {@code butler.quality.qa},SC-H-07)。
+ *
+ * <p>正常流:向家长发起满意度回访(问卷/评分)→ 家长提交评分并成功;
+ * 边界流(异常流):同一家长对同一管家重复提交评分<b>以最后一次为准</b>(幂等,
+ * 不重复计数,SC-H-07);差评(评分 ≤ {@link ButlerQualityService#BAD_REVIEW_RATING_THRESHOLD})
+ * 自动触发复查复盘({@code ButlerQualityService#triggerRetrospect},幂等只触发一次)。</p>
+ */
+@Slf4j
+@Service
+public class ButlerSatisfactionService {
+
+    @Resource
+    private ButlerSatisfactionMapper satisfactionMapper;
+
+    @Resource
+    private ButlerQualityService butlerQualityService;
+
+    @Resource
+    private ButlerPerformanceService butlerPerformanceService;
+
+    /**
+     * 家长提交满意度评分(SC-H-07 正常流步骤3)。
+     *
+     * <p>幂等:同一家长(memberUserId)对同一管家(butlerUserId)只保留一条评分,
+     * 重复提交更新原记录(以最后一次为准,不新增、不重复计数)。
+     * 差评提交后自动触发复盘并回流绩效。</p>
+     *
+     * @param familyId    家庭 ID
+     * @param memberUserId 评分家长用户 ID(能登录用户)
+     * @param butlerUserId 被评管家用户 ID
+     * @param surveyId    满意度问卷模板 ID(可空)
+     * @param rating      评分(1-5)
+     * @param comment     评价意见(可空)
+     */
+    @Transactional
+    public Result<ButlerSatisfaction> submitSatisfaction(Long familyId,
+                                                         Long memberUserId,
+                                                         Long butlerUserId,
+                                                         Long surveyId,
+                                                         Integer rating,
+                                                         String comment) {
+        if (memberUserId == null) {
+            return Result.badRequest("评分家长不能为空");
+        }
+        if (butlerUserId == null) {
+            return Result.badRequest("被评管家不能为空");
+        }
+        if (rating == null || rating < 1 || rating > 5) {
+            return Result.badRequest("评分必须在 1-5 之间");
+        }
+
+        // 幂等:家长对管家仅一条评分,重复提交以最后一次为准(不重复计数)
+        ButlerSatisfaction existing = satisfactionMapper.selectOne(new LambdaQueryWrapper<ButlerSatisfaction>()
+                .eq(ButlerSatisfaction::getMemberUserId, memberUserId)
+                .eq(ButlerSatisfaction::getButlerUserId, butlerUserId)
+                .last("LIMIT 1"));
+
+        Date now = new Date();
+        ButlerSatisfaction satisfaction;
+        if (existing != null) {
+            satisfaction = existing;
+            // 差评→非差评不撤销已触发的复盘标记(只追加),幂等以数字计数不回滚
+            satisfaction.setRating(rating);
+            satisfaction.setComment(comment);
+            if (surveyId != null) {
+                satisfaction.setSurveyId(surveyId);
+            }
+            satisfaction.setSubmittedAt(now);
+            satisfaction.setUpdatedAt(now);
+            satisfactionMapper.updateById(satisfaction);
+        } else {
+            satisfaction = new ButlerSatisfaction();
+            satisfaction.setFamilyId(familyId);
+            satisfaction.setMemberUserId(memberUserId);
+            satisfaction.setButlerUserId(butlerUserId);
+            satisfaction.setSurveyId(surveyId);
+            satisfaction.setRating(rating);
+            satisfaction.setComment(comment);
+            satisfaction.setBadReviewTriggered(0);
+            satisfaction.setSubmittedAt(now);
+            satisfaction.setCreatedAt(now);
+            satisfaction.setUpdatedAt(now);
+            satisfactionMapper.insert(satisfaction);
+        }
+
+        // 差评自动触发复盘(幂等:仅首次触发并计数)
+        if (rating <= ButlerQualityService.BAD_REVIEW_RATING_THRESHOLD) {
+            butlerQualityService.triggerRetrospect(satisfaction.getId());
+        }
+
+        // 回流:刷新管家绩效聚合
+        butlerPerformanceService.refresh(butlerUserId, now);
+        return Result.success(satisfaction);
+    }
+
+    /**
+     * 查询家长对某管家的满意度评分。
+     */
+    public Result<ButlerSatisfaction> getSatisfaction(Long memberUserId, Long butlerUserId) {
+        ButlerSatisfaction satisfaction = satisfactionMapper.selectOne(new LambdaQueryWrapper<ButlerSatisfaction>()
+                .eq(ButlerSatisfaction::getMemberUserId, memberUserId)
+                .eq(ButlerSatisfaction::getButlerUserId, butlerUserId)
+                .last("LIMIT 1"));
+        return satisfaction != null ? Result.success(satisfaction) : Result.notFound("尚未提交满意度评分");
+    }
+}

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

@@ -3049,6 +3049,62 @@ CREATE TABLE IF NOT EXISTS butler_resource_follow_up (
     INDEX idx_family_member (family_id, member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源参与回访沉淀偏好(AC4)';
 
+-- 真人管家服务质检记录(能力键 butler.quality.qa · SC-H-07:抽样/全量质检,逐项通过/不通过与依据,不展示隐私对话原文)
+CREATE TABLE IF NOT EXISTS butler_quality_inspections (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    butler_user_id BIGINT NOT NULL COMMENT '被质检管家用户ID',
+    session_id BIGINT NULL COMMENT '质检对象: 会话ID(butler_sessions.id)',
+    service_record_id BIGINT NULL COMMENT '质检对象: 工单/服务记录ID(butler_service_records.id)',
+    inspection_type VARCHAR(20) DEFAULT 'sample' COMMENT '质检方式: sample(抽样)/full(全量)',
+    inspection_items TEXT COMMENT '逐项质检结果JSON: [{"item":"SLA","passed":true,"basis":"依据"}]',
+    passed_count INT DEFAULT 0 COMMENT '通过项数',
+    failed_count INT DEFAULT 0 COMMENT '未通过项数',
+    desensitized_summary TEXT COMMENT '脱敏摘要与判定依据(不展示隐私对话原文)',
+    status VARCHAR(20) DEFAULT 'passed' COMMENT '质检结论: passed/failed',
+    inspector_id BIGINT NULL COMMENT '质检员用户ID',
+    inspected_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '质检时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_butler_id (butler_user_id),
+    INDEX idx_inspected_at (inspected_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真人管家服务质检记录(SC-H-07)';
+
+-- 真人管家满意度评分(能力键 butler.quality.qa · SC-H-07:家长提交评分;幂等以最后一次为准;差评触发复盘标记)
+CREATE TABLE IF NOT EXISTS butler_satisfaction (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    member_user_id BIGINT NOT NULL COMMENT '评分家长用户ID(能登录用户)',
+    butler_user_id BIGINT NOT NULL COMMENT '被评管家用户ID',
+    survey_id BIGINT NULL COMMENT '满意度问卷模板ID',
+    rating INT NOT NULL COMMENT '满意度评分(1-5)',
+    comment TEXT COMMENT '评价意见',
+    bad_review_triggered TINYINT(1) DEFAULT 0 COMMENT '差评是否已触发复盘(0/1,幂等标记)',
+    retrospect_ref VARCHAR(100) COMMENT '复盘关联引用(关联SC-H-04升级应急流程)',
+    submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_member_butler (member_user_id, butler_user_id),
+    INDEX idx_butler_id (butler_user_id),
+    INDEX idx_family_id (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真人管家满意度评分(SC-H-07)';
+
+-- 真人管家绩效看板(能力键 butler.quality.qa · SC-H-07 回流改进:质检+满意度按月聚合,仅授权角色可见)
+CREATE TABLE IF NOT EXISTS butler_performance (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    butler_user_id BIGINT NOT NULL COMMENT '管家用户ID',
+    year_month VARCHAR(7) NOT NULL COMMENT '绩效周期(yyyy-MM)',
+    inspected_count INT DEFAULT 0 COMMENT '质检单数',
+    pass_rate DECIMAL(5,2) DEFAULT 0 COMMENT '质检通过率(%)',
+    satisfaction_count INT DEFAULT 0 COMMENT '满意度评分单数',
+    avg_rating DECIMAL(3,2) DEFAULT 0 COMMENT '平均评分(1-5)',
+    bad_review_count INT DEFAULT 0 COMMENT '差评数(已触发复盘)',
+    performance_score DECIMAL(5,2) DEFAULT 0 COMMENT '绩效综合分(0-100)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_butler_month (butler_user_id, year_month),
+    INDEX idx_year_month (year_month)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真人管家绩效看板(SC-H-07)';
+
 -- 家庭订阅记录
 CREATE TABLE IF NOT EXISTS member_subscription (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,