Browse Source

docs: 文章阅读答题奖励+认知雷达设计规格与实现计划

Sisyphus 22 hours ago
parent
commit
5c943f3ff9

+ 4 - 2
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,7 +1,7 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.11
-**日期:** 2026-09-10
+**文档版本:** v2.12
+**日期:** 2026-09-19
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)** + **关系三图重构(关键人拓展章鱼图/珍珠图/能力图,设计规格已完成)**
 **维护:** 所有需求变更需更新本文档
 
@@ -375,6 +375,7 @@
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
+| `2026-09-19-article-reading-quiz-design.md` | 🟡 设计规格已确认(补全现有阅读答题:建 article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 
 ### 实施计划(plans/)
 
@@ -455,6 +456,7 @@
 | `2026-08-31-self-check-ai-integration.md` | 🟡 待实施(11 Tasks:LangGraph×2 graph + 路由 / Java Service+AiGateway+Controller / 前端结果页+中间页+Chat 注入) | 五维自检 AI 结合 P0+P1 实现计划(设计稿:2026-08-31-self-check-ai-integration-design.md) |
 | `2026-09-04-task-system-unification.md` | 🟢 已实施(9 Tasks:tasks表加字段/模板加onboarding_sequence/DTO透传/GrowthTask迁入TaskService/Onboarding复用模板/旧表迁移/旧接口兼容/前端统一任务中心/收尾) | 统一任务系统实施计划(设计稿:2026-09-04-task-system-unification-design.md) |
 | `2026-09-12-vendor-order-review.md` | ✅ 已实施(5 Tasks:后端 review/update 接口 + 临时券/赠品/配送校验 + 前端 order-review 页面 + 列表入口 + vendorId 详情返回) | 供应商订单审核实施计划(设计稿:2026-09-12-vendor-order-review-design.md) |
+| `2026-09-19-article-reading-quiz.md` | 🟡 待执行(7 Tasks:迁移299-300建表加列 / 实体字段调整 / LangGraph article_quiz_graph+路由 / AiGateway / quiz接口改造 / 前端适配 / 编译验证) | 文章阅读答题奖励+认知雷达实施计划(设计稿:2026-09-19-article-reading-quiz-design.md) |
 
 ### 计划与设计文档(specs/)
 

+ 973 - 0
docs/superpowers/plans/2026-09-19-article-reading-quiz.md

@@ -0,0 +1,973 @@
+# 文章阅读后答题奖励 + 认知雷达记录 实现计划
+
+> **面向 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<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**
+
+```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<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 行)整体替换为:
+
+```java
+    @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 的修正版。
+
+- [ ] **步骤 4:修正 `updateQuizSummary` 的维度正确数累加逻辑**
+
+将 `submitQuiz` 中判分循环改为同时统计每题维度与正确性,并调整 `updateQuizSummary` 签名。完整替换 `submitQuiz` 方法及 `updateQuizSummary` 方法为以下修正版:
+
+```java
+    @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**
+
+```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 行)的 `<text class="result-energy">` 改为同时展示 CF 值与能量:
+
+```html
+        <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**
+
+```bash
+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**
+
+```bash
+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

+ 180 - 0
docs/superpowers/specs/2026-09-19-article-reading-quiz-design.md

@@ -0,0 +1,180 @@
+# 文章阅读后答题奖励 + 认知雷达记录 — 设计规格
+
+> 日期:2026-09-19
+> 状态:待审查
+> 范围:cfc-backend + cfc-langgraph + cfc-frontend(小程序文章详情页)
+
+## 1. 需求概述
+
+用户读完文章(达到阅读时长)后,系统根据文章内容 + 用户画像 AI 生成若干道选择题。用户作答后,根据答对数量发放 **CF 值** 和 **五维能量**,并将题目与作答情况写入用户的 **认知雷达**(`profile_snapshot` 扩展字段),作为后续精准推送的基础。
+
+## 2. 现状盘点
+
+| 环节 | 现状 | 差距 |
+|------|------|------|
+| 阅读计时/达标 | 前端计时 + `/api/articles/report-reading-time` + `/api/articles/complete-read`(+5能量) | 无 |
+| AI 出题 | `/api/articles/quiz/generate` 调 `aiService.sendWorkflow("article-quiz")`(Dify),失败回退 3 道硬编码题 | 未走 AiGateway + LangGraph |
+| 提交答题 | `/api/articles/quiz/submit` 只算分、发能量(答对×5),**未持久化**、未发 CF 值、未写画像 | 缺记录表、缺 CF、缺画像 |
+| 记录表 | `ArticleQuizRecord` 实体存在(`@TableName("article_quiz_records")`),但 schema.sql **无建表语句** | 需建表 |
+| 认知雷达 | `profile_snapshot` 表存在(含 `dimension_scores`/`problem_domains` JSON),无答题维度 | 需扩展 |
+| 奖励服务 | `PointsService.awardCfPoints(memberId, amount, reason)`、`EnergyService.awardEnergy(memberId, sourceType, sourceId, totalAmount, desc, daysToExpire)` 均可用 | 需接线 |
+
+## 3. 架构与数据流
+
+```
+用户读完文章(前端计时达标)
+        │
+        ├─ ① POST /api/articles/complete-read   → 发阅读能量(已存在)
+        │
+        ├─ ② POST /api/articles/quiz/generate   → AiGateway.generateArticleQuiz()
+        │        └─ LangGraph /api/v1/article/quiz/generate(article_quiz_graph)
+        │              失败 → 降级到现有硬编码 3 题 fallback
+        │
+        ├─ ③ 用户作答(前端 quiz 弹窗)
+        │
+        └─ ④ POST /api/articles/quiz/submit
+                 ├─ 落库 article_quiz_records(题目+作答+得分)
+                 ├─ 发 CF 值(PointsService.awardCfPoints)
+                 ├─ 发五维能量(EnergyService.awardEnergy,按文章关联维度分配)
+                 └─ 更新 profile_snapshot.quiz_summary(认知雷达)
+```
+
+## 4. 数据模型
+
+### 4.1 新增表 `article_quiz_records`
+
+实体 `ArticleQuizRecord` 已存在,建表时采用规范字段(表尚不存在,无需历史兼容,直接建最终结构):
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AI | 主键 |
+| article_id | BIGINT | 文章 ID |
+| user_id | BIGINT | 登录用户 ID |
+| member_id | BIGINT | 家庭成员 ID(作答人,统一口径,见 `docs/architecture/userid-vs-memberid-usage-guide.md`) |
+| questions | TEXT | 题目快照 JSON(含正确答案与维度) |
+| answers | TEXT | 用户作答 JSON(含每题所选选项),作答前为 NULL |
+| score | INT | 答对题数 |
+| total_questions | INT | 题目总数 |
+| cf_earned | INT | 发放的 CF 值 |
+| energy_earned | INT | 发放的能量值 |
+| created_at | DATETIME | 创建时间 |
+| updated_at | DATETIME | 更新时间 |
+
+> 实体现有 `childId`/`pointsEarned` 字段不再使用(表为全新创建,不引入 childId 口径);`questions`/`answers` 字段直接承载 JSON。实体需同步调整:去掉 `childId`/`pointsEarned`,新增 `memberId`/`totalQuestions`/`cfEarned`/`energyEarned`。
+
+### 4.2 扩展 `profile_snapshot`
+
+新增一列:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| quiz_summary | JSON | 答题认知雷达汇总:`{"quiz_count":N,"correct_rate":0.8,"by_dimension":{"wisdom":{"quiz_count":n,"correct_rate":r},...},"last_quiz_at":"..."}` |
+
+`by_dimension` 的 key 使用五维代码(body/mind/wisdom/action/wealth),来自文章的 `related_dimensions` / `dimension_weights`。
+
+## 5. AI 出题(LangGraph)
+
+### 5.1 新 graph:`cfc-langgraph/app/graphs/article_quiz_graph.py`
+
+- 输入:`article_title`、`article_content`(截断至合理长度)、`profile_summary`(用户画像摘要,可选)
+- 输出:3 道单选题,结构化 JSON:
+
+```json
+[
+  {
+    "question": "问题文本",
+    "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"],
+    "answer": "A",
+    "dimension": "wisdom",
+    "explanation": "解析文本(可选)"
+  }
+]
+```
+
+- `dimension` 取值与文章 `related_dimensions` 对齐,缺省为 `wisdom`。
+- 系统提示词走 `prompt_service.get_prompt("article_quiz")`,无则用内置 DEFAULT_PROMPTS 兜底(需在 `prompt_service.py` 的 `DEFAULT_PROMPTS` 增加 `article_quiz` 条目)。
+
+### 5.2 新路由:`cfc-langgraph/app/api/article_quiz.py`
+
+- `POST /api/v1/article/quiz/generate`,Pydantic 模型放 `app/models/article_quiz.py`。
+- 响应:`{"questions": [...], "fallback_used": false}`。
+- 在 `app/main.py` 注册 `include_router(article_quiz.router)`。
+
+### 5.3 AiGateway 新方法
+
+`AiGateway.generateArticleQuiz(articleTitle, articleContent, profileSummary)`:
+- 调 `baseUrl + "/api/v1/article/quiz/generate"`
+- 遵循现有熔断/超时/Fallback 模式,失败返回 `null`,由调用方降级。
+
+## 6. 后端接口改造
+
+### 6.1 `/api/articles/quiz/generate`(ArticleCommentController)
+
+- 改为:先调 `aiGateway.generateArticleQuiz(...)`,成功则取得题目列表。
+- 失败则降级到现有硬编码 3 题 fallback(保持不变)。
+- 组装 `profile_summary`:从 `profile_snapshot` 读取当前成员画像摘要传入 AI。
+- **建记录并返回 recordId**:将题目快照(含正确答案)落库到 `article_quiz_records`(`answers` 为 NULL),返回 `{recordId, questions}`。判分以库内答案为唯一依据,前端无法篡改。
+
+### 6.2 `/api/articles/quiz/submit`(ArticleCommentController)
+
+改造为完整落库 + 双奖励 + 画像更新:
+
+1. 接收 `recordId`、`memberId`、`answers`(含每题所选选项)。
+2. 按 `recordId` 读取库内题目快照,后端自行判分:计算 `score`(答对题数)、`totalQuestions`。
+3. 回填 `article_quiz_records`(作答 JSON + 得分 + 奖励额度),拒绝重复提交(`answers` 已非 NULL)。
+4. 发放奖励:
+   - **CF 值**:`pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励")`
+   - **五维能量**:`energyService.awardEnergy(memberId, "article_quiz", articleId, energyEarned, "文章答题奖励", null)`,按文章维度分配。
+5. 更新 `profile_snapshot.quiz_summary`(认知雷达汇总,见 4.2)。
+6. 返回 `{correctCount, totalQuestions, cfEarned, energyEarned}`。
+
+> 判分与现有 `ArticleService.getAiQuestions`/`submitAnswers` 模式一致(出题建记录 → 提交按 recordId 读库判分),保持代码风格统一。
+
+### 6.3 奖励规则(可配置)
+
+| 场景 | CF 值 | 能量 |
+|------|-------|------|
+| 阅读完成 | 0 | +5(已存在) |
+| 答题:每答对 1 题 | +1 | +5 |
+| 答题:全对额外奖励 | +3 | 0 |
+
+> 规则常量先硬编码在 Service 层,后续如需运营可配置,再迁移到 `sys_config`(YAGNI,本期不引入配置项)。
+
+## 7. 前端改造(cfc-frontend)
+
+文件:`pages/article-center/article-detail.vue`
+
+- `startQuiz`:调用 `generateQuiz` 不变,响应结构仍为题目数组(后端已兼容)。
+- `submitQuiz`:改为传「每题所选选项」而非 `correctCount`;结果展示改为同时显示 CF 值 + 能量。
+- 结果弹窗 `result-dialog`:增加「CF 值 +X」展示。
+
+> 前端遵循:禁止可选链 `?.`、禁止 CSS Grid、`:key` 用方法调用、日期用 `parseDate()`。小程序打包由 HBuilderX 完成,Agent 只做语法校验。
+
+## 8. 迁移清单
+
+`DatabaseInitializer.runMigrations()` 新增(最新迁移编号 298,递增):
+
+- **迁移 299**:创建 `article_quiz_records` 表。
+- **迁移 300**:`profile_snapshot` 增加 `quiz_summary` JSON 列(`ensureColumn`)。
+
+同步更新 `schema.sql`(建表语句 + profile_snapshot 列)。
+
+## 9. 错误处理与边界
+
+- **AI 出题失败**:降级到硬编码 fallback,不影响答题主流程。
+- **重复提交**:`article_quiz_records` 已存在作答记录(`answers_json` 非空)时拒绝重复提交(与现有 `submitAnswers` 逻辑一致)。
+- **成员不存在**:`awardCfPoints` 内部抛「孩子不存在」,提交接口需捕获并降级(能量/CF 失败不阻断答题结果返回)。
+- **文章无维度**:`dimension` 缺省 `wisdom`,`quiz_summary.by_dimension` 使用 `wisdom` 兜底。
+
+## 10. 测试
+
+- 后端:`mvn clean compile` 验证编译通过(项目当前唯一验证方式)。
+- LangGraph:本地 `uvicorn` 起服务后 curl `/api/v1/article/quiz/generate` 验证返回 3 题结构化 JSON。
+- 前端:`node --check` 校验修改的 script 块语法(不打包,HBuilderX 负责打包)。
+
+## 11. 成功标准
+
+- [ ] 阅读达标后弹出 AI 出题的答题弹窗(AI 失败时 fallback 3 题)。
+- [ ] 提交答题后:落库 `article_quiz_records`、发放 CF 值 + 五维能量、更新 `profile_snapshot.quiz_summary`。
+- [ ] 前端结果弹窗展示「答对 X/Y 题」「+CF 值」「+能量」。
+- [ ] `mvn clean compile` 通过。