Jelajahi Sumber

feat: 确定性差距修复 - P0全部 + P1部分完成

已完成:
- G7: FamilyAccessInterceptor familyId 入参校验
- G1: 五维能量分数持久化 (five_dimension_scores)
- G18: 首次vs最新测评对比端点
- G8: 视角切换统一 + 密码校验
- G9: 邀请码过期/次数/吊销
- G2: 测评订单 results_ready 联动
- G13: 地址数据库导入骨架
Xiaogang Liao 3 bulan lalu
induk
melakukan
aa0c2d1684

+ 97 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentComparisonController.java

@@ -0,0 +1,97 @@
+package com.etotem.cfc.controller.assessment;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AssessmentComparisonService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "测评对比", description = "首次 vs 最新测评对比分析")
+@RestController
+@RequestMapping("/api/assessment/compare")
+public class AssessmentComparisonController {
+
+    @Resource
+    private AssessmentComparisonService comparisonService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Operation(summary = "对比孩子首次与最新测评")
+    @PostMapping("/by-child")
+    public Result<AssessmentComparisonService.CompareResult> compareByChild(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        AssessmentComparisonService.CompareResult result = comparisonService.compareByChild(childId);
+        if (result == null) {
+            return Result.error("至少需要两次完成测评才能对比");
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "对比孩子单维度变化")
+    @PostMapping("/by-child-dimension")
+    public Result<AssessmentComparisonService.DimensionDelta> compareByChildDimension(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        String dimensionCode = (String) params.get("dimensionCode");
+        if (childId == null || dimensionCode == null) {
+            return Result.error("childId和dimensionCode不能为空");
+        }
+        AssessmentComparisonService.DimensionDelta result =
+                comparisonService.compareByChildAndDimension(childId, dimensionCode);
+        if (result == null) {
+            return Result.error("暂无足够数据");
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "对比家庭所有孩子首次与最新测评")
+    @PostMapping("/by-family")
+    public Result<List<AssessmentComparisonService.CompareResult>> compareByFamily(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        if (familyId == null && userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+            }
+        }
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        List<AssessmentComparisonService.CompareResult> results =
+                comparisonService.compareByFamily(familyId);
+        return Result.success(results);
+    }
+
+    @Operation(summary = "获取进步叙事")
+    @PostMapping("/narrative")
+    public Result<String> getNarrative(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        String narrative = comparisonService.getProgressNarrative(childId);
+        return Result.success(narrative);
+    }
+}

+ 6 - 39
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyController.java

@@ -32,54 +32,21 @@ public class FamilyController {
     private UserMapper userMapper;
 
     /**
-     * 获取当前家庭的邀请码
+     * 获取当前家庭的邀请码(已废弃,请使用邀请令牌系统)
      */
-    @Operation(summary = "获取邀请码")
+    @Operation(summary = "获取邀请码(已废弃)")
     @PostMapping("/invite-code")
     public Result<String> getInviteCode(@RequestHeader("X-User-Id") Long userId) {
-        User user = userMapper.selectById(userId);
-        if (user == null || user.getFamilyId() == null) {
-            return Result.error("用户没有家庭");
-        }
-
-        Family family = familyMapper.selectById(user.getFamilyId());
-        if (family == null) {
-            return Result.error("家庭不存在");
-        }
-
-        // 如果没有邀请码,生成一个
-        if (family.getInviteCode() == null || family.getInviteCode().isEmpty()) {
-            family.setInviteCode(generateInviteCode());
-            family.setUpdatedAt(new Date());
-            familyMapper.updateById(family);
-        }
-
-        return Result.success(family.getInviteCode());
+        return Result.error(410, "该接口已废弃,请使用 /api/family/invite/generate 生成邀请令牌");
     }
 
     /**
-     * 生成/重置邀请码
+     * 生成/重置邀请码(已废弃,请使用邀请令牌系统)
      */
-    @Operation(summary = "生成邀请码")
+    @Operation(summary = "生成邀请码(已废弃)")
     @PostMapping("/invite-code/generate")
     public Result<String> generateInviteCode(@RequestHeader("X-User-Id") Long userId) {
-        User user = userMapper.selectById(userId);
-        if (user == null || user.getFamilyId() == null) {
-            return Result.error("用户没有家庭");
-        }
-
-        Family family = familyMapper.selectById(user.getFamilyId());
-        if (family == null) {
-            return Result.error("家庭不存在");
-        }
-
-        // 生成新的邀请码
-        String newCode = generateInviteCode();
-        family.setInviteCode(newCode);
-        family.setUpdatedAt(new Date());
-        familyMapper.updateById(family);
-
-        return Result.success(newCode);
+        return Result.error(410, "该接口已废弃,请使用 /api/family/invite/generate 生成邀请令牌");
     }
 
     /**

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.FamilyStatusDTO;
 import com.etotem.cfc.dto.InviteAcceptDTO;
 import com.etotem.cfc.dto.InviteGenerateDTO;
+import com.etotem.cfc.dto.InviteRevokeDTO;
 import com.etotem.cfc.dto.InviteValidateDTO;
 import com.etotem.cfc.dto.TransferAdminDTO;
 import com.etotem.cfc.entity.Family;
@@ -241,6 +242,26 @@ public class FamilyInviteController {
         }
     }
 
+    /**
+     * 吊销邀请链接
+     */
+    @Operation(summary = "吊销邀请链接")
+    @PostMapping("/revoke")
+    public Result<String> revokeInvite(HttpServletRequest request,
+                                       @RequestBody InviteRevokeDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        try {
+            familyInvitationService.revokeInvitation(dto.getInvitationId(), userId);
+            return Result.success("邀请已吊销");
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     /**
      * 转让家庭管理员
      */

+ 23 - 10
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java

@@ -88,14 +88,11 @@ public class FamilyUserController {
         return Result.success(familyId);
     }
 
-    @Operation(summary = "切换家长/孩子模式")
+    @Operation(summary = "切换家长/孩子模式(已废弃,使用 /api/family/member/switch)")
     @PostMapping("/switch-mode")
     public Result<String> switchMode(javax.servlet.http.HttpServletRequest request,
                                      @RequestBody Map<String, String> body) {
-        Long userId = getUserId(request);
-        String mode = body.get("mode");
-        String result = userService.switchMode(userId, mode);
-        return Result.success(result);
+        return Result.error(410, "该接口已废弃,请使用 /api/family/member/switch");
     }
 
     @Operation(summary = "切换用户角色 (支持 parent/child/teacher)")
@@ -119,14 +116,11 @@ public class FamilyUserController {
         return Result.success(roles);
     }
 
-    @Operation(summary = "家长切换到指定孩子身份")
+    @Operation(summary = "家长切换到指定孩子身份(已废弃,使用 /api/family/member/switch)")
     @PostMapping("/switch-to-child")
     public Result<Long> switchToFamilyMember(javax.servlet.http.HttpServletRequest request,
                                        @RequestBody Map<String, Long> body) {
-        Long userId = getUserId(request);
-        Long childId = body.get("childId");
-        Long result = userService.switchToFamilyMember(userId, childId);
-        return Result.success(result);
+        return Result.error(410, "该接口已废弃,请使用 /api/family/member/switch");
     }
 
     @Operation(summary = "孩子切换回家长身份")
@@ -137,6 +131,25 @@ public class FamilyUserController {
         return Result.success(result);
     }
 
+    @Operation(summary = "带密码校验切换回家长视角")
+    @PostMapping("/switch-back-verify")
+    public Result<Boolean> switchBackVerify(javax.servlet.http.HttpServletRequest request,
+                                            @RequestBody Map<String, String> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        String password = body.get("password");
+        if (password == null || password.isEmpty()) {
+            return Result.error("请输入密码");
+        }
+        boolean verified = userService.verifySwitchBack(userId, password);
+        if (!verified) {
+            return Result.error("密码错误");
+        }
+        return Result.success(true);
+    }
+
     @Operation(summary = "创建孩子")
     @PostMapping("/children")
     public Result<FamilyMemberInfoDTO> createFamilyMember(javax.servlet.http.HttpServletRequest request,

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InviteRevokeDTO.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class InviteRevokeDTO {
+    private Long invitationId;       // 邀请记录ID
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyInvitation.java

@@ -25,6 +25,10 @@ public class FamilyInvitation implements Serializable {
 
     private Date expiresAt;        // 过期时间
 
+    private Integer maxUseCount;   // 最大使用次数(默认1)
+
+    private Integer usedCount;     // 已使用次数(默认0)
+
     private Date createdAt;
 
     private Date updatedAt;

+ 421 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentComparisonService.java

@@ -0,0 +1,421 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.FiveDimensionScore;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class AssessmentComparisonService {
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /**
+     * 维度组定义:dimensionName -> [score fields that belong to this group]
+     */
+    private static final Map<String, List<String>> DIMENSION_GROUPS = new LinkedHashMap<>();
+
+    static {
+        DIMENSION_GROUPS.put("attention", Arrays.asList("attentionScore", "focusScore"));
+        DIMENSION_GROUPS.put("memory", Collections.singletonList("memoryScore"));
+        DIMENSION_GROUPS.put("logic", Collections.singletonList("logicScore"));
+        DIMENSION_GROUPS.put("perception", Arrays.asList("perceptionScore", "spatialScore"));
+        DIMENSION_GROUPS.put("processing", Collections.singletonList("processingSpeedScore"));
+        DIMENSION_GROUPS.put("emotion", Arrays.asList("emotionScore", "resilienceScore",
+                "stressCopingScore", "selfAwarenessScore"));
+    }
+
+    /** 单个维度对比结果 */
+    public static class DimensionDelta {
+        public String dimension;
+        public double delta;
+        public String direction; // up / down / unchanged
+        public List<String> drivers; // ["logicScore +18", "memoryScore -5"]
+
+        public DimensionDelta(String dimension, double delta, List<String> drivers) {
+            this.dimension = dimension;
+            this.delta = delta;
+            this.direction = delta > 0.5 ? "up" : (delta < -0.5 ? "down" : "unchanged");
+            this.drivers = drivers;
+        }
+    }
+
+    /** 单次测评快照 */
+    public static class AssessmentSnapshot {
+        public Long id;
+        public Date date;
+        public Map<String, Integer> scores = new HashMap<>();
+
+        public AssessmentSnapshot(Long id, Date date) {
+            this.id = id;
+            this.date = date;
+        }
+    }
+
+    /** 完整对比结果 */
+    public static class CompareResult {
+        public AssessmentSnapshot firstAssessment;
+        public AssessmentSnapshot latestAssessment;
+        public List<DimensionDelta> dimensionDeltas = new ArrayList<>();
+        public double overallDelta;
+        public String narrative;
+
+        public CompareResult(AssessmentSnapshot first, AssessmentSnapshot latest) {
+            this.firstAssessment = first;
+            this.latestAssessment = latest;
+        }
+    }
+
+    /**
+     * 对比某个孩子的首次 vs 最新测评
+     */
+    public CompareResult compareByChild(Long childId) {
+        List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(
+                new LambdaQueryWrapper<DanAssessmentResult>()
+                        .eq(DanAssessmentResult::getChildId, childId)
+                        .eq(DanAssessmentResult::getStatus, "completed")
+                        .orderByAsc(DanAssessmentResult::getAssessmentDate)
+        );
+
+        if (results == null || results.size() < 2) {
+            // 尝试从 five_dimension_scores 读取
+            return compareFromFiveDimensionScores(childId);
+        }
+
+        DanAssessmentResult first = results.get(0);
+        DanAssessmentResult latest = results.get(results.size() - 1);
+
+        AssessmentSnapshot firstSnap = toSnapshot(first);
+        AssessmentSnapshot latestSnap = toSnapshot(latest);
+
+        return computeDeltas(firstSnap, latestSnap);
+    }
+
+    /**
+     * 聚焦单个维度的对比
+     */
+    public DimensionDelta compareByChildAndDimension(Long childId, String dimensionCode) {
+        List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(
+                new LambdaQueryWrapper<DanAssessmentResult>()
+                        .eq(DanAssessmentResult::getChildId, childId)
+                        .eq(DanAssessmentResult::getStatus, "completed")
+                        .orderByAsc(DanAssessmentResult::getAssessmentDate)
+        );
+
+        if (results == null || results.size() < 2) {
+            return null;
+        }
+
+        DanAssessmentResult first = results.get(0);
+        DanAssessmentResult latest = results.get(results.size() - 1);
+
+        List<String> fieldNames = DIMENSION_GROUPS.get(dimensionCode);
+        if (fieldNames == null) {
+            fieldNames = Collections.singletonList(dimensionCode + "Score");
+        }
+
+        Map<String, Integer> firstScores = extractScores(first, fieldNames);
+        Map<String, Integer> latestScores = extractScores(latest, fieldNames);
+
+        double totalDelta = 0;
+        List<String> drivers = new ArrayList<>();
+        for (String field : fieldNames) {
+            int fVal = firstScores.getOrDefault(field, 0);
+            int lVal = latestScores.getOrDefault(field, 0);
+            int diff = lVal - fVal;
+            totalDelta += diff;
+            if (diff != 0) {
+                drivers.add(field + (diff >= 0 ? " +" + diff : " " + diff));
+            }
+        }
+
+        return new DimensionDelta(dimensionCode, totalDelta, drivers);
+    }
+
+    /**
+     * 生成结构化叙事
+     */
+    public String getProgressNarrative(Long childId) {
+        CompareResult result = compareByChild(childId);
+        if (result == null || result.dimensionDeltas.isEmpty()) {
+            return "暂无足够数据生成进步叙事。需要至少完成两次测评。";
+        }
+
+        StringBuilder sb = new StringBuilder();
+        double overallImprovement = 0;
+        int improvedCount = 0, declinedCount = 0;
+        List<String> improvedDetails = new ArrayList<>();
+        List<String> declinedDetails = new ArrayList<>();
+
+        for (DimensionDelta d : result.dimensionDeltas) {
+            overallImprovement += d.delta;
+            if ("up".equals(d.direction)) {
+                improvedCount++;
+                improvedDetails.add(String.format(Locale.CHINA, "%s提升 %.1f 分",
+                        dimensionLabel(d.dimension), d.delta));
+            } else if ("down".equals(d.direction)) {
+                declinedCount++;
+                declinedDetails.add(String.format(Locale.CHINA, "%s下降 %.1f 分",
+                        dimensionLabel(d.dimension), Math.abs(d.delta)));
+            }
+        }
+
+        if (improvedCount > 0) {
+            sb.append("进步明显:").append(String.join(",", improvedDetails)).append("。");
+        }
+        if (declinedCount > 0) {
+            sb.append("需关注:").append(String.join(",", declinedDetails)).append("。");
+        }
+
+        // 驱动因素分析
+        List<String> allDrivers = result.dimensionDeltas.stream()
+                .flatMap(d -> d.drivers.stream())
+                .collect(Collectors.toList());
+        if (!allDrivers.isEmpty()) {
+            List<String> topGainers = allDrivers.stream()
+                    .filter(d -> d.contains("+"))
+                    .sorted((a, b) -> {
+                        int va = extractDriverValue(a);
+                        int vb = extractDriverValue(b);
+                        return Integer.compare(vb, va);
+                    })
+                    .limit(3)
+                    .collect(Collectors.toList());
+            if (!topGainers.isEmpty()) {
+                sb.append("主要驱动因素:").append(String.join(",", topGainers)).append("。");
+            }
+        }
+
+        return sb.toString();
+    }
+
+    /**
+     * 家庭维度:所有孩子的对比
+     */
+    public List<CompareResult> compareByFamily(Long familyId) {
+        List<FamilyMember> children = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+        );
+        List<CompareResult> results = new ArrayList<>();
+        for (FamilyMember child : children) {
+            CompareResult r = compareByChild(child.getId());
+            if (r != null) {
+                results.add(r);
+            }
+        }
+        return results;
+    }
+
+    // ==================== 内部方法 ====================
+
+    private CompareResult compareFromFiveDimensionScores(Long memberId) {
+        List<FiveDimensionScore> scores = fiveDimensionScoreService.getMemberHistory(
+                memberId, "member", null, null);
+        if (scores == null || scores.size() < 2) {
+            return null;
+        }
+
+        Map<String, List<FiveDimensionScore>> byDim = scores.stream()
+                .collect(Collectors.groupingBy(FiveDimensionScore::getDimensionCode));
+
+        AssessmentSnapshot firstSnap = new AssessmentSnapshot(null, null);
+        AssessmentSnapshot latestSnap = new AssessmentSnapshot(null, null);
+
+        for (Map.Entry<String, List<FiveDimensionScore>> entry : byDim.entrySet()) {
+            List<FiveDimensionScore> sorted = entry.getValue().stream()
+                    .sorted(Comparator.comparing(FiveDimensionScore::getAssessedAt))
+                    .collect(Collectors.toList());
+            if (sorted.size() >= 2) {
+                firstSnap.scores.put(entry.getKey(), sorted.get(0).getScore());
+                // update firstSnap date to earliest
+                if (firstSnap.date == null || sorted.get(0).getAssessedAt().before(firstSnap.date)) {
+                    firstSnap.date = sorted.get(0).getAssessedAt();
+                    firstSnap.id = sorted.get(0).getId();
+                }
+                FiveDimensionScore last = sorted.get(sorted.size() - 1);
+                latestSnap.scores.put(entry.getKey(), last.getScore());
+                if (latestSnap.date == null || last.getAssessedAt().after(latestSnap.date)) {
+                    latestSnap.date = last.getAssessedAt();
+                    latestSnap.id = last.getId();
+                }
+            }
+        }
+
+        if (firstSnap.scores.isEmpty() || latestSnap.scores.isEmpty()) {
+            return null;
+        }
+
+        return computeDeltas(firstSnap, latestSnap);
+    }
+
+    private CompareResult computeDeltas(AssessmentSnapshot first, AssessmentSnapshot latest) {
+        CompareResult result = new CompareResult(first, latest);
+
+        Set<String> allDimensions = new LinkedHashSet<>(DIMENSION_GROUPS.keySet());
+        // Also add any extra keys from snapshots
+        for (String key : first.scores.keySet()) {
+            if (!DIMENSION_GROUPS.containsKey(key) && !key.endsWith("Score")) {
+                allDimensions.add(key);
+            } else if (!DIMENSION_GROUPS.containsKey(key)) {
+                // raw score field like "logicScore" - skip, will be covered by drivers
+            }
+        }
+        // Add dimension codes from five_dimension_scores
+        for (String key : first.scores.keySet()) {
+            if (Arrays.asList("body", "mind", "wisdom", "action", "wealth").contains(key)) {
+                allDimensions.add(key);
+            }
+        }
+
+        double totalDelta = 0;
+        int count = 0;
+        for (String dim : allDimensions) {
+            List<String> fieldNames = DIMENSION_GROUPS.getOrDefault(dim, Collections.singletonList(dim));
+            double dimDelta = 0;
+            List<String> drivers = new ArrayList<>();
+            for (String field : fieldNames) {
+                int fVal = first.scores.getOrDefault(field, 0);
+                int lVal = latest.scores.getOrDefault(field, 0);
+                int diff = lVal - fVal;
+                dimDelta += diff;
+                if (diff != 0 && fieldNames.size() > 1) {
+                    drivers.add(field + (diff >= 0 ? " +" + diff : " " + diff));
+                }
+            }
+            result.dimensionDeltas.add(new DimensionDelta(dim, dimDelta, drivers));
+            totalDelta += dimDelta;
+            count++;
+        }
+
+        result.overallDelta = count > 0 ? totalDelta / count : 0;
+        result.narrative = buildNarrative(result);
+        return result;
+    }
+
+    private String buildNarrative(CompareResult result) {
+        StringBuilder sb = new StringBuilder();
+        int up = 0, down = 0;
+        List<String> upDetails = new ArrayList<>();
+        List<String> downDetails = new ArrayList<>();
+
+        for (DimensionDelta d : result.dimensionDeltas) {
+            if ("up".equals(d.direction)) {
+                up++;
+                upDetails.add(String.format(Locale.CHINA, "%s提升 %.1f 分",
+                        dimensionLabel(d.dimension), d.delta));
+            } else if ("down".equals(d.direction)) {
+                down++;
+                downDetails.add(String.format(Locale.CHINA, "%s下降 %.1f 分",
+                        dimensionLabel(d.dimension), Math.abs(d.delta)));
+            }
+        }
+
+        if (up > 0) {
+            sb.append("可喜进步:").append(String.join(",", upDetails)).append("。");
+        }
+        if (down > 0) {
+            sb.append("需加强:").append(String.join(",", downDetails)).append("。");
+        }
+        if (up == 0 && down == 0) {
+            sb.append("各项指标保持稳定,无明显变化。");
+        }
+
+        double overall = result.overallDelta;
+        if (overall > 5) {
+            sb.append(String.format(Locale.CHINA, "综合来看,该生在测评中表现持续进步,综合提升 %.1f 分。",
+                    overall));
+        } else if (overall > 1) {
+            sb.append(String.format(Locale.CHINA, "整体略有进步(+%.1f 分)。", overall));
+        } else if (overall < -5) {
+            sb.append(String.format(Locale.CHINA, "综合评分有所下降(%.1f 分),建议关注并进一步评估。",
+                    overall));
+        }
+
+        return sb.toString();
+    }
+
+    private AssessmentSnapshot toSnapshot(DanAssessmentResult r) {
+        AssessmentSnapshot snap = new AssessmentSnapshot(r.getId(), r.getAssessmentDate());
+        snap.scores.put("attentionScore", safeVal(r.getAttentionScore()));
+        snap.scores.put("focusScore", safeVal(r.getFocusScore()));
+        snap.scores.put("memoryScore", safeVal(r.getMemoryScore()));
+        snap.scores.put("logicScore", safeVal(r.getLogicScore()));
+        snap.scores.put("perceptionScore", safeVal(r.getPerceptionScore()));
+        snap.scores.put("spatialScore", safeVal(r.getSpatialScore()));
+        snap.scores.put("processingSpeedScore", safeVal(r.getProcessingSpeedScore()));
+        snap.scores.put("emotionScore", safeVal(r.getEmotionScore()));
+        snap.scores.put("resilienceScore", safeVal(r.getResilienceScore()));
+        snap.scores.put("stressCopingScore", safeVal(r.getStressCopingScore()));
+        snap.scores.put("selfAwarenessScore", safeVal(r.getSelfAwarenessScore()));
+        snap.scores.put("overallScore", safeVal(r.getOverallScore()));
+        // Big Five
+        snap.scores.put("opennessScore", safeVal(r.getOpennessScore()));
+        snap.scores.put("conscientiousnessScore", safeVal(r.getConscientiousnessScore()));
+        snap.scores.put("extraversionScore", safeVal(r.getExtraversionScore()));
+        snap.scores.put("agreeablenessScore", safeVal(r.getAgreeablenessScore()));
+        snap.scores.put("neuroticismScore", safeVal(r.getNeuroticismScore()));
+        return snap;
+    }
+
+    private Map<String, Integer> extractScores(DanAssessmentResult r, List<String> fields) {
+        Map<String, Integer> scores = new HashMap<>();
+        AssessmentSnapshot snap = toSnapshot(r);
+        for (String f : fields) {
+            Integer v = snap.scores.get(f);
+            if (v != null) {
+                scores.put(f, v);
+            }
+        }
+        return scores;
+    }
+
+    private static int safeVal(Integer v) {
+        return v != null ? v : 0;
+    }
+
+    private static int extractDriverValue(String driver) {
+        // "logicScore +18" → 18, "memoryScore -5" → -5
+        String[] parts = driver.split(" ");
+        if (parts.length >= 2) {
+            try {
+                return Integer.parseInt(parts[parts.length - 1].replace("+", ""));
+            } catch (NumberFormatException e) {
+                return 0;
+            }
+        }
+        return 0;
+    }
+
+    private static String dimensionLabel(String dim) {
+        switch (dim) {
+            case "attention": return "注意力";
+            case "memory": return "记忆力";
+            case "logic": return "逻辑思维";
+            case "perception": return "感知能力";
+            case "processing": return "加工速度";
+            case "emotion": return "情绪心理";
+            case "overall": return "综合";
+            case "body": return "身体素质";
+            case "mind": return "心理健康";
+            case "wisdom": return "智慧发展";
+            case "action": return "行动能力";
+            case "wealth": return "财富素养";
+            default: return dim;
+        }
+    }
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java

@@ -104,6 +104,22 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
         return this.list(wrapper);
     }
 
+    public AssessmentOrder getByAppointmentId(Long appointmentId) {
+        LambdaQueryWrapper<AssessmentOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AssessmentOrder::getAppointmentId, appointmentId);
+        return this.getOne(wrapper);
+    }
+
+    public boolean markResultsReady(String orderNo) {
+        LambdaQueryWrapper<AssessmentOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AssessmentOrder::getOrderNo, orderNo);
+        AssessmentOrder order = this.getOne(wrapper);
+        if (order == null || !"paid".equals(order.getStatus())) return false;
+        order.setStatus("results_ready");
+        order.setUpdatedAt(new Date());
+        return this.updateById(order);
+    }
+
     public List<AssessmentOrder> getByUserId(Long userId) {
         LambdaQueryWrapper<AssessmentOrder> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AssessmentOrder::getUserId, userId);

+ 107 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/DataMigrationService.java

@@ -42,6 +42,9 @@ public class DataMigrationService implements DataMigrationServiceInterface {
     @Resource
     private GrowthPlanService growthPlanService;
 
+    @Resource
+    private StreetMapper streetMapper;
+
     // ============ 统计结果 ============
     private int migratedFamilies;
     private int migratedParents;
@@ -51,6 +54,8 @@ public class DataMigrationService implements DataMigrationServiceInterface {
     private int migratedResults;
     private int migratedRecords;
     private int migratedPlans;
+    private int migratedStreets;
+
     private final List<String> errors = new ArrayList<>();
 
     // ============ ID 映射缓存 ============
@@ -75,11 +80,12 @@ public class DataMigrationService implements DataMigrationServiceInterface {
             migrateAssessmentResults();
             migrateGrowthRecords();
             migrateGrowthPlans();
+            migrateAddress();
 
             log.info("========== SFMS 数据迁移完成,耗时: {}ms ==========", System.currentTimeMillis() - start);
-            log.info("迁移统计: {} 家庭, {} 家长, {} 孩子, {} 规划师, {} 订单, {} 测评结果, {} 成长档案, {} 成长方案",
+            log.info("迁移统计: {} 家庭, {} 家长, {} 孩子, {} 规划师, {} 订单, {} 测评结果, {} 成长档案, {} 成长方案, {} 地址",
                 migratedFamilies, migratedParents, migratedFamilyMemberren, migratedTeachers,
-                migratedOrders, migratedResults, migratedRecords, migratedPlans);
+                migratedOrders, migratedResults, migratedRecords, migratedPlans, migratedStreets);
         } catch (Exception e) {
             log.error("迁移过程中出错", e);
             errors.add("全局错误: " + e.getMessage());
@@ -97,6 +103,7 @@ public class DataMigrationService implements DataMigrationServiceInterface {
         migratedResults = 0;
         migratedRecords = 0;
         migratedPlans = 0;
+        migratedStreets = 0;
         errors.clear();
         sfmsPhoneToFamilyId.clear();
         sfmsClientIdToFamilyMemberUserId.clear();
@@ -716,6 +723,102 @@ public class DataMigrationService implements DataMigrationServiceInterface {
         }
     }
 
+    // ================================================================
+    //  10. 地址数据导入:从 SFMS 同步省市区街道数据到 streets 表
+    // ================================================================
+    private void migrateAddress() {
+        log.info("--- 10/10: 导入地址数据 ---");
+        try {
+            long existing = streetMapper.selectCount(null);
+            if (existing > 0) {
+                log.info("  streets 表已有 {} 条数据,跳过导入", existing);
+                return;
+            }
+
+            // 常见表名: sys_region / base_region / sys_province
+            String[][] tableNames = {
+                {"sys_region", "region_id", "region_name", "parent_id", "region_level"},
+                {"base_region", "id", "name", "parent_id", "level"},
+                {"sys_city", "id", "name", "parent_id", "type"}
+            };
+
+            List<Map<String, Object>> rows = null;
+            String nameCol = null, parentCol = null, levelCol = null;
+
+            for (String[] table : tableNames) {
+                try {
+                    rows = sfms.queryForList("SELECT * FROM " + table[0] + " LIMIT 1");
+                    if (rows != null && !rows.isEmpty()) {
+                        nameCol = table[2];
+                        parentCol = table[3];
+                        levelCol = table[4];
+                        rows = sfms.queryForList("SELECT * FROM " + table[0] + " ORDER BY " + levelCol + ", " + nameCol);
+                        log.info("  从 {}({}) 读取到 {} 条地址记录", table[0], table[1], rows.size());
+                        break;
+                    }
+                } catch (Exception ignored) {
+                }
+            }
+
+            if (rows == null || rows.isEmpty()) {
+                log.info("  SFMS 无可用地址表,跳过导入");
+                return;
+            }
+
+            int count = 0;
+            for (Map<String, Object> row : rows) {
+                try {
+                    Street street = new Street();
+                    Number idNum = (Number) row.get(tableNames[0][1]);
+                    if (idNum != null) street.setId(idNum.longValue());
+                    street.setFullName((String) row.get(nameCol));
+                    Number parentNum = row.get(parentCol) != null ? (Number) row.get(parentCol) : null;
+                    if (parentNum != null) street.setParentId(parentNum.longValue());
+                    Number lvlNum = row.get(levelCol) != null ? (Number) row.get(levelCol) : null;
+                    if (lvlNum != null) street.setLevel(lvlNum.intValue());
+
+                    // 按层级填充名称
+                    String name = (String) row.get(nameCol);
+                    if (name != null) {
+                        int lvl = lvlNum != null ? lvlNum.intValue() : 0;
+                        switch (lvl) {
+                            case 1: street.setProvince(name); break;
+                            case 2: street.setCity(name); break;
+                            case 3: street.setDistrict(name); break;
+                            case 4: street.setStreet(name); break;
+                        }
+                    }
+
+                    street.setCreatedAt(new Date());
+                    street.setUpdatedAt(new Date());
+                    streetMapper.insert(street);
+                    count++;
+                } catch (Exception e) {
+                    log.warn("导入地址记录失败: {}", e.getMessage());
+                }
+            }
+
+            // 再尝试从 crmclientinfo 中提取地址
+            try {
+                List<Map<String, Object>> addrRows = sfms.queryForList(
+                    "SELECT DISTINCT province_show_label, city_show_label, " +
+                    "district_show_label, company_name_show_label " +
+                    "FROM crmclientinfo WHERE province_show_label IS NOT NULL"
+                );
+                for (Map<String, Object> row : addrRows) {
+                    // 这里只是采集已知地址供参考,不做全量导入
+                }
+            } catch (Exception ignored) {
+            }
+
+            migratedStreets = count;
+            log.info("  导入 {} 条地址数据", migratedStreets);
+        } catch (Exception e) {
+            log.error("导入地址数据出错", e);
+            errors.add("导入地址数据出错: " + e.getMessage());
+        }
+    }
+
     // ================================================================
     //  辅助方法
     // ================================================================
@@ -813,8 +916,9 @@ public class DataMigrationService implements DataMigrationServiceInterface {
         result.put("migratedResults", migratedResults);
         result.put("migratedRecords", migratedRecords);
         result.put("migratedPlans", migratedPlans);
+        result.put("migratedStreets", migratedStreets);
         result.put("total", migratedFamilies + migratedParents + migratedFamilyMemberren + migratedTeachers
-            + migratedOrders + migratedResults + migratedRecords + migratedPlans);
+            + migratedOrders + migratedResults + migratedRecords + migratedPlans + migratedStreets);
         if (!errors.isEmpty()) {
             result.put("errors", errors);
         }

+ 54 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.etotem.cfc.dto.FamilyStatusDTO;
 import com.etotem.cfc.dto.InviteValidateDTO;
 import com.etotem.cfc.entity.FamilyMember;
@@ -50,6 +51,11 @@ public class FamilyInvitationService {
             throw new RuntimeException("邀请链接无效或已过期");
         }
 
+        if (invitation.getUsedCount() != null && invitation.getMaxUseCount() != null
+                && invitation.getUsedCount() >= invitation.getMaxUseCount()) {
+            throw new RuntimeException("邀请链接已超过最大使用次数");
+        }
+
         Family family = familyMapper.selectById(invitation.getFamilyId());
         if (family == null) {
             throw new RuntimeException("家庭不存在");
@@ -76,12 +82,23 @@ public class FamilyInvitationService {
             throw new RuntimeException("家庭不存在");
         }
 
+        long activeCount = familyInvitationMapper.selectCount(
+                new LambdaQueryWrapper<FamilyInvitation>()
+                        .eq(FamilyInvitation::getCreatedBy, userId)
+                        .eq(FamilyInvitation::getStatus, "active")
+        );
+        if (activeCount >= 10) {
+            throw new RuntimeException("您已生成过多邀请链接,请先吊销未使用的链接");
+        }
+
         FamilyInvitation invitation = new FamilyInvitation();
         invitation.setFamilyId(familyId);
         invitation.setToken(UUID.randomUUID().toString().replace("-", ""));
         invitation.setCreatedBy(userId);
         invitation.setStatus("active");
         invitation.setExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L));
+        invitation.setMaxUseCount(1);
+        invitation.setUsedCount(0);
         invitation.setCreatedAt(new Date());
         invitation.setUpdatedAt(new Date());
         familyInvitationMapper.insert(invitation);
@@ -90,15 +107,28 @@ public class FamilyInvitationService {
     }
 
     public String acceptInvitation(Long userId, String token, String familyRole, Boolean mergeFamily) {
+        // Step 1: 原子递增 usedCount(并发安全:只有 usedCount < maxUseCount 时才更新)
+        int rows = familyInvitationMapper.update(
+                null,
+                new UpdateWrapper<FamilyInvitation>()
+                        .setSql("used_count = used_count + 1, updated_at = NOW()")
+                        .eq("token", token)
+                        .eq("status", "active")
+                        .and(i -> i.isNull("expires_at").or().gt("expires_at", new Date()))
+                        .apply("used_count < max_use_count")
+        );
+        if (rows == 0) {
+            throw new RuntimeException("邀请链接无效、已过期或已被使用太多次");
+        }
+
         FamilyInvitation invitation = familyInvitationMapper.selectOne(
-                new LambdaQueryWrapper<FamilyInvitation>()
-                        .eq(FamilyInvitation::getToken, token)
-                        .eq(FamilyInvitation::getStatus, "active")
-                        .and(w -> w.isNull(FamilyInvitation::getExpiresAt)
-                                .or().gt(FamilyInvitation::getExpiresAt, new Date()))
+                new LambdaQueryWrapper<FamilyInvitation>().eq(FamilyInvitation::getToken, token)
         );
-        if (invitation == null) {
-            throw new RuntimeException("邀请链接无效或已过期");
+
+        if (invitation.getUsedCount() >= invitation.getMaxUseCount()) {
+            invitation.setStatus("used");
+            invitation.setUpdatedAt(new Date());
+            familyInvitationMapper.updateById(invitation);
         }
 
         User user = userMapper.selectById(userId);
@@ -132,10 +162,6 @@ public class FamilyInvitationService {
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
 
-        invitation.setStatus("used");
-        invitation.setUpdatedAt(new Date());
-        familyInvitationMapper.updateById(invitation);
-
         return "加入家庭成功";
     }
 
@@ -220,6 +246,23 @@ public class FamilyInvitationService {
         return dto;
     }
 
+    public void revokeInvitation(Long invitationId, Long userId) {
+        FamilyInvitation invitation = familyInvitationMapper.selectById(invitationId);
+        if (invitation == null) {
+            throw new RuntimeException("邀请记录不存在");
+        }
+        if (!invitation.getCreatedBy().equals(userId)) {
+            throw new RuntimeException("只有邀请创建者才能吊销");
+        }
+        if (!"active".equals(invitation.getStatus())) {
+            throw new RuntimeException("该邀请已失效");
+        }
+
+        invitation.setStatus("revoked");
+        invitation.setUpdatedAt(new Date());
+        familyInvitationMapper.updateById(invitation);
+    }
+
     public void transferAdmin(Long familyId, Long currentUserId, Long newAdminId) {
         Family family = familyMapper.selectById(familyId);
         if (family == null) {

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/TeacherService.java

@@ -33,6 +33,9 @@ public class TeacherService {
     @Resource
     private UserService userService;
 
+    @Resource
+    private AssessmentOrderService assessmentOrderService;
+
     /**
      * 获取老师信息
      */
@@ -188,7 +191,15 @@ public class TeacherService {
             teacher.setUpdatedAt(new Date());
             teacherMapper.updateById(teacher);
         }
-        
+
+        // 标记订单为 results_ready,通知家长结果可查
+        if (dto.getAppointmentId() != null) {
+            AssessmentOrder order = assessmentOrderService.getByAppointmentId(dto.getAppointmentId());
+            if (order != null && "paid".equals(order.getStatus())) {
+                assessmentOrderService.markResultsReady(order.getOrderNo());
+            }
+        }
+
         return toAssessmentResultDTO(result);
     }
 

+ 25 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -16,6 +16,7 @@ import com.etotem.cfc.entity.DistributionRelation;
 import com.etotem.cfc.service.DistributionService;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.api.UserServiceInterface;
+import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
 import org.springframework.stereotype.Service;
 import org.springframework.util.DigestUtils;
@@ -24,6 +25,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.*;
 
 
+@Slf4j
 @Service
 public class UserService implements UserServiceInterface {
 
@@ -410,7 +412,9 @@ private FamilyInvitationService familyInvitationService;
         return result;
     }
 
+    @Deprecated
     public String switchMode(Long userId, String mode) {
+        log.warn("已废弃的 switchMode 被调用, userId={}, mode={} - 应使用 /api/family/member/switch", userId, mode);
         User user = userMapper.selectById(userId);
         if (user == null) {
             return null;
@@ -587,7 +591,9 @@ private FamilyInvitationService familyInvitationService;
    * 家长切换到指定孩子身份(视图切换,不修改用户的实际角色)
    * 家长可以在家长模式下查看孩子的任务和积分
    */
+  @Deprecated
   public Long switchToChild(Long userId, Long childId) {
+    log.warn("已废弃的 switchToChild 被调用, userId={}, childId={} - 应使用 /api/family/member/switch", userId, childId);
     User user = userMapper.selectById(userId);
     if (user == null) {
       return null;
@@ -604,16 +610,31 @@ private FamilyInvitationService familyInvitationService;
   }
 
   /**
-   * 从孩子视图切换回家长视图
-   * 这只是确认切换操作,不需要修改数据库
+   * 从孩子视图切换回家长视图(旧版,无密码校验)
+   * 保留以兼容旧 JWT token
    */
   public boolean switchBackToParent(Long userId) {
+    log.warn("无密码的 switchBackToParent 被调用, userId={} - 建议前端使用 /switch-back-verify", userId);
     User user = userMapper.selectById(userId);
     if (user == null) {
       return false;
     }
-    // 不需要修改用户的 role 字段
-    // 只是确认操作成功,前端切换视图
+    return true;
+  }
+
+  /**
+   * 带密码校验的切换回家长视图
+   * @param userId 用户ID
+   * @param password 密码
+   * @return 校验并切换成功返回 true
+   */
+  public boolean verifySwitchBack(Long userId, String password) {
+    if (password == null || password.isEmpty()) {
+      return false;
+    }
+    if (!verifyPassword(userId, password)) {
+      return false;
+    }
     return true;
   }
 

+ 16 - 6
cfc-frontend/store/index.js

@@ -15,8 +15,9 @@ const store = new Vuex.Store({
     currentMemberId: '', // 当前切换到的家庭成员ID(family_members表)
     currentMemberRole: '', // 当前切换到的家庭成员effectiveRole
     children: [],
-    isSwitchedChild: false, // 家长切换到孩子状态的标记
-    isSwitchedMember: false // 通过family_members切换的标记
+    isSwitchedChild: false, // 家长切换到孩子状态的标记(已废弃,使用 currentView)
+    isSwitchedMember: false, // 通过family_members切换的标记(已废弃,使用 currentView)
+    currentView: 'self' // 当前视角: 'self' | 'member'
   },
   mutations: {
     setToken(state, token) {
@@ -26,17 +27,17 @@ const store = new Vuex.Store({
     setUserInfo(state, userInfo) {
       state.userId = userInfo.userId
       state.role = userInfo.role
-      state.currentRole = userInfo.role // 初始化当前角色为实际角色
+      state.currentRole = userInfo.role
       state.familyId = userInfo.familyId
       state.nickname = userInfo.nickname
-      // 保存到本地存储
       uni.setStorageSync('userId', userInfo.userId)
       uni.setStorageSync('role', userInfo.role)
       uni.setStorageSync('currentRole', userInfo.role)
       uni.setStorageSync('familyId', userInfo.familyId)
-      // 登录时清除切换标记
       state.isSwitchedChild = false
+      state.currentView = 'self'
       uni.setStorageSync('isSwitchedChild', false)
+      uni.setStorageSync('currentView', 'self')
     },
     setChildren(state, children) {
       state.children = children
@@ -53,18 +54,22 @@ const store = new Vuex.Store({
       state.currentRole = 'child'
       state.currentChildId = childId
       state.isSwitchedChild = true
+      state.currentView = 'member'
       uni.setStorageSync('currentRole', 'child')
       uni.setStorageSync('currentChildId', childId)
       uni.setStorageSync('isSwitchedChild', true)
+      uni.setStorageSync('currentView', 'member')
     },
     // 切换回家长模式
     switchBackToParent(state) {
       state.currentRole = 'parent'
       state.currentChildId = ''
       state.isSwitchedChild = false
+      state.currentView = 'self'
       uni.setStorageSync('currentRole', 'parent')
       uni.removeStorageSync('currentChildId')
       uni.setStorageSync('isSwitchedChild', false)
+      uni.setStorageSync('currentView', 'self')
     },
     // 通过family_members切换到某个成员
     switchToMember(state, payload) {
@@ -72,6 +77,7 @@ const store = new Vuex.Store({
       state.currentMemberId = payload.memberId
       state.currentMemberRole = payload.effectiveRole
       state.isSwitchedMember = true
+      state.currentView = 'member'
       if (payload.effectiveRole === 'child') {
         state.isSwitchedChild = true
         state.currentChildId = payload.memberId
@@ -80,6 +86,7 @@ const store = new Vuex.Store({
       uni.setStorageSync('currentMemberId', payload.memberId)
       uni.setStorageSync('currentMemberRole', payload.effectiveRole)
       uni.setStorageSync('isSwitchedMember', true)
+      uni.setStorageSync('currentView', 'member')
       if (payload.effectiveRole === 'child') {
         uni.setStorageSync('currentChildId', payload.memberId)
         uni.setStorageSync('isSwitchedChild', true)
@@ -94,12 +101,14 @@ const store = new Vuex.Store({
       state.isSwitchedMember = false
       state.isSwitchedChild = false
       state.currentChildId = ''
+      state.currentView = 'self'
       uni.setStorageSync('currentRole', originalRole)
       uni.removeStorageSync('currentMemberId')
       uni.removeStorageSync('currentMemberRole')
       uni.setStorageSync('isSwitchedMember', false)
       uni.removeStorageSync('currentChildId')
       uni.setStorageSync('isSwitchedChild', false)
+      uni.setStorageSync('currentView', 'self')
     },
     // 切换视图模式(不修改数据库中的实际角色)
     switchViewMode(state, role) {
@@ -125,7 +134,7 @@ const store = new Vuex.Store({
       state.isSwitchedChild = false
       state.isSwitchedMember = false
       state.isSwitchedTeacher = false
-      // 清除全部登录态存储
+      state.currentView = 'self'
       uni.removeStorageSync('token')
       uni.removeStorageSync('currentRole')
       uni.removeStorageSync('isSwitchedChild')
@@ -140,6 +149,7 @@ const store = new Vuex.Store({
       uni.removeStorageSync('currentMemberId')
       uni.removeStorageSync('currentMemberRole')
       uni.removeStorageSync('isSwitchedMember')
+      uni.removeStorageSync('currentView')
     }
   },
   actions: {

+ 4 - 0
cfc-frontend/utils/api.js

@@ -190,6 +190,10 @@ export const switchBackToParent = () => {
 	return request('/api/user/switch-back-to-parent', 'POST', {})
 }
 
+export const switchBackVerify = (password) => {
+	return request('/api/family/user/switch-back-verify', 'POST', { password })
+}
+
 export const getUserRoles = () => {
 	return request('/api/user/roles', 'POST')
 }