# 文章阅读后答题奖励 + 认知雷达记录 实现计划 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 补全「阅读达标 → AI 出题 → 作答 → CF值+五维能量双奖励 → 认知雷达记录」链路,将答题结果持久化并写入 `profile_snapshot.quiz_summary`。 **架构:** 前端阅读达标后调 `/quiz/generate`(后端经 AiGateway 调 LangGraph 出题,失败降级硬编码题,落库题目快照返回 recordId),作答后调 `/quiz/submit`(后端按 recordId 读库判分,发放 CF 值 + 五维能量,更新认知雷达)。AI 出题走 LangGraph 新 graph `article_quiz_graph`。 **技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus(后端)/ FastAPI + LangGraph + ChatOpenAI(Python 侧)/ uni-app Vue 2(前端) --- ## 文件结构(本计划涉及) ### 后端 cfc-backend | 文件 | 动作 | 职责 | |------|------|------| | `src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 修改 | 迁移 299 建 `article_quiz_records` 表、迁移 300 加 `quiz_summary` 列 | | `src/main/resources/schema.sql` | 修改 | 同步建表语句 + profile_snapshot 列 | | `src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java` | 修改 | 字段调整:`childId`→`memberId`、`pointsEarned`→`cfEarned`,新增 `totalQuestions`/`energyEarned` | | `src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java` | 修改 | 新增 `quizSummary` 字段 | | `src/main/java/com/etotem/cfc/service/ArticleService.java` | 修改 | 修复 `getAiQuestions`/`submitAnswers` 对旧字段名的引用 | | `src/main/java/com/etotem/cfc/service/AiGateway.java` | 修改 | 新增 `generateArticleQuiz` 方法 | | `src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java` | 修改 | 重写 `generateQuiz`/`submitQuiz`,新增 `updateQuizSummary` 辅助方法 | ### Python cfc-langgraph | 文件 | 动作 | 职责 | |------|------|------| | `app/graphs/article_quiz_graph.py` | 创建 | AI 出题 agent | | `app/models/article_quiz.py` | 创建 | Pydantic 请求/响应模型 | | `app/api/article_quiz.py` | 创建 | FastAPI 路由 | | `app/prompt_service.py` | 修改 | DEFAULT_PROMPTS 增加 `article_quiz` | | `app/main.py` | 修改 | 注册路由 | ### 前端 cfc-frontend | 文件 | 动作 | 职责 | |------|------|------| | `pages/article-center/article-detail.vue` | 修改 | 适配新接口结构、结果弹窗增加 CF 展示 | --- ## 任务 1:数据库迁移 + 实体字段调整 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` - 修改:`cfc-backend/src/main/resources/schema.sql` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java` - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java` - [ ] **步骤 1:在 `DatabaseInitializer.java` 的 `runMigrations()` 末尾追加两个迁移** 在 `runMigrations()` 方法体末尾(`profile_history` 表创建块之后)追加: ```java // 迁移299: 创建 article_quiz_records 表(文章阅读答题记录) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_quiz_records (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "article_id BIGINT DEFAULT NULL, " + "user_id BIGINT DEFAULT NULL, " + "member_id BIGINT DEFAULT NULL, " + "questions TEXT, " + "answers TEXT, " + "score INT DEFAULT 0, " + "total_questions INT DEFAULT 0, " + "cf_earned INT DEFAULT 0, " + "energy_earned INT DEFAULT 0, " + "created_at DATETIME, " + "updated_at DATETIME, " + "INDEX idx_member (member_id), " + "INDEX idx_article (article_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'"); log.info("已创建article_quiz_records表"); } catch (Exception e) { log.warn("创建article_quiz_records表可能已存在: {}", e.getMessage()); } // 迁移300: profile_snapshot 增加 quiz_summary 列(认知雷达答题汇总) ensureColumn("profile_snapshot", "quiz_summary", "JSON DEFAULT NULL COMMENT '答题认知雷达汇总'"); ``` - [ ] **步骤 2:同步 `schema.sql`** 在 `schema.sql` 中 `article_reading_records` 建表语句(约 4070 行)之后追加: ```sql -- 文章答题记录表 CREATE TABLE IF NOT EXISTS article_quiz_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, article_id BIGINT DEFAULT NULL COMMENT '文章ID', user_id BIGINT DEFAULT NULL COMMENT '用户ID', member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)', questions TEXT COMMENT '题目快照JSON(含正确答案与维度)', answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL', score INT DEFAULT 0 COMMENT '答对题数', total_questions INT DEFAULT 0 COMMENT '题目总数', cf_earned INT DEFAULT 0 COMMENT '发放的CF值', energy_earned INT DEFAULT 0 COMMENT '发放的能量值', created_at DATETIME, updated_at DATETIME, INDEX idx_member (member_id), INDEX idx_article (article_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'; ``` 并在 `profile_snapshot` 建表语句(约 5404 行)的 `problem_domains` 行之后、`computed_at` 行之前追加一列: ```sql quiz_summary JSON DEFAULT NULL COMMENT '答题认知雷达汇总', ``` - [ ] **步骤 3:调整 `ArticleQuizRecord.java` 实体** 将 `childId`/`pointsEarned` 字段替换为 `memberId`/`totalQuestions`/`cfEarned`/`energyEarned`: ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.util.Date; @TableName("article_quiz_records") public class ArticleQuizRecord { @TableId(type = IdType.AUTO) private Long id; private Long articleId; private Long userId; private Long memberId; private String questions; private String answers; private Integer score; private Integer totalQuestions; private Integer cfEarned; private Integer energyEarned; private Date createdAt; private Date updatedAt; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Long getArticleId() { return articleId; } public void setArticleId(Long articleId) { this.articleId = articleId; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public Long getMemberId() { return memberId; } public void setMemberId(Long memberId) { this.memberId = memberId; } public String getQuestions() { return questions; } public void setQuestions(String questions) { this.questions = questions; } public String getAnswers() { return answers; } public void setAnswers(String answers) { this.answers = answers; } public Integer getScore() { return score; } public void setScore(Integer score) { this.score = score; } public Integer getTotalQuestions() { return totalQuestions; } public void setTotalQuestions(Integer totalQuestions) { this.totalQuestions = totalQuestions; } public Integer getCfEarned() { return cfEarned; } public void setCfEarned(Integer cfEarned) { this.cfEarned = cfEarned; } public Integer getEnergyEarned() { return energyEarned; } public void setEnergyEarned(Integer energyEarned) { this.energyEarned = energyEarned; } public Date getCreatedAt() { return createdAt; } public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } public Date getUpdatedAt() { return updatedAt; } public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; } } ``` - [ ] **步骤 4:调整 `ProfileSnapshot.java` 实体,新增 `quizSummary` 字段** 在 `problemDomains` 字段之后追加: ```java private String quizSummary; ``` 并追加 getter/setter: ```java public String getQuizSummary() { return quizSummary; } public void setQuizSummary(String quizSummary) { this.quizSummary = quizSummary; } ``` - [ ] **步骤 5:修复 `ArticleService.java` 中 `getAiQuestions`/`submitAnswers` 对旧字段名的引用** `getAiQuestions`(约 797 行)中: ```java record.setChildId(memberId); // 改为 record.setMemberId(memberId); ``` ```java record.setPointsEarned(0); // 改为 record.setCfEarned(0); ``` `submitAnswers`(约 868 行)中: ```java record.setPointsEarned(pointsEarned); // 改为 record.setCfEarned(pointsEarned); ``` - [ ] **步骤 6:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS,无编译错误。 - [ ] **步骤 7:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java git commit -m "feat: 文章答题记录表迁移与实体字段调整" ``` --- ## 任务 2:LangGraph AI 出题 graph **文件:** - 创建:`cfc-langgraph/app/graphs/article_quiz_graph.py` - [ ] **步骤 1:创建 `article_quiz_graph.py`** ```python from langchain_openai import ChatOpenAI from langchain_core.messages import SystemMessage, HumanMessage from app.config import settings from app.monitoring import monitor_agent from app.prompt_service import get_prompt import json import logging logger = logging.getLogger(__name__) SYSTEM_PROMPT = """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。 要求: 1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头 2. 题目考察文章核心内容的理解,适合青少年读者 3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom 4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组 输出格式: [ {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"} ] """ class ArticleQuizAgent: def __init__(self): self.llm = ChatOpenAI( model=settings.llm_model, api_key=settings.llm_api_key, base_url=settings.llm_base_url, temperature=settings.llm_temperature, ) @monitor_agent("article_quiz") async def run(self, article_title: str, article_content: str, profile_summary: str = "") -> dict: """根据文章内容生成 3 道选择题,失败返回空 questions""" try: human = f"文章标题:{article_title}\n文章内容:{article_content}" if profile_summary: human += f"\n用户画像摘要:{profile_summary}" messages = [ SystemMessage(content=await get_prompt("article_quiz") or SYSTEM_PROMPT), HumanMessage(content=human), ] response = await self.llm.ainvoke(messages) content = response.content.strip() if "```json" in content: content = content.split("```json")[1].split("```")[0].strip() elif "```" in content: content = content.split("```")[1].split("```")[0].strip() questions = json.loads(content) if not isinstance(questions, list): return {"questions": [], "fallback_used": True} return {"questions": questions, "fallback_used": False} except Exception as e: logger.warning("文章出题生成失败: %s", e) return {"questions": [], "fallback_used": True} ``` - [ ] **步骤 2:语法校验** 运行:`cd cfc-langgraph && python -m py_compile app/graphs/article_quiz_graph.py` 预期:无输出,退出码 0。 - [ ] **步骤 3:Commit** ```bash git add cfc-langgraph/app/graphs/article_quiz_graph.py git commit -m "feat: 新增文章答题出题 graph" ``` --- ## 任务 3:LangGraph 模型 + 路由 + prompt 注册 **文件:** - 创建:`cfc-langgraph/app/models/article_quiz.py` - 创建:`cfc-langgraph/app/api/article_quiz.py` - 修改:`cfc-langgraph/app/prompt_service.py` - 修改:`cfc-langgraph/app/main.py` - [ ] **步骤 1:创建 `app/models/article_quiz.py`** ```python from pydantic import BaseModel from typing import List, Dict, Any class ArticleQuizRequest(BaseModel): article_title: str article_content: str = "" profile_summary: str = "" class ArticleQuizResponse(BaseModel): questions: List[Dict[str, Any]] = [] fallback_used: bool = True ``` - [ ] **步骤 2:创建 `app/api/article_quiz.py`** ```python from fastapi import APIRouter from app.models.article_quiz import ArticleQuizRequest, ArticleQuizResponse from app.graphs.article_quiz_graph import ArticleQuizAgent import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1", tags=["article_quiz"]) _agent = None def get_agent() -> ArticleQuizAgent: global _agent if _agent is None: _agent = ArticleQuizAgent() return _agent @router.post("/article/quiz/generate", response_model=ArticleQuizResponse) async def generate_article_quiz(req: ArticleQuizRequest): agent = get_agent() result = await agent.run(req.article_title, req.article_content, req.profile_summary) return ArticleQuizResponse(questions=result.get("questions", []), fallback_used=result.get("fallback_used", True)) ``` - [ ] **步骤 3:在 `prompt_service.py` 的 `DEFAULT_PROMPTS` 增加 `article_quiz` 条目** 在 `"recommend"` 条目(约 84 行结尾)之后、`}` 之前追加: ```python "article_quiz": """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。 要求: 1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头 2. 题目考察文章核心内容的理解,适合青少年读者 3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom 4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组 输出格式: [ {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"} ] """, ``` - [ ] **步骤 4:在 `main.py` 注册路由** 第 6 行 import 增加 `article_quiz`: ```python from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz ``` 在 `app.include_router(portrait.router)`(约 34 行)之后追加: ```python app.include_router(article_quiz.router) ``` - [ ] **步骤 5:语法校验** 运行:`cd cfc-langgraph && python -m py_compile app/models/article_quiz.py app/api/article_quiz.py app/prompt_service.py app/main.py` 预期:无输出,退出码 0。 - [ ] **步骤 6:Commit** ```bash git add cfc-langgraph/app/models/article_quiz.py cfc-langgraph/app/api/article_quiz.py cfc-langgraph/app/prompt_service.py cfc-langgraph/app/main.py git commit -m "feat: 注册文章答题出题路由与提示词" ``` --- ## 任务 4:AiGateway 新增 `generateArticleQuiz` 方法 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` - [ ] **步骤 1:在 `AiGateway.java` 末尾(`analyzeTongue` 方法之后、类右括号之前)新增方法** ```java /** * 生成文章阅读答题(调用 LangGraph article_quiz_graph) * @return 题目列表(每项含 question/options/answer/dimension/explanation);失败返回 null */ public List> generateArticleQuiz(String articleTitle, String articleContent, String profileSummary) { if (!enabled || isCircuitOpen()) return null; try { ObjectNode body = objectMapper.createObjectNode(); body.put("article_title", articleTitle != null ? articleTitle : ""); body.put("article_content", articleContent != null ? articleContent : ""); body.put("profile_summary", profileSummary != null ? profileSummary : ""); HttpEntity entity = new HttpEntity<>(body.toString(), createJsonHeaders()); String url = baseUrl + "/api/v1/article/quiz/generate"; ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { JsonNode root = objectMapper.readTree(response.getBody()); JsonNode questions = root.get("questions"); if (questions != null && questions.isArray() && questions.size() > 0) { List> list = objectMapper.convertValue(questions, List.class); consecutiveFailures.set(0); log.debug("AiGateway generateArticleQuiz 成功: {} 题", list.size()); return list; } } return null; } catch (Exception e) { log.warn("AiGateway generateArticleQuiz 调用失败: {}", e.getMessage()); recordFailure(); return null; } } ``` > 该方法使用的 `ObjectNode`/`JsonNode`/`HttpEntity`/`ResponseEntity`/`List`/`Map` 均已在文件顶部 import 中,无需新增 import。 - [ ] **步骤 2:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS。 - [ ] **步骤 3:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java git commit -m "feat: AiGateway 新增文章答题出题方法" ``` --- ## 任务 5:重写后端 quiz 接口(出题 + 判分 + 双奖励 + 认知雷达) **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java` - [ ] **步骤 1:新增 import 与依赖注入** 在文件顶部 import 区(`import com.etotem.cfc.service.EnergyService;` 之后)追加: ```java import com.etotem.cfc.entity.ArticleQuizRecord; import com.etotem.cfc.entity.ProfileSnapshot; import com.etotem.cfc.mapper.ArticleQuizRecordMapper; import com.etotem.cfc.mapper.ProfileSnapshotMapper; import com.etotem.cfc.service.AiGateway; import com.etotem.cfc.service.PointsService; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; ``` 在 `@Resource private EnergyService energyService;` 之后追加依赖注入: ```java @Resource private AiGateway aiGateway; @Resource private PointsService pointsService; @Resource private ArticleQuizRecordMapper articleQuizRecordMapper; @Resource private ProfileSnapshotMapper profileSnapshotMapper; ``` - [ ] **步骤 2:重写 `generateQuiz` 方法** 将现有 `generateQuiz`(85-105 行)整体替换为: ```java @PostMapping("/quiz/generate") public Result> generateQuiz(@RequestBody Map params, @RequestAttribute("userId") Long userId, @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) { Long articleId = ParamUtils.getLong(params.get("articleId")); Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId); Article article = articleMapper.selectById(articleId); if (article == null) return Result.error("文章不存在"); 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().eq(ProfileSnapshot::getMemberId, memberId)); if (snapshot != null && snapshot.getDimensionScores() != null) { profileSummary = snapshot.getDimensionScores(); } } // 优先走 AiGateway + LangGraph,失败降级硬编码 List> questions = null; try { 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 result = new HashMap<>(); result.put("recordId", record.getId()); result.put("questions", questions); return Result.success(result); } private Map q(String q, List opts, String a, String dimension) { Map m = new HashMap<>(); m.put("question", q); m.put("options", opts); m.put("answer", a); m.put("dimension", dimension); return m; } ``` - [ ] **步骤 3:重写 `submitQuiz` 方法** 将现有 `submitQuiz`(110-120 行)整体替换为: ```java @PostMapping("/quiz/submit") public Result> submitQuiz(@RequestBody Map params, @RequestAttribute("userId") Long userId, @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) { Long recordId = ParamUtils.getLong(params.get("recordId")); Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId); 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 answerList = new ArrayList<>(); try { JSONArray qArr = JSON.parseArray(record.getQuestions()); JSONArray aArr = JSON.parseArray(String.valueOf(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 < answerList.size(); i++) { JSONObject q = qArr.getJSONObject(i); String correct = q.getString("answer"); String selected = answerList.get(i); if (correct != null && selected != null && correct.equalsIgnoreCase(selected.trim())) { score++; } } } catch (Exception e) { log.warn("解析答题结果失败: {}", e.getMessage()); } // 奖励规则:每答对 1 题 +1 CF +5 能量,全对额外 +3 CF 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, record.getQuestions(), score, totalQuestions); Map result = new HashMap<>(); result.put("correctCount", score); result.put("totalQuestions", totalQuestions); result.put("cfEarned", cfEarned); result.put("energyEarned", energyEarned); return Result.success(result); } private void updateQuizSummary(Long memberId, String questionsJson, int score, int totalQuestions) { if (memberId == null) return; try { ProfileSnapshot snapshot = profileSnapshotMapper.selectOne( new LambdaQueryWrapper().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(); JSONArray qArr = JSON.parseArray(questionsJson); if (qArr != null) { for (int i = 0; i < qArr.size(); i++) { JSONObject q = qArr.getJSONObject(i); String dim = q.getString("dimension"); if (dim == null || dim.isEmpty()) dim = "wisdom"; JSONObject dimStat = byDim.getJSONObject(dim); if (dimStat == null) dimStat = new JSONObject(); dimStat.put("total_count", dimStat.getIntValue("total_count") + 1); dimStat.put("correct_rate", dimStat.getIntValue("total_count") > 0 ? (double) dimStat.getIntValue("correct_count") / dimStat.getIntValue("total_count") : 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()); } } ``` > 注意:`updateQuizSummary` 中的 `by_dimension` 正确数累加需在判分循环中记录每题维度与正确性。上面代码中 `dimStat` 的 `correct_count` 未按题累加——**需修正**:在 `submitQuiz` 判分循环中,同时构建 `Map`(dimension → [correct, total])传入 `updateQuizSummary`。见下方步骤 4 的修正版。 - [ ] **步骤 4:修正 `updateQuizSummary` 的维度正确数累加逻辑** 将 `submitQuiz` 中判分循环改为同时统计每题维度与正确性,并调整 `updateQuizSummary` 签名。完整替换 `submitQuiz` 方法及 `updateQuizSummary` 方法为以下修正版: ```java @PostMapping("/quiz/submit") public Result> submitQuiz(@RequestBody Map params, @RequestAttribute("userId") Long userId, @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) { Long recordId = ParamUtils.getLong(params.get("recordId")); Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId); 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 answerList = new ArrayList<>(); Map dimStats = new HashMap<>(); // dimension -> [correct, total] try { JSONArray qArr = JSON.parseArray(record.getQuestions()); JSONArray aArr = JSON.parseArray(String.valueOf(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 result = new HashMap<>(); result.put("correctCount", score); result.put("totalQuestions", totalQuestions); result.put("cfEarned", cfEarned); result.put("energyEarned", energyEarned); return Result.success(result); } private void updateQuizSummary(Long memberId, int score, int totalQuestions, Map dimStats) { if (memberId == null) return; try { ProfileSnapshot snapshot = profileSnapshotMapper.selectOne( new LambdaQueryWrapper().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 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()); } } ``` - [ ] **步骤 5:补充 import** `submitQuiz` 用到了 `SimpleDateFormat`,确认文件顶部已有 `import java.util.*;`(已存在,第 15 行),`SimpleDateFormat` 已涵盖。若无,则追加 `import java.text.SimpleDateFormat;`。 - [ ] **步骤 6:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS。 - [ ] **步骤 7:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java git commit -m "feat: 文章答题出题/提交/双奖励/认知雷达接口改造" ``` --- ## 任务 6:前端适配新接口结构 + 结果弹窗展示 CF **文件:** - 修改:`cfc-frontend/pages/article-center/article-detail.vue` - [ ] **步骤 1:`data()` 增加 `quizRecordId` 字段** 在 `data()` return 中 `quizResult: {},` 附近追加: ```js quizRecordId: null, ``` - [ ] **步骤 2:改 `startQuiz` 方法** 将现有 `startQuiz`(262-277 行)中 `generateQuiz` 调用后的处理改为适配 `{recordId, questions}` 结构: ```js async startQuiz() { try { var memRes = await getMyMembership() if (memRes.data && memRes.data.memberLevel && memRes.data.memberLevel === 'FREE') { uni.showToast({ title: '浠宝/福宝答题是会员专属', icon: 'none' }) setTimeout(function() { uni.navigateTo({ url: '/pages/membership/upgrade' }) }, 1500) return } var memberId = uni.getStorageSync('currentChildId') var res = await generateQuiz({ articleId: this.articleId, memberId: parseInt(memberId || 0) }) if (res.code === 200 && res.data && res.data.questions && res.data.questions.length > 0) { this.quizRecordId = res.data.recordId this.quizQuestions = res.data.questions this.quizAnswers = [] this.showQuiz = true } } catch (e) {} }, ``` - [ ] **步骤 3:改 `submitQuiz` 方法** 将现有 `submitQuiz`(278-292 行)替换为: ```js async submitQuiz() { var memberId = uni.getStorageSync('currentChildId') var answers = [] for (var i = 0; i < this.quizQuestions.length; i++) { answers.push(this.quizAnswers[i] || '') } try { var res = await submitQuiz({ recordId: this.quizRecordId, memberId: parseInt(memberId || 0), answers: answers }) if (res.code === 200) { this.quizResult = res.data || {} this.showQuiz = false this.showResult = true } } catch (e) {} }, ``` - [ ] **步骤 4:结果弹窗增加 CF 值展示** 将结果弹窗(约 80 行)的 `` 改为同时展示 CF 值与能量: ```html 获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡ ``` - [ ] **步骤 5:语法校验(仅 script 块)** 运行:`cd cfc-frontend && node --check <(sed -n '/