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

feat(backend): 关系问卷评分算法替换 mock — 维度加权/反向/降级/缺题处理 + 5 个单测

iwt 1 месяц назад
Родитель
Сommit
fdb7901885

+ 79 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java

@@ -103,7 +103,7 @@ public class RelationshipQuestionnaireService {
         }
 
         // 解析答案并计算评分(简化版,实际在 T16 实现复杂计算)
-        BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson());
+        BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson(), snapshot.getAiGeneratedJson());
 
         // 保存回答结果
         RelationshipQuestionnaireResponse response = new RelationshipQuestionnaireResponse();
@@ -192,13 +192,84 @@ public class RelationshipQuestionnaireService {
                 "]";
     }
 
-    private BigDecimal[] calculateScoresFromAnswers(String answersJson) {
-        // 简化评分计算,实际在 T16 实现复杂逻辑
-        // 这里假设答案格式为 JSON: {"1":"A","2":"B","3":"C"} 其中 A=100,B=75,C=50,D=25,E=0
-        BigDecimal trust = new BigDecimal("75.00");
-        BigDecimal intimacy = new BigDecimal("70.00");
-        BigDecimal communication = new BigDecimal("80.00");
-        return new BigDecimal[]{trust, intimacy, communication};
+    private BigDecimal[] calculateScoresFromAnswers(String answersJson, String questionsJson) {
+        if (answersJson == null || answersJson.isEmpty() || questionsJson == null || questionsJson.isEmpty()) {
+            return new BigDecimal[]{
+                    new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
+        }
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode questionsRoot = mapper.readTree(questionsJson);
+            com.fasterxml.jackson.databind.JsonNode questionsNode = questionsRoot.get("questions");
+            if (questionsNode == null || !questionsNode.isArray()) {
+                return new BigDecimal[]{
+                        new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
+            }
+
+            com.fasterxml.jackson.databind.JsonNode answersNode = mapper.readTree(answersJson);
+
+            java.util.Map<String, double[]> dimSums = new java.util.HashMap<>();
+            for (com.fasterxml.jackson.databind.JsonNode q : questionsNode) {
+                String qId = q.get("id").asText();
+                String dim = q.get("dimension").asText();
+                String direction = q.has("direction") ? q.get("direction").asText("positive") : "positive";
+                double weight = q.has("weight") ? q.get("weight").asDouble(1.0) : 1.0;
+
+                if (!answersNode.has(qId)) continue;
+
+                double rawScore;
+                if (q.has("options")) {
+                    String answerOptionId = answersNode.get(qId).has("id")
+                            ? answersNode.get(qId).get("id").asText() : null;
+                    double maxScore = 0;
+                    rawScore = 0;
+                    boolean found = false;
+                    for (com.fasterxml.jackson.databind.JsonNode opt : q.get("options")) {
+                        double s = opt.get("score").asDouble(0);
+                        if (s > maxScore) maxScore = s;
+                        if (answerOptionId != null && answerOptionId.equals(opt.get("id").asText())) {
+                            rawScore = s;
+                            found = true;
+                        }
+                    }
+                    if (!found) continue;
+                    rawScore = (maxScore > 0) ? (rawScore / maxScore * 100.0) : 0;
+                } else if (q.has("scale")) {
+                    double scaleMin = q.get("scale").get("min").asDouble(0);
+                    double scaleMax = q.get("scale").get("max").asDouble(4);
+                    double scoreVal = answersNode.get(qId).has("scoreValue")
+                            ? answersNode.get(qId).get("scoreValue").asDouble(0) : 0;
+                    rawScore = (scaleMax > scaleMin) ? ((scoreVal - scaleMin) / (scaleMax - scaleMin) * 100.0) : 0;
+                } else {
+                    continue;
+                }
+
+                if ("negative".equals(direction)) {
+                    rawScore = 100.0 - rawScore;
+                }
+
+                dimSums.computeIfAbsent(dim, k -> new double[]{0, 0});
+                double[] arr = dimSums.get(dim);
+                arr[0] += rawScore * weight;
+                arr[1] += weight;
+            }
+
+            BigDecimal trust = computeDimScore(dimSums.get("trust"));
+            BigDecimal intimacy = computeDimScore(dimSums.get("intimacy"));
+            BigDecimal communication = computeDimScore(dimSums.get("communication"));
+            return new BigDecimal[]{trust, intimacy, communication};
+        } catch (Exception e) {
+            log.warn("calculateScoresFromAnswers 异常: {}", e.getMessage());
+            return new BigDecimal[]{
+                    new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
+        }
+    }
+
+    private BigDecimal computeDimScore(double[] arr) {
+        if (arr == null || arr[1] == 0) return new BigDecimal("0.00");
+        double score = arr[0] / arr[1];
+        score = Math.min(100.0, Math.max(0.0, score));
+        return new BigDecimal(String.format("%.2f", score));
     }
 
     private void updateMemberScores(Long memberId, BigDecimal[] scores) {

+ 139 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java

@@ -0,0 +1,139 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.RelationshipQuestionnaireResponseMapper;
+import com.etotem.cfc.mapper.RelationshipQuestionnaireSnapshotMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RelationshipQuestionnaireServiceTest {
+
+    private RelationshipQuestionnaireService service;
+
+    @Mock
+    private RelationshipQuestionnaireSnapshotMapper snapshotMapper;
+    @Mock
+    private RelationshipQuestionnaireResponseMapper responseMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @BeforeEach
+    void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new RelationshipQuestionnaireService();
+        setField(service, "snapshotMapper", snapshotMapper);
+        setField(service, "responseMapper", responseMapper);
+        setField(service, "familyMemberMapper", familyMemberMapper);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private BigDecimal[] callCalculate(String answersJson, String questionsJson) throws Exception {
+        Method m = RelationshipQuestionnaireService.class
+                .getDeclaredMethod("calculateScoresFromAnswers", String.class, String.class);
+        m.setAccessible(true);
+        return (BigDecimal[]) m.invoke(service, answersJson, questionsJson);
+    }
+
+    @Test
+    void calculateScores_positive_allMax_intimacyMin() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q3\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q4\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"c\",\"score\":2},\"q2\":{\"id\":\"c\",\"score\":2}," +
+                "\"q3\":{\"id\":\"a\",\"score\":0},\"q4\":{\"id\":\"b\",\"score\":1}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        assertEquals(0, scores[0].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[1].compareTo(new BigDecimal("0.00")));
+        assertEquals(0, scores[2].compareTo(new BigDecimal("50.00")));
+    }
+
+    @Test
+    void calculateScores_negative_direction_reverse() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"negative\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q3\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"a\",\"score\":0}," +
+                "\"q2\":{\"id\":\"c\",\"score\":2},\"q3\":{\"id\":\"c\",\"score\":2}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        assertEquals(0, scores[0].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[1].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[2].compareTo(new BigDecimal("100.00")));
+    }
+
+    @Test
+    void calculateScores_weighted_average() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":2.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q3\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q4\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"c\",\"score\":2},\"q2\":{\"id\":\"a\",\"score\":0}," +
+                "\"q3\":{\"id\":\"b\",\"score\":1},\"q4\":{\"id\":\"b\",\"score\":1}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        assertEquals(0, scores[0].compareTo(new BigDecimal("66.67")));
+        assertEquals(0, scores[1].compareTo(new BigDecimal("50.00")));
+        assertEquals(0, scores[2].compareTo(new BigDecimal("50.00")));
+    }
+
+    @Test
+    void calculateScores_invalidJson_returnsNegativeOne() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[\"id\":\"q1\",\"dimension\":\"trust\"]}";
+        BigDecimal[] scores1 = callCalculate(null, questions);
+        assertEquals(0, scores1[0].compareTo(new BigDecimal("-1.00")));
+        assertEquals(0, scores1[1].compareTo(new BigDecimal("-1.00")));
+        assertEquals(0, scores1[2].compareTo(new BigDecimal("-1.00")));
+
+        BigDecimal[] scores2 = callCalculate("not-json!", questions);
+        assertEquals(0, scores2[0].compareTo(new BigDecimal("-1.00")));
+    }
+
+    @Test
+    void calculateScores_missingDimension_onlyAveragesExisting() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"b\",\"score\":2},\"q2\":{\"id\":\"b\",\"score\":2}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        assertEquals(0, scores[0].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[1].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[2].compareTo(new BigDecimal("0.00")));
+    }
+}