|
@@ -3,16 +3,28 @@ package com.etotem.cfc.controller.content;
|
|
|
import com.etotem.cfc.common.Result;
|
|
import com.etotem.cfc.common.Result;
|
|
|
import com.etotem.cfc.entity.ArticleComment;
|
|
import com.etotem.cfc.entity.ArticleComment;
|
|
|
import com.etotem.cfc.entity.Article;
|
|
import com.etotem.cfc.entity.Article;
|
|
|
|
|
+import com.etotem.cfc.entity.ArticleQuizRecord;
|
|
|
|
|
+import com.etotem.cfc.entity.ProfileSnapshot;
|
|
|
import com.etotem.cfc.mapper.ArticleMapper;
|
|
import com.etotem.cfc.mapper.ArticleMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ProfileSnapshotMapper;
|
|
|
import com.etotem.cfc.service.ArticleCommentService;
|
|
import com.etotem.cfc.service.ArticleCommentService;
|
|
|
-import com.etotem.cfc.service.AIService;
|
|
|
|
|
|
|
+import com.etotem.cfc.service.AiGateway;
|
|
|
import com.etotem.cfc.service.EnergyService;
|
|
import com.etotem.cfc.service.EnergyService;
|
|
|
|
|
+import com.etotem.cfc.service.PointsService;
|
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
|
|
|
+import org.slf4j.Logger;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.bind.annotation.*;
|
|
|
|
|
|
|
|
import javax.annotation.Resource;
|
|
import javax.annotation.Resource;
|
|
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
import java.util.*;
|
|
import java.util.*;
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.alibaba.fastjson.JSONArray;
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import com.etotem.cfc.util.ParamUtils;
|
|
import com.etotem.cfc.util.ParamUtils;
|
|
|
|
|
|
|
|
@Tag(name = "文章评论", description = "文章评论与AI答题")
|
|
@Tag(name = "文章评论", description = "文章评论与AI答题")
|
|
@@ -20,14 +32,22 @@ import com.etotem.cfc.util.ParamUtils;
|
|
|
@RequestMapping("/api/articles")
|
|
@RequestMapping("/api/articles")
|
|
|
public class ArticleCommentController {
|
|
public class ArticleCommentController {
|
|
|
|
|
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(ArticleCommentController.class);
|
|
|
|
|
+
|
|
|
@Resource
|
|
@Resource
|
|
|
private ArticleCommentService articleCommentService;
|
|
private ArticleCommentService articleCommentService;
|
|
|
@Resource
|
|
@Resource
|
|
|
private ArticleMapper articleMapper;
|
|
private ArticleMapper articleMapper;
|
|
|
@Resource
|
|
@Resource
|
|
|
- private AIService aiService;
|
|
|
|
|
- @Resource
|
|
|
|
|
private EnergyService energyService;
|
|
private EnergyService energyService;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AiGateway aiGateway;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private PointsService pointsService;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ArticleQuizRecordMapper articleQuizRecordMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProfileSnapshotMapper profileSnapshotMapper;
|
|
|
|
|
|
|
|
@PostMapping("/comments/list")
|
|
@PostMapping("/comments/list")
|
|
|
public Result<List<Map<String, Object>>> listComments(@RequestBody Map<String, Object> params) {
|
|
public Result<List<Map<String, Object>>> listComments(@RequestBody Map<String, Object> params) {
|
|
@@ -83,39 +103,216 @@ public class ArticleCommentController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@PostMapping("/quiz/generate")
|
|
@PostMapping("/quiz/generate")
|
|
|
- public Result<List<Map<String, Object>>> generateQuiz(@RequestBody Map<String, Object> params) {
|
|
|
|
|
|
|
+ public Result<Map<String, Object>> generateQuiz(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
|
Long articleId = ParamUtils.getLong(params.get("articleId"));
|
|
Long articleId = ParamUtils.getLong(params.get("articleId"));
|
|
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
|
|
|
Article article = articleMapper.selectById(articleId);
|
|
Article article = articleMapper.selectById(articleId);
|
|
|
if (article == null) return Result.error("文章不存在");
|
|
if (article == null) return Result.error("文章不存在");
|
|
|
- String c = article.getContent();
|
|
|
|
|
- if (c != null && c.length() > 2000) c = c.substring(0, 2000);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ String content = article.getContent();
|
|
|
|
|
+ if (content != null && content.length() > 2000) content = content.substring(0, 2000);
|
|
|
|
|
+
|
|
|
|
|
+ // 组装用户画像摘要(从 profile_snapshot 读五维评分)
|
|
|
|
|
+ String profileSummary = "";
|
|
|
|
|
+ if (memberId != null) {
|
|
|
|
|
+ ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
|
|
|
|
|
+ if (snapshot != null && snapshot.getDimensionScores() != null) {
|
|
|
|
|
+ profileSummary = snapshot.getDimensionScores();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 优先走 AiGateway + LangGraph,失败降级硬编码
|
|
|
|
|
+ List<Map<String, Object>> questions = null;
|
|
|
try {
|
|
try {
|
|
|
- Map<String, Object> inputs = new HashMap<>();
|
|
|
|
|
- inputs.put("article_title", article.getTitle());
|
|
|
|
|
- inputs.put("article_content", c);
|
|
|
|
|
- Map<String, Object> dr = aiService.sendWorkflow("article-quiz", inputs);
|
|
|
|
|
- List<Map<String, Object>> qs = (List<Map<String, Object>>) dr.getOrDefault("questions", new ArrayList<>());
|
|
|
|
|
- if (!qs.isEmpty()) return Result.success(qs);
|
|
|
|
|
- } catch (Exception e) {}
|
|
|
|
|
- List<Map<String, Object>> fb = new ArrayList<>();
|
|
|
|
|
- fb.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知","B. 健康生活","C. 亲子关系","D. 财富管理"), "A"));
|
|
|
|
|
- fb.add(q("文章提到了哪些维度?", Arrays.asList("A. 身","B. 心","C. 智","D. 以上都有"), "D"));
|
|
|
|
|
- fb.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识","B. 获得了启发","C. 需要再想想","D. 很有共鸣"), "A"));
|
|
|
|
|
- return Result.success(fb);
|
|
|
|
|
|
|
+ questions = aiGateway.generateArticleQuiz(article.getTitle(), content, profileSummary);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("AI 出题异常: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (questions == null || questions.isEmpty()) {
|
|
|
|
|
+ questions = new ArrayList<>();
|
|
|
|
|
+ questions.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知", "B. 健康生活", "C. 亲子关系", "D. 财富管理"), "A", "wisdom"));
|
|
|
|
|
+ questions.add(q("文章提到了哪些维度?", Arrays.asList("A. 身", "B. 心", "C. 智", "D. 以上都有"), "D", "wisdom"));
|
|
|
|
|
+ questions.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识", "B. 获得了启发", "C. 需要再想想", "D. 很有共鸣"), "A", "wisdom"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 落库题目快照,返回 recordId(判分以库内答案为准,防篡改)
|
|
|
|
|
+ ArticleQuizRecord record = new ArticleQuizRecord();
|
|
|
|
|
+ record.setArticleId(articleId);
|
|
|
|
|
+ record.setUserId(userId);
|
|
|
|
|
+ record.setMemberId(memberId);
|
|
|
|
|
+ record.setQuestions(JSON.toJSONString(questions));
|
|
|
|
|
+ record.setAnswers(null);
|
|
|
|
|
+ record.setScore(0);
|
|
|
|
|
+ record.setTotalQuestions(questions.size());
|
|
|
|
|
+ record.setCfEarned(0);
|
|
|
|
|
+ record.setEnergyEarned(0);
|
|
|
|
|
+ record.setCreatedAt(new Date());
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ articleQuizRecordMapper.insert(record);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("recordId", record.getId());
|
|
|
|
|
+ result.put("questions", questions);
|
|
|
|
|
+ return Result.success(result);
|
|
|
}
|
|
}
|
|
|
- private Map<String, Object> q(String q, List<String> opts, String a) {
|
|
|
|
|
- Map<String, Object> m = new HashMap<>(); m.put("question", q); m.put("options", opts); m.put("answer", a); return m;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> q(String q, List<String> opts, String a, String dimension) {
|
|
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
|
|
+ m.put("question", q);
|
|
|
|
|
+ m.put("options", opts);
|
|
|
|
|
+ m.put("answer", a);
|
|
|
|
|
+ m.put("dimension", dimension);
|
|
|
|
|
+ return m;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@PostMapping("/quiz/submit")
|
|
@PostMapping("/quiz/submit")
|
|
|
- public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId,
|
|
|
|
|
|
|
+ public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
|
- int correct = params.get("correctCount") != null ? Integer.parseInt(params.get("correctCount").toString()) : 0;
|
|
|
|
|
- int earned = correct * 5;
|
|
|
|
|
|
|
+ Long recordId = ParamUtils.getLong(params.get("recordId"));
|
|
|
Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
|
|
Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
|
|
|
- if (memberId != null && earned > 0) try { energyService.awardEnergy(memberId, "article_quiz", 0L, earned, "答题奖励", null); } catch (Exception e) {}
|
|
|
|
|
- Map<String, Object> r = new HashMap<>();
|
|
|
|
|
- r.put("correctCount", correct); r.put("totalQuestions", 3); r.put("earnedEnergy", earned);
|
|
|
|
|
- return Result.success(r);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
|
|
|
|
|
+ if (record == null) return Result.error("答题记录不存在");
|
|
|
|
|
+ if (record.getAnswers() != null && !record.getAnswers().isEmpty()) {
|
|
|
|
|
+ return Result.error("已作答,不可重复提交");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int score = 0;
|
|
|
|
|
+ int totalQuestions = 0;
|
|
|
|
|
+ List<String> answerList = new ArrayList<>();
|
|
|
|
|
+ Map<String, int[]> dimStats = new HashMap<>(); // dimension -> [correct, total]
|
|
|
|
|
+ try {
|
|
|
|
|
+ JSONArray qArr = JSON.parseArray(record.getQuestions());
|
|
|
|
|
+ JSONArray aArr = parseAnswers(params.get("answers"));
|
|
|
|
|
+ totalQuestions = qArr != null ? qArr.size() : 0;
|
|
|
|
|
+ if (aArr != null) {
|
|
|
|
|
+ for (int i = 0; i < aArr.size(); i++) {
|
|
|
|
|
+ answerList.add(aArr.getString(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int i = 0; i < totalQuestions; i++) {
|
|
|
|
|
+ JSONObject q = qArr.getJSONObject(i);
|
|
|
|
|
+ String correct = q.getString("answer");
|
|
|
|
|
+ String dim = q.getString("dimension");
|
|
|
|
|
+ if (dim == null || dim.isEmpty()) dim = "wisdom";
|
|
|
|
|
+ String selected = i < answerList.size() ? answerList.get(i) : "";
|
|
|
|
|
+ boolean isCorrect = correct != null && selected != null && correct.equalsIgnoreCase(selected.trim());
|
|
|
|
|
+ int[] stat = dimStats.computeIfAbsent(dim, k -> new int[2]);
|
|
|
|
|
+ stat[1]++;
|
|
|
|
|
+ if (isCorrect) { stat[0]++; score++; }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析答题结果失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
|
|
|
|
|
+ int energyEarned = score * 5;
|
|
|
|
|
+
|
|
|
|
|
+ record.setAnswers(JSON.toJSONString(answerList));
|
|
|
|
|
+ record.setScore(score);
|
|
|
|
|
+ record.setTotalQuestions(totalQuestions);
|
|
|
|
|
+ record.setCfEarned(cfEarned);
|
|
|
|
|
+ record.setEnergyEarned(energyEarned);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ articleQuizRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ if (memberId != null) {
|
|
|
|
|
+ if (cfEarned > 0) {
|
|
|
|
|
+ try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
|
|
|
|
|
+ catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (energyEarned > 0) {
|
|
|
|
|
+ try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
|
|
|
|
|
+ catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateQuizSummary(memberId, score, totalQuestions, dimStats);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("correctCount", score);
|
|
|
|
|
+ result.put("totalQuestions", totalQuestions);
|
|
|
|
|
+ result.put("cfEarned", cfEarned);
|
|
|
|
|
+ result.put("energyEarned", energyEarned);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 兼容前端两种传参:answers 为 JSON 数组字符串("[\"A\",\"B\"]")或直接为数组(["A","B"])。
|
|
|
|
|
+ * 后端 @RequestBody 反序列化时数组会变成 List,直接 String.valueOf(list) 得到 "[A, B]" 无法被 fastjson 解析,
|
|
|
|
|
+ * 故必须区分处理。
|
|
|
|
|
+ */
|
|
|
|
|
+ private JSONArray parseAnswers(Object answers) {
|
|
|
|
|
+ if (answers == null) return null;
|
|
|
|
|
+ if (answers instanceof String) {
|
|
|
|
|
+ String s = ((String) answers).trim();
|
|
|
|
|
+ return s.isEmpty() ? null : JSON.parseArray(s);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (answers instanceof List) {
|
|
|
|
|
+ return JSON.parseArray(JSON.toJSONString(answers));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (answers instanceof JSONArray) {
|
|
|
|
|
+ return (JSONArray) answers;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void updateQuizSummary(Long memberId, int score, int totalQuestions, Map<String, int[]> dimStats) {
|
|
|
|
|
+ if (memberId == null) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject summary = new JSONObject();
|
|
|
|
|
+ if (snapshot != null && snapshot.getQuizSummary() != null && !snapshot.getQuizSummary().isEmpty()) {
|
|
|
|
|
+ summary = JSON.parseObject(snapshot.getQuizSummary());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int prevCorrect = summary.getIntValue("correct_count");
|
|
|
|
|
+ int prevTotal = summary.getIntValue("total_count");
|
|
|
|
|
+ int newCorrect = prevCorrect + score;
|
|
|
|
|
+ int newTotal = prevTotal + totalQuestions;
|
|
|
|
|
+ summary.put("quiz_count", summary.getIntValue("quiz_count") + 1);
|
|
|
|
|
+ summary.put("correct_count", newCorrect);
|
|
|
|
|
+ summary.put("total_count", newTotal);
|
|
|
|
|
+ summary.put("correct_rate", newTotal > 0 ? (double) newCorrect / newTotal : 0.0);
|
|
|
|
|
+ summary.put("last_quiz_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject byDim = summary.getJSONObject("by_dimension");
|
|
|
|
|
+ if (byDim == null) byDim = new JSONObject();
|
|
|
|
|
+ for (Map.Entry<String, int[]> entry : dimStats.entrySet()) {
|
|
|
|
|
+ String dim = entry.getKey();
|
|
|
|
|
+ int correct = entry.getValue()[0];
|
|
|
|
|
+ int total = entry.getValue()[1];
|
|
|
|
|
+ JSONObject dimStat = byDim.getJSONObject(dim);
|
|
|
|
|
+ if (dimStat == null) dimStat = new JSONObject();
|
|
|
|
|
+ int dimCorrect = dimStat.getIntValue("correct_count") + correct;
|
|
|
|
|
+ int dimTotal = dimStat.getIntValue("total_count") + total;
|
|
|
|
|
+ dimStat.put("correct_count", dimCorrect);
|
|
|
|
|
+ dimStat.put("total_count", dimTotal);
|
|
|
|
|
+ dimStat.put("correct_rate", dimTotal > 0 ? (double) dimCorrect / dimTotal : 0.0);
|
|
|
|
|
+ byDim.put(dim, dimStat);
|
|
|
|
|
+ }
|
|
|
|
|
+ summary.put("by_dimension", byDim);
|
|
|
|
|
+
|
|
|
|
|
+ if (snapshot == null) {
|
|
|
|
|
+ snapshot = new ProfileSnapshot();
|
|
|
|
|
+ snapshot.setMemberId(memberId);
|
|
|
|
|
+ snapshot.setQuizSummary(summary.toJSONString());
|
|
|
|
|
+ snapshot.setUpdatedAt(new Date());
|
|
|
|
|
+ profileSnapshotMapper.insert(snapshot);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ snapshot.setQuizSummary(summary.toJSONString());
|
|
|
|
|
+ snapshot.setUpdatedAt(new Date());
|
|
|
|
|
+ profileSnapshotMapper.updateById(snapshot);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("更新认知雷达失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|