2026-09-19-article-reading-quiz.md 40 KB

文章阅读后答题奖励 + 认知雷达记录 实现计划

面向 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 修改 字段调整:childIdmemberIdpointsEarnedcfEarned,新增 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.javarunMigrations() 末尾追加两个迁移

runMigrations() 方法体末尾(profile_history 表创建块之后)追加:

        // 迁移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.sqlarticle_reading_records 建表语句(约 4070 行)之后追加:

-- 文章答题记录表
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 行之前追加一列:

    quiz_summary JSON DEFAULT NULL COMMENT '答题认知雷达汇总',
  • 步骤 3:调整 ArticleQuizRecord.java 实体

childId/pointsEarned 字段替换为 memberId/totalQuestions/cfEarned/energyEarned

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 字段之后追加:

    private String quizSummary;

并追加 getter/setter:

    public String getQuizSummary() { return quizSummary; }
    public void setQuizSummary(String quizSummary) { this.quizSummary = quizSummary; }
  • 步骤 5:修复 ArticleService.javagetAiQuestions/submitAnswers 对旧字段名的引用

getAiQuestions(约 797 行)中:

        record.setChildId(memberId);      // 改为
        record.setMemberId(memberId);
        record.setPointsEarned(0);        // 改为
        record.setCfEarned(0);

submitAnswers(约 868 行)中:

        record.setPointsEarned(pointsEarned);   // 改为
        record.setCfEarned(pointsEarned);
  • 步骤 6:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS,无编译错误。

  • [ ] 步骤 7:Commit

    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

    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

    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

    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

    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.pyDEFAULT_PROMPTS 增加 article_quiz 条目

"recommend" 条目(约 84 行结尾)之后、} 之前追加:

    "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

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 行)之后追加:

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

    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 方法之后、类右括号之前)新增方法

    /**
     * 生成文章阅读答题(调用 LangGraph article_quiz_graph)
     * @return 题目列表(每项含 question/options/answer/dimension/explanation);失败返回 null
     */
    public List<Map<String, Object>> 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<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
            String url = baseUrl + "/api/v1/article/quiz/generate";
    
            ResponseEntity<String> 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<Map<String, Object>> 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

    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; 之后)追加:

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; 之后追加依赖注入:

    @Resource
    private AiGateway aiGateway;
    @Resource
    private PointsService pointsService;
    @Resource
    private ArticleQuizRecordMapper articleQuizRecordMapper;
    @Resource
    private ProfileSnapshotMapper profileSnapshotMapper;
  • 步骤 2:重写 generateQuiz 方法

将现有 generateQuiz(85-105 行)整体替换为:

    @PostMapping("/quiz/generate")
    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 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<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
            if (snapshot != null && snapshot.getDimensionScores() != null) {
                profileSummary = snapshot.getDimensionScores();
            }
        }

        // 优先走 AiGateway + LangGraph,失败降级硬编码
        List<Map<String, Object>> 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<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, 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;
    }
  • 步骤 3:重写 submitQuiz 方法

将现有 submitQuiz(110-120 行)整体替换为:

    @PostMapping("/quiz/submit")
    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> 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<String> 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<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);
    }

    private void updateQuizSummary(Long memberId, String questionsJson, int score, int totalQuestions) {
        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();
            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 正确数累加需在判分循环中记录每题维度与正确性。上面代码中 dimStatcorrect_count 未按题累加——需修正:在 submitQuiz 判分循环中,同时构建 Map<String, int[]>(dimension → [correct, total])传入 updateQuizSummary。见下方步骤 4 的修正版。

  • 步骤 4:修正 updateQuizSummary 的维度正确数累加逻辑

submitQuiz 中判分循环改为同时统计每题维度与正确性,并调整 updateQuizSummary 签名。完整替换 submitQuiz 方法及 updateQuizSummary 方法为以下修正版:

    @PostMapping("/quiz/submit")
    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> 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<String> answerList = new ArrayList<>();
        Map<String, int[]> 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<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);
    }

    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());
        }
    }
  • 步骤 5:补充 import

submitQuiz 用到了 SimpleDateFormat,确认文件顶部已有 import java.util.*;(已存在,第 15 行),SimpleDateFormat 已涵盖。若无,则追加 import java.text.SimpleDateFormat;

  • 步骤 6:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • [ ] 步骤 7:Commit

    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: {}, 附近追加:

      quizRecordId: null,
  • 步骤 2:改 startQuiz 方法

将现有 startQuiz(262-277 行)中 generateQuiz 调用后的处理改为适配 {recordId, questions} 结构:

    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 行)替换为:

    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 行)的 <text class="result-energy"> 改为同时展示 CF 值与能量:

        <text class="result-energy">获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡</text>
  • 步骤 5:语法校验(仅 script 块)

运行:cd cfc-frontend && node --check <(sed -n '/<script>/,/<\/script>/p' pages/article-center/article-detail.vue | sed '1d;$d') 预期:无语法错误输出。

注意:小程序打包由 HBuilderX 完成,本计划不执行 npm run build

  • [ ] 步骤 6:Commit

    git add cfc-frontend/pages/article-center/article-detail.vue
    git commit -m "feat: 文章答题弹窗适配新接口并展示CF值"
    

任务 7:整体编译验证与收尾

  • 步骤 1:后端编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • 步骤 2:LangGraph 语法验证

运行:cd cfc-langgraph && python -m py_compile app/graphs/article_quiz_graph.py app/models/article_quiz.py app/api/article_quiz.py app/prompt_service.py app/main.py 预期:无输出,退出码 0。

  • 步骤 3:更新 PROJECT-OVERVIEW.md 状态

docs/superpowers/PROJECT-OVERVIEW.md 中本次设计规格条目的状态从「🟡 设计规格已确认」改为「🟢 已实施」,并在 plans 索引中追加本计划文件条目。

  • [ ] 步骤 4:Commit

    git add docs/superpowers/PROJECT-OVERVIEW.md
    git commit -m "docs: 更新文章答题功能实施状态"
    

验证清单(对应规格成功标准)

  • 阅读达标后弹出 AI 出题答题弹窗(AI 失败时 fallback 3 题)—— 任务 2/5
  • 提交答题后落库 article_quiz_records、发放 CF 值 + 五维能量、更新 profile_snapshot.quiz_summary —— 任务 5
  • 前端结果弹窗展示「答对 X/Y 题」「+CF 值」「+能量」—— 任务 6
  • mvn clean compile 通过 —— 任务 7