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

feat: 数学闯关后端服务

新增数学闯关核心服务后端:WisdomMathController/WisdomMathService/AIService.runWorkflow、MathChildLevel和MathQuizLog实体及Mapper、schema.sql新增math_child_level和math_quiz_logs表、application.yml新增math配置项。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao 4 месяцев назад
Родитель
Сommit
454e9fd9bf

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/wisdom/WisdomMathController.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.controller.wisdom;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.WisdomMathService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/wisdom/math")
+public class WisdomMathController {
+
+    @Resource
+    private WisdomMathService wisdomMathService;
+
+    @PostMapping("/start")
+    public Result<Map<String, Object>> start(@RequestBody Map<String, Object> params,
+                                              @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+            ? Long.valueOf(params.get("childId").toString()) : userId;
+        Integer age = params.get("age") != null
+            ? Integer.valueOf(params.get("age").toString()) : null;
+        return Result.success(wisdomMathService.startSession(childId, age));
+    }
+
+    @PostMapping("/verify")
+    public Result<Map<String, Object>> verify(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+            ? Long.valueOf(params.get("childId").toString()) : userId;
+        String expression = params.get("expression") != null ? params.get("expression").toString() : "";
+        String userAnswer = params.get("userAnswer") != null ? params.get("userAnswer").toString() : "";
+        return Result.success(wisdomMathService.verifyAnswer(childId, expression, userAnswer));
+    }
+
+    @PostMapping("/complete")
+    public Result<Map<String, Object>> complete(@RequestBody Map<String, Object> params,
+                                                 @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+            ? Long.valueOf(params.get("childId").toString()) : userId;
+        int level = params.get("level") != null ? Integer.parseInt(params.get("level").toString()) : 1;
+        int targetN = params.get("targetN") != null ? Integer.parseInt(params.get("targetN").toString()) : 5;
+        int correctCount = params.get("correctCount") != null ? Integer.parseInt(params.get("correctCount").toString()) : 0;
+        int totalAttempts = params.get("totalAttempts") != null ? Integer.parseInt(params.get("totalAttempts").toString()) : 0;
+        long totalTimeMs = params.get("totalTimeMs") != null ? Long.parseLong(params.get("totalTimeMs").toString()) : 0;
+        List<String> expressions = params.get("expressions") != null ? (List<String>) params.get("expressions") : null;
+        return Result.success(wisdomMathService.completeSession(childId, level, targetN, correctCount, totalAttempts, totalTimeMs, expressions));
+    }
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MathChildLevel.java

@@ -0,0 +1,30 @@
+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.util.Date;
+
+@Data
+@TableName("math_child_level")
+public class MathChildLevel implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long childId;
+
+    private Integer currentLevel;
+
+    private Integer totalPassed;
+
+    private Long bestTimeMs;
+
+    private Integer bestAccuracy;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MathQuizLog.java

@@ -0,0 +1,36 @@
+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.util.Date;
+
+@Data
+@TableName("math_quiz_logs")
+public class MathQuizLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long childId;
+
+    private Integer level;
+
+    private Integer targetN;
+
+    private Integer correctCount;
+
+    private Integer totalAttempts;
+
+    private Long totalTimeMs;
+
+    private Integer accuracy;
+
+    private Integer passed;
+
+    private String exprs;
+
+    private Date createdAt;
+}

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

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

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

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

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -8,6 +8,8 @@ import org.springframework.web.client.RestTemplate;
 import com.etotem.cfc.entity.EmotionCheckin;
 import com.etotem.cfc.entity.EmotionCheckin;
 import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import java.util.*;
 import java.util.*;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
@@ -18,6 +20,8 @@ import java.util.stream.Collectors;
 @Service
 @Service
 public class AIService {
 public class AIService {
 
 
+    private static final Logger log = LoggerFactory.getLogger(AIService.class);
+
     @Resource
     @Resource
     private RestTemplate restTemplate;
     private RestTemplate restTemplate;
 
 
@@ -144,6 +148,38 @@ public class AIService {
         restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class);
         restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class);
     }
     }
 
 
