面向 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(前端)
| 文件 | 动作 | 职责 |
|---|---|---|
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 辅助方法 |
| 文件 | 动作 | 职责 |
|---|---|---|
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 |
修改 | 注册路由 |
| 文件 | 动作 | 职责 |
|---|---|---|
pages/article-center/article-detail.vue |
修改 | 适配新接口结构、结果弹窗增加 CF 展示 |
文件:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.javacfc-backend/src/main/resources/schema.sqlcfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.javacfc-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 表创建块之后)追加:
// 迁移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 '答题认知雷达汇总'");
schema.sql在 schema.sql 中 article_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 '答题认知雷达汇总',
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; }
}
ProfileSnapshot.java 实体,新增 quizSummary 字段在 problemDomains 字段之后追加:
private String quizSummary;
并追加 getter/setter:
public String getQuizSummary() { return quizSummary; }
public void setQuizSummary(String quizSummary) { this.quizSummary = quizSummary; }
ArticleService.java 中 getAiQuestions/submitAnswers 对旧字段名的引用getAiQuestions(约 797 行)中:
record.setChildId(memberId); // 改为
record.setMemberId(memberId);
record.setPointsEarned(0); // 改为
record.setCfEarned(0);
submitAnswers(约 868 行)中:
record.setPointsEarned(pointsEarned); // 改为
record.setCfEarned(pointsEarned);
运行: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: 文章答题记录表迁移与实体字段调整"
文件:
创建: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"
文件:
cfc-langgraph/app/models/article_quiz.pycfc-langgraph/app/api/article_quiz.pycfc-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.py 的 DEFAULT_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": "解析"}
]
""",
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)
运行: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: 注册文章答题出题路由与提示词"
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。
运行: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 新增文章答题出题方法"
文件:
修改: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;
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;
}
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正确数累加需在判分循环中记录每题维度与正确性。上面代码中dimStat的correct_count未按题累加——需修正:在submitQuiz判分循环中,同时构建Map<String, int[]>(dimension → [correct, total])传入updateQuizSummary。见下方步骤 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());
}
}
submitQuiz 用到了 SimpleDateFormat,确认文件顶部已有 import java.util.*;(已存在,第 15 行),SimpleDateFormat 已涵盖。若无,则追加 import java.text.SimpleDateFormat;。
运行: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: 文章答题出题/提交/双奖励/认知雷达接口改造"
文件:
修改:cfc-frontend/pages/article-center/article-detail.vue
[ ] 步骤 1:data() 增加 quizRecordId 字段
在 data() return 中 quizResult: {}, 附近追加:
quizRecordId: null,
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) {}
},
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) {}
},
将结果弹窗(约 80 行)的 <text class="result-energy"> 改为同时展示 CF 值与能量:
<text class="result-energy">获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡</text>
运行: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值"
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS。
运行: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。
将 docs/superpowers/PROJECT-OVERVIEW.md 中本次设计规格条目的状态从「🟡 设计规格已确认」改为「🟢 已实施」,并在 plans 索引中追加本计划文件条目。
[ ] 步骤 4:Commit
git add docs/superpowers/PROJECT-OVERVIEW.md
git commit -m "docs: 更新文章答题功能实施状态"
article_quiz_records、发放 CF 值 + 五维能量、更新 profile_snapshot.quiz_summary —— 任务 5mvn clean compile 通过 —— 任务 7