Browse Source

feat(article-center): add AI questions endpoint + submit answers with scoring

Sisyphus 2 months ago
parent
commit
85c87a46f4

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -126,4 +126,31 @@ public class ArticleController {
         Map<String, Object> tip = articleService.getDailyTip();
         return Result.success(tip);
     }
+
+    @PostMapping("/ai-questions")
+    public Result<Map<String, Object>> aiQuestions(@RequestBody Map<String, Object> body,
+                                                    @RequestAttribute("userId") Long userId) {
+        Long articleId = Long.valueOf(body.get("articleId").toString());
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        try {
+            Map<String, Object> data = articleService.getAiQuestions(articleId, userId, childId);
+            return Result.success(data);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/submit-answers")
+    public Result<Map<String, Object>> submitAnswers(@RequestBody Map<String, Object> body,
+                                                      @RequestAttribute("userId") Long userId) {
+        Long recordId = Long.valueOf(body.get("recordId").toString());
+        String answersJson = (String) body.get("answers");
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        try {
+            Map<String, Object> data = articleService.submitAnswers(recordId, answersJson, userId, childId);
+            return Result.success(data);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
 }

+ 140 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.dto.ReadingStatsDTO;
 import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.entity.ArticleQuizRecord;
 import com.etotem.cfc.entity.ArticleReadingRecord;
 import com.etotem.cfc.entity.Child;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
 import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
 import com.etotem.cfc.mapper.ChildMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
@@ -55,6 +57,15 @@ public class ArticleService {
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private PointsService pointsService;
+
+    @Resource
+    private AIService aiService;
+
+    @Resource
+    private ArticleQuizRecordMapper articleQuizRecordMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
@@ -410,4 +421,133 @@ public class ArticleService {
         fallback.put("coverImage", null);
         return fallback;
     }
+
+    /**
+     * 根据文章内容和用户画像生成 2 道选择题
+     */
+    @Transactional
+    public Map<String, Object> getAiQuestions(Long articleId, Long userId, Long childId) {
+        Map<String, Object> result = new HashMap<>();
+
+        // 1. 获取文章
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) {
+            throw new RuntimeException("文章不存在");
+        }
+
+        // 2. 构建 Dify prompt
+        String contentPreview = article.getContent() != null ? article.getContent() : "";
+        if (contentPreview.length() > 800) {
+            contentPreview = contentPreview.substring(0, 800);
+        }
+        String prompt = "你是一个家庭教育助手。根据以下文章内容,为儿童生成 2 道选择题(每题 4 个选项)。"
+            + "题目应该适合该年龄段的孩子理解。"
+            + "请以 JSON 数组格式返回,格式:[{\"question\":\"问题\",\"options\":[\"A.选项 1\",\"B.选项 2\",\"C.选项 3\",\"D.选项 4\"],\"answer\":\"A\"}]"
+            + "不要返回其他文字,只返回 JSON 数组。"
+            + "\n\n文章标题:" + article.getTitle()
+            + "\n文章内容:" + contentPreview;
+
+        // 3. 调用 Dify AI
+        Map<String, Object> aiResult = aiService.sendMessage(prompt, String.valueOf(userId), "", null);
+        String aiResponse = (String) aiResult.get("answer");
+        if (aiResponse == null) {
+            aiResponse = "";
+        }
+
+        // 4. 提取 JSON 数组
+        String questionsJson = extractJsonFromResponse(aiResponse);
+
+        // 5. 保存记录
+        ArticleQuizRecord record = new ArticleQuizRecord();
+        record.setArticleId(articleId);
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setQuestions(questionsJson);
+        record.setAnswers(null);
+        record.setScore(0);
+        record.setPointsEarned(0);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.insert(record);
+
+        result.put("recordId", record.getId());
+        result.put("questions", questionsJson);
+        return result;
+    }
+
+    private String extractJsonFromResponse(String response) {
+        if (response == null || response.isEmpty()) return "[]";
+        int start = response.indexOf('[');
+        int end = response.lastIndexOf(']');
+        if (start >= 0 && end > start) {
+            return response.substring(start, end + 1);
+        }
+        return "[]";
+    }
+
+    @Transactional
+    public Map<String, Object> submitAnswers(Long recordId, String answersJson, Long userId, Long childId) {
+        Map<String, Object> result = new HashMap<>();
+
+        // 1. 获取记录
+        ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
+        if (record == null) {
+            throw new RuntimeException("答题记录不存在");
+        }
+        if (record.getAnswers() != null) {
+            throw new RuntimeException("已作答,不可重复提交");
+        }
+
+        // 2. 解析题目和答案,计算分数
+        int score = 0;
+        int totalQuestions = 0;
+        try {
+            List<Map<String, Object>> questions = objectMapper.readValue(record.getQuestions(), objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));
+            List<Map<String, Object>> answers = objectMapper.readValue(answersJson, objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));
+            totalQuestions = questions.size();
+
+            Map<String, Object> answerMap = new HashMap<>();
+            for (Map<String, Object> a : answers) {
+                answerMap.put(String.valueOf(a.get("questionIndex")), a.get("selected"));
+            }
+
+            for (int i = 0; i < questions.size(); i++) {
+                Map<String, Object> q = questions.get(i);
+                String correct = (String) q.get("answer");
+                String selected = (String) answerMap.get(String.valueOf(i));
+                if (correct != null && correct.equalsIgnoreCase(selected != null ? selected.toString() : "")) {
+                    score++;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析答题结果失败:{}", e.getMessage());
+        }
+
+        // 3. 计算积分:完全答对 +5 分
+        int pointsEarned = 0;
+        if (score == totalQuestions && totalQuestions > 0) {
+            pointsEarned = 5;
+        }
+
+        // 4. 更新记录
+        record.setAnswers(answersJson);
+        record.setScore(score);
+        record.setPointsEarned(pointsEarned);
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.updateById(record);
+
+        // 5. 发放积分
+        if (pointsEarned > 0 && childId != null) {
+            try {
+                pointsService.awardSystemPoints(childId, pointsEarned, "AI 阅读答题奖励");
+            } catch (Exception e) {
+                log.warn("积分发放失败:{}", e.getMessage());
+            }
+        }
+
+        result.put("score", score);
+        result.put("totalQuestions", totalQuestions);
+        result.put("pointsEarned", pointsEarned);
+        return result;
+    }
 }