+    /**
+     * 调用 Dify Workflow API(阻塞模式)
+     *
+     * @param workflowId Dify 工作流 ID
+     * @param inputs     工作流输入变量
+     * @param userId     用户标识
+     * @return workflow outputs
+     */
+    public Map<String, Object> runWorkflow(String workflowId, Map<String, Object> inputs, String userId) {
+        String url = difyBaseUrl + "/workflows/run";
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+        body.put("response_mode", "blocking");
+        body.put("user", userId);
+        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, authHeaders());
+        try {
+            ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
+            if (resp.getBody() != null) {
+                Map<String, Object> data = (Map<String, Object>) resp.getBody().get("data");
+                if (data != null) {
+                    Map<String, Object> outputs = (Map<String, Object>) data.get("outputs");
+                    if (outputs != null) return outputs;
+                }
+            }
+        } catch (Exception e) {
+            log.error("Dify workflow 调用失败", e);
+        }
+        Map<String, Object> empty = new LinkedHashMap<>();
+        empty.put("error", "workflow call failed");
+        return empty;
+    }
+
     /**
     /**
      * Build an emotional context prompt for the Dify AI conversation
      * Build an emotional context prompt for the Dify AI conversation
      * based on the child's recent checkin history.
      * based on the child's recent checkin history.

+ 225 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WisdomMathService.java

@@ -0,0 +1,225 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.MathChildLevel;
+import com.etotem.cfc.entity.MathQuizLog;
+import com.etotem.cfc.mapper.MathChildLevelMapper;
+import com.etotem.cfc.mapper.MathQuizLogMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class WisdomMathService {
+
+    private static final Logger log = LoggerFactory.getLogger(WisdomMathService.class);
+
+    private static final String[] LEVEL_NAMES = {
+        "一位数加减", "两位数加减", "一位数四则运算", "两位数四则运算含括号"
+    };
+
+    private static final int[][] LEVEL_AGE_MAP = {
+        {3, 6},   // level 1: 3-6岁
+        {6, 9},   // level 2: 6-9岁
+        {9, 12},  // level 3: 9-12岁
+        {12, 99}  // level 4: 12岁+
+    };
+
+    @Value("${math.verify-mode:dify}")
+    private String verifyMode;
+
+    @Value("${math.workflow-id:}")
+    private String workflowId;
+
+    @Resource
+    private MathChildLevelMapper childLevelMapper;
+
+    @Resource
+    private MathQuizLogMapper quizLogMapper;
+
+    @Resource
+    private AIService aiService;
+
+    public Map<String, Object> startSession(Long childId, Integer age) {
+        MathChildLevel level = childLevelMapper.selectOne(
+            new LambdaQueryWrapper<MathChildLevel>().eq(MathChildLevel::getChildId, childId));
+        int currentLevel = 1;
+        if (level != null) {
+            currentLevel = level.getCurrentLevel();
+        } else {
+            currentLevel = levelByAge(age);
+            level = new MathChildLevel();
+            level.setChildId(childId);
+            level.setCurrentLevel(currentLevel);
+            level.setTotalPassed(0);
+            childLevelMapper.insert(level);
+        }
+        int suggestedTarget = suggestedTargetN(currentLevel, age);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("level", currentLevel);
+        result.put("levelName", LEVEL_NAMES[currentLevel - 1]);
+        result.put("age", age);
+        result.put("suggestedTargetN", suggestedTarget);
+        result.put("totalPassed", level != null ? level.getTotalPassed() : 0);
+        result.put("bestTimeMs", level != null ? level.getBestTimeMs() : null);
+        result.put("bestAccuracy", level != null ? level.getBestAccuracy() : null);
+        return result;
+    }
+
+    public Map<String, Object> verifyAnswer(Long childId, String expression, String userAnswer) {
+        if ("dify".equals(verifyMode) && workflowId != null && !workflowId.isEmpty()) {
+            return verifyViaDify(expression, userAnswer, String.valueOf(childId));
+        }
+        return verifyViaEval(expression, userAnswer);
+    }
+
+    private Map<String, Object> verifyViaDify(String expression, String userAnswer, String userId) {
+        Map<String, Object> inputs = new LinkedHashMap<>();
+        inputs.put("expression", expression);
+        inputs.put("userAnswer", userAnswer);
+        Map<String, Object> outputs = aiService.runWorkflow(workflowId, inputs, userId);
+        if (outputs.containsKey("error")) {
+            log.warn("Dify workflow failed, falling back to eval mode");
+            return verifyViaEval(expression, userAnswer);
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("correct", outputs.getOrDefault("correct", false));
+        result.put("correctAnswer", outputs.getOrDefault("correctAnswer", ""));
+        result.put("feedback", outputs.getOrDefault("feedback", ""));
+        return result;
+    }
+
+    private Map<String, Object> verifyViaEval(String expression, String userAnswer) {
+        boolean correct = false;
+        String correctAnswer = "";
+        String feedback = "";
+        try {
+            String expr = expression.replaceAll("×", "*").replaceAll("÷", "/")
+                .replaceAll("x", "*").replaceAll("X", "*")
+                .replaceAll("=", "=").replaceAll("=", "");
+            javax.script.ScriptEngineManager mgr = new javax.script.ScriptEngineManager();
+            javax.script.ScriptEngine engine = mgr.getEngineByName("nashorn");
+            if (engine != null) {
+                Object evalResult = engine.eval(expr);
+                correctAnswer = String.valueOf(evalResult);
+                correct = correctAnswer.equals(userAnswer.trim());
+                feedback = correct ? "正确!" : "再想想哦";
+            } else {
+                feedback = "算式无法解析,请用标准格式(如 12+38)";
+            }
+        } catch (Exception e) {
+            log.warn("math eval failed for: {}", expression, e);
+            feedback = "算式格式有误,请检查";
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("correct", correct);
+        result.put("correctAnswer", correctAnswer);
+        result.put("feedback", feedback);
+        return result;
+    }
+
+    public Map<String, Object> completeSession(Long childId, int level, int targetN,
+                                                 int correctCount, int totalAttempts,
+                                                 long totalTimeMs, List<String> expressions) {
+        int accuracy = totalAttempts > 0 ? (correctCount * 100 / totalAttempts) : 0;
+        boolean passed = correctCount >= targetN;
+
+        MathQuizLog logEntry = new MathQuizLog();
+        logEntry.setChildId(childId);
+        logEntry.setLevel(level);
+        logEntry.setTargetN(targetN);
+        logEntry.setCorrectCount(correctCount);
+        logEntry.setTotalAttempts(totalAttempts);
+        logEntry.setTotalTimeMs(totalTimeMs);
+        logEntry.setAccuracy(accuracy);
+        logEntry.setPassed(passed ? 1 : 0);
+        logEntry.setExprs(expressions != null ? String.join(",", expressions) : "");
+        quizLogMapper.insert(logEntry);
+
+        boolean levelUp = false;
+        boolean levelDown = false;
+        int newLevel = level;
+        String reason = "继续加油";
+
+        if (passed) {
+            MathChildLevel levelRecord = childLevelMapper.selectOne(
+                new LambdaQueryWrapper<MathChildLevel>().eq(MathChildLevel::getChildId, childId));
+            if (levelRecord == null) {
+                levelRecord = new MathChildLevel();
+                levelRecord.setChildId(childId);
+                levelRecord.setCurrentLevel(level);
+                levelRecord.setTotalPassed(1);
+                childLevelMapper.insert(levelRecord);
+            } else {
+                levelRecord.setTotalPassed(levelRecord.getTotalPassed() != null
+                    ? levelRecord.getTotalPassed() + 1 : 1);
+                if (levelRecord.getBestTimeMs() == null || totalTimeMs < levelRecord.getBestTimeMs()) {
+                    levelRecord.setBestTimeMs(totalTimeMs);
+                }
+                if (levelRecord.getBestAccuracy() == null || accuracy > levelRecord.getBestAccuracy()) {
+                    levelRecord.setBestAccuracy(accuracy);
+                }
+            }
+
+            double avgTimePerQuestion = totalAttempts > 0 ? (double) totalTimeMs / totalAttempts : 0;
+
+            if (accuracy >= 90 && avgTimePerQuestion <= 30000 && level < 4) {
+                levelUp = true;
+                newLevel = level + 1;
+                reason = "太棒了!正确率" + accuracy + "%,平均每题" + (avgTimePerQuestion / 1000) + "秒,升级到" + LEVEL_NAMES[newLevel - 1] + "!";
+            } else if (accuracy >= 80) {
+                reason = "表现不错!正确率" + accuracy + "%,继续练习可以挑战更高级别。";
+            } else {
+                reason = "过关了!正确率" + accuracy + "%,多练习提高准确率。";
+            }
+
+            if (levelRecord.getCurrentLevel() != newLevel) {
+                levelRecord.setCurrentLevel(newLevel);
+            }
+            childLevelMapper.updateById(levelRecord);
+
+            if (accuracy < 50 && avgTimePerQuestion > 60000 && level > 1) {
+                levelDown = true;
+                newLevel = level - 1;
+                reason = "正确率仅" + accuracy + "%,建议降级到" + LEVEL_NAMES[newLevel - 1] + "巩固基础。";
+                levelRecord.setCurrentLevel(newLevel);
+                childLevelMapper.updateById(levelRecord);
+            }
+        } else {
+            reason = "未达到目标,再接再厉!";
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("passed", passed);
+        result.put("levelUp", levelUp);
+        result.put("levelDown", levelDown);
+        result.put("newLevel", newLevel);
+        result.put("reason", reason);
+        result.put("accuracy", accuracy);
+        result.put("totalAttempts", totalAttempts);
+        result.put("totalTimeMs", totalTimeMs);
+        return result;
+    }
+
+    private int levelByAge(Integer age) {
+        if (age == null) return 1;
+        for (int i = 0; i < LEVEL_AGE_MAP.length; i++) {
+            if (age >= LEVEL_AGE_MAP[i][0] && age <= LEVEL_AGE_MAP[i][1]) {
+                return i + 1;
+            }
+        }
+        return 1;
+    }
+
+    private int suggestedTargetN(int level, Integer age) {
+        if (age == null) age = 6;
+        if (age <= 6) return 3;
+        if (age <= 9) return 5;
+        if (age <= 12) return 8;
+        return 10;
+    }
+}

+ 4 - 0
cfc-backend/src/main/resources/application.yml

@@ -81,6 +81,10 @@ dify:
   api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
   api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
   nutrition-api-key: app-OnHzTiI6EULUfLyceCDqJkQu  # 精准营养助手
   nutrition-api-key: app-OnHzTiI6EULUfLyceCDqJkQu  # 精准营养助手
 
 
+math:
+  verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)
+  workflow-id: ""    # Dify 数学判题工作流 ID(留空则使用 eval 兜底)
+
 logging:
 logging:
   level:
   level:
     com.etotem.cfc: debug
     com.etotem.cfc: debug

+ 31 - 1
cfc-backend/src/main/resources/schema.sql

@@ -231,7 +231,7 @@ CREATE TABLE IF NOT EXISTS dan_assessments (
     details JSON,
     details JSON,
     source ENUM('api', 'manual') DEFAULT 'manual',
     source ENUM('api', 'manual') DEFAULT 'manual',
     photo_url VARCHAR(255),
     photo_url VARCHAR(255),
-    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 
 -- 任务模板表
 -- 任务模板表
@@ -709,6 +709,36 @@ CREATE TABLE IF NOT EXISTS admin_task_templates (
 -- =============================================
 -- =============================================
 
 
 -- 测评资料表
 -- 测评资料表
+-- 数学闯关 - 孩子当前等级
+CREATE TABLE IF NOT EXISTS math_child_level (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    child_id BIGINT NOT NULL UNIQUE,
+    current_level TINYINT NOT NULL DEFAULT 1 COMMENT '1=一位数加减,2=两位数加减,3=一位数四则,4=两位数四则',
+    total_passed INT DEFAULT 0 COMMENT '累计通关次数',
+    best_time_ms BIGINT DEFAULT NULL COMMENT '最佳用时(毫秒)',
+    best_accuracy INT DEFAULT NULL COMMENT '最佳正确率(%)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_child_id (child_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 数学闯关 - 通关日志
+CREATE TABLE IF NOT EXISTS math_quiz_logs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    child_id BIGINT NOT NULL,
+    level TINYINT NOT NULL COMMENT '闯关时等级',
+    target_n INT NOT NULL COMMENT '目标连续答对数',
+    correct_count INT NOT NULL COMMENT '实际连续答对数',
+    total_attempts INT NOT NULL COMMENT '总答题数',
+    total_time_ms BIGINT NOT NULL COMMENT '总用时(毫秒)',
+    accuracy INT NOT NULL COMMENT '正确率(%)',
+    passed TINYINT(1) DEFAULT 0 COMMENT '是否通关',
+    exprs TEXT COMMENT '本轮表达式列表(JSON)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_id (child_id),
+    INDEX idx_level (level)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
 CREATE TABLE IF NOT EXISTS assessment_materials (
 CREATE TABLE IF NOT EXISTS assessment_materials (
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
     title VARCHAR(100) NOT NULL COMMENT '资料标题',
     title VARCHAR(100) NOT NULL COMMENT '资料标题',