瀏覽代碼

first commit

231250141 6 月之前
當前提交
e59f7e9672
共有 100 個文件被更改,包括 10438 次插入0 次删除
  1. 4 0
      .gitignore
  2. 221 0
      README.md
  3. 252 0
      pom.xml
  4. 14 0
      src/main/java/com/smartreview/SmartReviewApplication.java
  5. 79 0
      src/main/java/com/smartreview/configure/DashScopeConfig.java
  6. 51 0
      src/main/java/com/smartreview/configure/LoginInterceptor.java
  7. 33 0
      src/main/java/com/smartreview/configure/RedisConfig.java
  8. 38 0
      src/main/java/com/smartreview/configure/SecurityConfig.java
  9. 37 0
      src/main/java/com/smartreview/configure/WebMvcConfig.java
  10. 67 0
      src/main/java/com/smartreview/controller/BlockController.java
  11. 77 0
      src/main/java/com/smartreview/controller/CheckpointController.java
  12. 112 0
      src/main/java/com/smartreview/controller/DocumentController.java
  13. 50 0
      src/main/java/com/smartreview/controller/ReportController.java
  14. 101 0
      src/main/java/com/smartreview/controller/ReviewChatController.java
  15. 45 0
      src/main/java/com/smartreview/controller/UserController.java
  16. 23 0
      src/main/java/com/smartreview/enums/BlockStatus.java
  17. 11 0
      src/main/java/com/smartreview/enums/BlockType.java
  18. 41 0
      src/main/java/com/smartreview/enums/ConversationType.java
  19. 63 0
      src/main/java/com/smartreview/enums/DocumentStatus.java
  20. 19 0
      src/main/java/com/smartreview/enums/QuestionGenerationStatus.java
  21. 19 0
      src/main/java/com/smartreview/enums/QuestionSource.java
  22. 27 0
      src/main/java/com/smartreview/enums/QuestionStatus.java
  23. 19 0
      src/main/java/com/smartreview/enums/QuestionType.java
  24. 41 0
      src/main/java/com/smartreview/exception/GlobalExceptionHandler.java
  25. 39 0
      src/main/java/com/smartreview/exception/SmartReviewException.java
  26. 68 0
      src/main/java/com/smartreview/po/Block.java
  27. 41 0
      src/main/java/com/smartreview/po/Chapter.java
  28. 92 0
      src/main/java/com/smartreview/po/Conversation.java
  29. 129 0
      src/main/java/com/smartreview/po/Document.java
  30. 71 0
      src/main/java/com/smartreview/po/KnowledgePoint.java
  31. 97 0
      src/main/java/com/smartreview/po/Question.java
  32. 55 0
      src/main/java/com/smartreview/po/QuestionGenerationTask.java
  33. 59 0
      src/main/java/com/smartreview/po/ReviewChatMessage.java
  34. 39 0
      src/main/java/com/smartreview/po/User.java
  35. 53 0
      src/main/java/com/smartreview/po/UserAnswer.java
  36. 24 0
      src/main/java/com/smartreview/repository/BlockRepository.java
  37. 17 0
      src/main/java/com/smartreview/repository/ChapterRepository.java
  38. 65 0
      src/main/java/com/smartreview/repository/ConversationRepository.java
  39. 16 0
      src/main/java/com/smartreview/repository/DocumentRepository.java
  40. 26 0
      src/main/java/com/smartreview/repository/KnowledgePointRepository.java
  41. 28 0
      src/main/java/com/smartreview/repository/QuestionGenerationTaskRepository.java
  42. 46 0
      src/main/java/com/smartreview/repository/QuestionRepository.java
  43. 36 0
      src/main/java/com/smartreview/repository/ReviewChatMessageRepository.java
  44. 42 0
      src/main/java/com/smartreview/repository/UserAnswerRepository.java
  45. 15 0
      src/main/java/com/smartreview/repository/UserRepository.java
  46. 93 0
      src/main/java/com/smartreview/service/AIService.java
  47. 32 0
      src/main/java/com/smartreview/service/BlockService.java
  48. 24 0
      src/main/java/com/smartreview/service/DocumentCleanupService.java
  49. 64 0
      src/main/java/com/smartreview/service/DocumentService.java
  50. 21 0
      src/main/java/com/smartreview/service/KnowledgeIndexerService.java
  51. 72 0
      src/main/java/com/smartreview/service/MineruService.java
  52. 52 0
      src/main/java/com/smartreview/service/QuestionService.java
  53. 19 0
      src/main/java/com/smartreview/service/ReportService.java
  54. 77 0
      src/main/java/com/smartreview/service/ReviewChatService.java
  55. 30 0
      src/main/java/com/smartreview/service/UserService.java
  56. 596 0
      src/main/java/com/smartreview/service/impl/AIServiceImpl.java
  57. 254 0
      src/main/java/com/smartreview/service/impl/BlockServiceImpl.java
  58. 104 0
      src/main/java/com/smartreview/service/impl/DocumentCleanupServiceImpl.java
  59. 895 0
      src/main/java/com/smartreview/service/impl/DocumentServiceImpl.java
  60. 175 0
      src/main/java/com/smartreview/service/impl/KnowledgeIndexerServiceImpl.java
  61. 334 0
      src/main/java/com/smartreview/service/impl/MineruServiceImpl.java
  62. 542 0
      src/main/java/com/smartreview/service/impl/QuestionServiceImpl.java
  63. 2109 0
      src/main/java/com/smartreview/service/impl/ReportServiceImpl.java
  64. 459 0
      src/main/java/com/smartreview/service/impl/ReviewChatServiceImpl.java
  65. 72 0
      src/main/java/com/smartreview/service/impl/UserServiceImpl.java
  66. 387 0
      src/main/java/com/smartreview/utils/MarkdownParser.java
  67. 70 0
      src/main/java/com/smartreview/utils/OssUtil.java
  68. 86 0
      src/main/java/com/smartreview/utils/SecurityUtil.java
  69. 35 0
      src/main/java/com/smartreview/utils/StringListJsonConverter.java
  70. 73 0
      src/main/java/com/smartreview/utils/TokenUtil.java
  71. 50 0
      src/main/java/com/smartreview/vo/Response.java
  72. 35 0
      src/main/java/com/smartreview/vo/block/BlockDetailVO.java
  73. 14 0
      src/main/java/com/smartreview/vo/block/UpdateBlockContentRequestVO.java
  74. 12 0
      src/main/java/com/smartreview/vo/block/UpdateBlockProgressRequestVO.java
  75. 42 0
      src/main/java/com/smartreview/vo/chat/ChatMessageVO.java
  76. 76 0
      src/main/java/com/smartreview/vo/chat/ChatStreamResponseVO.java
  77. 19 0
      src/main/java/com/smartreview/vo/chat/ConversationDetailVO.java
  78. 73 0
      src/main/java/com/smartreview/vo/chat/ConversationVO.java
  79. 46 0
      src/main/java/com/smartreview/vo/chat/CreateConversationRequestVO.java
  80. 30 0
      src/main/java/com/smartreview/vo/chat/ReviewChatRequestVO.java
  81. 26 0
      src/main/java/com/smartreview/vo/chat/ReviewChatResponseVO.java
  82. 20 0
      src/main/java/com/smartreview/vo/chat/SendMessageRequestVO.java
  83. 20 0
      src/main/java/com/smartreview/vo/document/BlockBriefVO.java
  84. 17 0
      src/main/java/com/smartreview/vo/document/ChapterTreeVO.java
  85. 19 0
      src/main/java/com/smartreview/vo/document/DocumentDetailVO.java
  86. 21 0
      src/main/java/com/smartreview/vo/document/DocumentListVO.java
  87. 114 0
      src/main/java/com/smartreview/vo/document/DocumentParseStatusVO.java
  88. 22 0
      src/main/java/com/smartreview/vo/document/ParseDocumentRequestVO.java
  89. 51 0
      src/main/java/com/smartreview/vo/document/ParseDocumentV2RequestVO.java
  90. 26 0
      src/main/java/com/smartreview/vo/document/ParseProgressVO.java
  91. 82 0
      src/main/java/com/smartreview/vo/mineru/MineruBatchUploadRequest.java
  92. 52 0
      src/main/java/com/smartreview/vo/mineru/MineruBatchUploadResponse.java
  93. 19 0
      src/main/java/com/smartreview/vo/mineru/MineruCallbackRequest.java
  94. 76 0
      src/main/java/com/smartreview/vo/mineru/MineruTaskRequest.java
  95. 44 0
      src/main/java/com/smartreview/vo/mineru/MineruTaskResponse.java
  96. 127 0
      src/main/java/com/smartreview/vo/mineru/MineruTaskResultResponse.java
  97. 64 0
      src/main/java/com/smartreview/vo/question/QuestionGenerationResponseVO.java
  98. 25 0
      src/main/java/com/smartreview/vo/question/QuestionVO.java
  99. 16 0
      src/main/java/com/smartreview/vo/question/SubmitAnswerRequestVO.java
  100. 23 0
      src/main/java/com/smartreview/vo/question/SubmitAnswerResponseVO.java

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+.idea
+target
+Smart-Review-Backend.iml
+src/main/resources/.env

+ 221 - 0
README.md

@@ -0,0 +1,221 @@
+# 智能复习平台后端 (Smart Review Backend)
+
+基于 Spring Boot 3.2 的智能复习平台后端服务,集成阿里云百炼 AI 和 OSS 存储。
+
+## 技术栈
+
+- **框架**: Spring Boot 3.2.0
+- **数据库**: MySQL 8.0 + Spring Data JPA
+- **缓存**: Redis
+- **AI服务**: 阿里云百炼 (DashScope)
+- **存储**: 阿里云 OSS
+- **认证**: JWT
+- **文档解析**: flexmark-java (Markdown)
+- **PDF导出**: iText7 + html2pdf
+
+## 项目结构
+
+```
+src/main/java/com/smartreview/
+├── SmartReviewApplication.java    # 主启动类
+├── configure/                      # 配置类
+│   ├── DashScopeConfig.java       # AI配置
+│   ├── LoginInterceptor.java      # 登录拦截器
+│   ├── RedisConfig.java           # Redis配置
+│   ├── SecurityConfig.java        # 安全配置
+│   └── WebMvcConfig.java          # Web配置
+├── controller/                     # 控制器层
+│   ├── UserController.java        # 用户认证
+│   ├── DocumentController.java    # 文档管理
+│   ├── BlockController.java       # 内容块
+│   ├── CheckpointController.java  # 题目提交
+│   ├── ReviewChatController.java  # AI对话
+│   └── ReportController.java      # 复习报告
+├── service/                        # 服务层
+│   ├── impl/                      # 服务实现
+│   └── AIService.java             # AI服务接口
+├── repository/                     # 数据访问层
+├── po/                            # 实体类
+├── vo/                            # 值对象
+├── enums/                         # 枚举类
+├── exception/                     # 异常处理
+└── utils/                         # 工具类
+```
+
+## API 接口
+
+### 用户认证模块
+| 接口                 | 方法 | 说明             |
+| -------------------- | ---- | ---------------- |
+| `/api/user/login`    | POST | 用户登录         |
+| `/api/user/register` | POST | 用户注册         |
+| `/api/user/info`     | GET  | 获取当前用户信息 |
+
+### 文档管理模块
+| 接口                   | 方法      | 说明                              |
+| ---------------------- | --------- | --------------------------------- |
+| `/api/document/upload` | POST      | 上传MD文档到OSS                   |
+| `/api/document/parse`  | POST(SSE) | 解析文档 → 切分Block → 提取知识点 |
+| `/api/document/list`   | GET       | 获取用户所有复习项目              |
+| `/api/document/{id}`   | GET       | 获取文档详情 + 目录树             |
+| `/api/document/{id}`   | DELETE    | 删除复习项目                      |
+
+### 复习学习模块
+| 接口                                     | 方法 | 说明                   |
+| ---------------------------------------- | ---- | ---------------------- |
+| `/api/block/{blockId}`                   | GET  | 获取Block原文内容      |
+| `/api/block/{blockId}/progress`          | PUT  | 更新Block学习状态      |
+| `/api/block/{blockId}/generate-question` | POST | 动态生成Checkpoint题目 |
+| `/api/checkpoint/submit`                 | POST | 提交答案,返回批改结果 |
+
+### AI助教模块(支持多轮对话)
+| 接口                                | 方法      | 说明                       |
+| ----------------------------------- | --------- | -------------------------- |
+| `/api/chat/conversation`            | POST      | 创建新对话                 |
+| `/api/chat/conversations`           | GET       | 获取对话列表               |
+| `/api/chat/conversation/{id}`       | GET       | 获取对话详情(含消息历史) |
+| `/api/chat/message`                 | POST(SSE) | 发送消息(流式响应)       |
+| `/api/chat/conversation/{id}/close` | POST      | 关闭对话                   |
+| `/api/chat/conversation/{id}`       | DELETE    | 删除对话                   |
+
+### 复习报告模块
+| 接口                           | 方法 | 说明             |
+| ------------------------------ | ---- | ---------------- |
+| `/api/report/{documentId}`     | GET  | 获取复习报告数据 |
+| `/api/report/{documentId}/pdf` | GET  | 导出PDF报告      |
+
+## 数据库表
+
+1. **users** - 用户表
+2. **documents** - 复习文档表
+3. **chapters** - 章节表
+4. **blocks** - 内容块表
+5. **knowledge_points** - 知识点表
+6. **questions** - 题目表
+7. **user_answers** - 答题记录表
+8. **conversations** - 对话会话表
+9. **chat_messages** - 对话消息表
+
+## 配置说明
+
+编辑 `src/main/resources/application-dev.yml`:
+
+```yaml
+spring:
+  datasource:
+    url: jdbc:mysql://localhost:3306/smart_review
+    username: your_username
+    password: your_password
+
+aliyun:
+  oss:
+    endpoint: oss-cn-hangzhou.aliyuncs.com
+    accessKeyId: your_access_key_id
+    accessKeySecret: your_access_key_secret
+    bucketName: your_bucket_name
+
+dashscope:
+  api-key: your_dashscope_api_key
+  model: qwen-plus  # 默认使用的模型
+  models:
+    knowledge-extractor: qwen-plus  # 知识点提取
+    question-generator: qwen-plus   # 题目生成
+    answer-grader: qwen-plus        # 问答题批改
+    review-assistant: qwen-plus     # 复习助教对话
+```
+
+## 运行项目
+
+1. 创建MySQL数据库:
+```sql
+CREATE DATABASE smart_review CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+```
+
+2. 配置 `application-dev.yml` 中的数据库、OSS、DashScope信息
+
+3. 启动Redis服务
+
+4. 运行项目:
+```bash
+mvn spring-boot:run
+```
+
+## AI模型说明
+
+本项目直接调用阿里云 DashScope 的大语言模型(如 qwen-plus),而不是调用智能体应用。
+
+### 模型功能配置
+
+在 `application-dev.yml` 中配置各功能使用的模型:
+
+```yaml
+dashscope:
+  api-key: your_api_key
+  model: qwen-plus  # 默认模型
+  models:
+    knowledge-extractor: qwen-plus  # 知识点提取
+    question-generator: qwen-plus   # 题目生成
+    answer-grader: qwen-plus        # 答案批改
+    review-assistant: qwen-plus     # AI对话助教
+```
+
+### 1. 知识点提取 (knowledge-extractor)
+**用途**: 从Block内容中提取3-5个关键知识点
+**输出格式**: JSON数组
+```json
+[
+  {"content": "知识点描述", "sourceText": "原文定位文字"}
+]
+```
+
+### 2. 题目生成 (question-generator)
+**用途**: 根据Block内容、知识点、用户提问历史动态生成题目
+**输出格式**:
+```json
+{
+  "type": "SINGLE/MULTIPLE/ESSAY",
+  "questionText": "题目内容",
+  "options": ["A. 选项1", ...],
+  "correctAnswer": "正确答案",
+  "explanation": "解析",
+  "sourceEvidence": "原文依据"
+}
+```
+
+### 3. 问答题批改 (answer-grader)
+**用途**: 批改问答题答案
+**输出格式**:
+```json
+{
+  "score": 85,
+  "feedback": "反馈意见",
+  "reviewSuggestion": "回看建议"
+}
+```
+
+### 4. 复习助教对话 (review-assistant)
+**用途**: 支持自由对话和划词提问,帮助学生理解知识点
+**特性**: 
+- 支持多轮对话,保持上下文
+- 支持流式响应
+- 两种对话类型:FREE_CHAT(自由对话)、KNOWLEDGE_QA(知识点问答/划词提问)
+
+## 多轮对话功能
+
+### 对话类型
+1. **FREE_CHAT(自由对话)**: 用户可以自由向AI助教提问
+2. **KNOWLEDGE_QA(知识点问答)**: 用户划词选中文本后进行提问
+
+### 对话流程
+1. 创建对话 `POST /api/chat/conversation`
+2. 发送消息 `POST /api/chat/message`(支持多轮)
+3. 获取历史 `GET /api/chat/conversation/{id}`
+4. 关闭/删除对话
+
+## 核心功能
+
+1. **文档解析**: 使用Markdown库解析MD文件,按标题层级切分章节,按段落切分Block
+2. **知识点提取**: AI自动从每个Block中提取3-5个关键知识点
+3. **动态题目生成**: 根据Block内容、知识点、用户提问历史智能生成checkpoint题目
+4. **划词提问**: 用户选中文本后可向AI助教提问,提问内容会记录为知识点用于后续出题
+5. **复习报告**: 生成包含进度、正确率、薄弱点、错题的复习报告,支持PDF导出

+ 252 - 0
pom.xml

@@ -0,0 +1,252 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.smartreview</groupId>
+    <artifactId>Smart-Review-Backend</artifactId>
+    <version>2.0</version>
+
+    <properties>
+        <java.version>17</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <spring-boot.version>3.2.0</spring-boot.version>
+        <spring-ai.version>1.0.0</spring-ai.version>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Boot Core -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Spring Data JPA -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+
+        <!-- MySQL -->
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <version>8.0.31</version>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- H2 for testing -->
+        <dependency>
+            <groupId>com.h2database</groupId>
+            <artifactId>h2</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Spring Security -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Redis -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+
+        <!-- DashScope SDK for AI -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>dashscope-sdk-java</artifactId>
+            <version>2.21.12</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-simple</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <!-- Aliyun OSS -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+            <version>3.15.1</version>
+        </dependency>
+
+        <!-- JWT -->
+        <dependency>
+            <groupId>com.auth0</groupId>
+            <artifactId>java-jwt</artifactId>
+            <version>3.10.3</version>
+        </dependency>
+
+        <!-- Lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+        </dependency>
+
+        <!-- MapStruct -->
+        <dependency>
+            <groupId>org.mapstruct</groupId>
+            <artifactId>mapstruct</artifactId>
+            <version>1.6.2</version>
+        </dependency>
+        <dependency>
+            <groupId>org.mapstruct</groupId>
+            <artifactId>mapstruct-processor</artifactId>
+            <version>1.6.2</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Markdown Parser - flexmark -->
+        <dependency>
+            <groupId>com.vladsch.flexmark</groupId>
+            <artifactId>flexmark-all</artifactId>
+            <version>0.64.8</version>
+        </dependency>
+
+        <!-- PDF Export - iText -->
+        <dependency>
+            <groupId>com.itextpdf</groupId>
+            <artifactId>itext7-core</artifactId>
+            <version>7.2.5</version>
+            <type>pom</type>
+        </dependency>
+        <dependency>
+            <groupId>com.itextpdf</groupId>
+            <artifactId>html2pdf</artifactId>
+            <version>4.0.5</version>
+        </dependency>
+
+        <!-- Logging -->
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+        </dependency>
+
+        <!-- Actuator -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+
+        <!-- JSON Processing -->
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+            <version>2.10.1</version>
+        </dependency>
+
+        <!-- Apache Commons Text for fuzzy matching -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-text</artifactId>
+            <version>1.11.0</version>
+        </dependency>
+
+        <!-- JLaTeXMath for LaTeX formula rendering -->
+        <dependency>
+            <groupId>org.scilab.forge</groupId>
+            <artifactId>jlatexmath</artifactId>
+            <version>1.0.7</version>
+        </dependency>
+    </dependencies>
+
+    <repositories>
+        <repository>
+            <id>spring-milestones</id>
+            <name>Spring Milestones</name>
+            <url>https://repo.spring.io/milestone</url>
+            <snapshots>
+                <enabled>false</enabled>
+            </snapshots>
+        </repository>
+    </repositories>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring-boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+                <includes>
+                    <include>application.yml</include>
+                    <include>application-dev.yml</include>
+                    <include>application-prod.yml</include>
+                    <include>**/*.xml</include>
+                </includes>
+            </resource>
+            <!-- 字体文件等二进制资源(不进行filtering,否则会损坏) -->
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>false</filtering>
+                <includes>
+                    <include>font/**</include>
+                </includes>
+            </resource>
+        </resources>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.8.1</version>
+                <configuration>
+                    <source>17</source>
+                    <target>17</target>
+                    <encoding>UTF-8</encoding>
+                    <compilerArgument>-parameters</compilerArgument>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>${spring-boot.version}</version>
+                <configuration>
+                    <mainClass>com.smartreview.SmartReviewApplication</mainClass>
+                    <skip>false</skip>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>repackage</id>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 14 - 0
src/main/java/com/smartreview/SmartReviewApplication.java

@@ -0,0 +1,14 @@
+package com.smartreview;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+@EnableAsync
+@SpringBootApplication
+public class SmartReviewApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(SmartReviewApplication.class, args);
+    }
+}

+ 79 - 0
src/main/java/com/smartreview/configure/DashScopeConfig.java

@@ -0,0 +1,79 @@
+package com.smartreview.configure;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * DashScope配置(直接调用AI模型)
+ */
+@Data
+@Component
+@ConfigurationProperties("dashscope")
+public class DashScopeConfig {
+
+    /**
+     * API密钥
+     */
+    private String apiKey;
+
+    /**
+     * 默认模型名称
+     */
+    private String model = "qwen-plus";
+
+    /**
+     * 各功能使用的模型配置
+     */
+    private Map<String, String> models;
+
+    /**
+     * 获取指定功能的模型名称
+     */
+    public String getModel(String functionKey) {
+        if (models != null && models.containsKey(functionKey)) {
+            String modelName = models.get(functionKey);
+            if (modelName != null && !modelName.isBlank()) {
+                return modelName;
+            }
+        }
+        return model;
+    }
+
+    /**
+     * 获取知识点提取模型
+     */
+    public String getKnowledgeExtractorModel() {
+        return getModel("knowledge-extractor");
+    }
+
+    /**
+     * 获取题目生成模型
+     */
+    public String getQuestionGeneratorModel() {
+        return getModel("question-generator");
+    }
+
+    /**
+     * 获取答案批改模型
+     */
+    public String getAnswerGraderModel() {
+        return getModel("answer-grader");
+    }
+
+    /**
+     * 获取复习助教模型
+     */
+    public String getReviewAssistantModel() {
+        return getModel("review-assistant");
+    }
+
+    /**
+     * 获取对话标题生成模型
+     */
+    public String getTitleGeneratorModel() {
+        return getModel("title-generator");
+    }
+}

+ 51 - 0
src/main/java/com/smartreview/configure/LoginInterceptor.java

@@ -0,0 +1,51 @@
+package com.smartreview.configure;
+
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.User;
+import com.smartreview.utils.TokenUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * 登录拦截器
+ */
+@Component
+@RequiredArgsConstructor
+public class LoginInterceptor implements HandlerInterceptor {
+
+    private final TokenUtil tokenUtil;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        // 预检请求放行
+        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+            return true;
+        }
+
+        // 获取token
+        String token = request.getHeader("token");
+        if (token == null || token.isEmpty()) {
+            token = request.getHeader("Authorization");
+            if (token != null && token.startsWith("Bearer ")) {
+                token = token.substring(7);
+            }
+        }
+
+        // 验证token
+        if (!tokenUtil.verifyToken(token)) {
+            throw SmartReviewException.notLogin();
+        }
+
+        // 获取用户信息并存入session
+        User user = tokenUtil.getUser(token);
+        if (user == null) {
+            throw SmartReviewException.notLogin();
+        }
+
+        request.getSession().setAttribute("currentUser", user);
+        return true;
+    }
+}

+ 33 - 0
src/main/java/com/smartreview/configure/RedisConfig.java

@@ -0,0 +1,33 @@
+package com.smartreview.configure;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+/**
+ * Redis配置
+ * 这个类暂时没有用到,但保留以备将来使用
+ */
+@Configuration
+public class RedisConfig {
+
+    @Bean
+    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 使用String序列化器作为key的序列化器
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setHashKeySerializer(new StringRedisSerializer());
+
+        // 使用JSON序列化器作为value的序列化器
+        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
+        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
+
+        template.afterPropertiesSet();
+        return template;
+    }
+}

+ 38 - 0
src/main/java/com/smartreview/configure/SecurityConfig.java

@@ -0,0 +1,38 @@
+package com.smartreview.configure;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * Spring Security配置
+ */
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+    @Bean
+    public PasswordEncoder passwordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+
+    @Bean
+    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+        http
+                .csrf(AbstractHttpConfigurer::disable)
+                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+                .authorizeHttpRequests(auth -> auth
+                        // 公开接口
+                        .requestMatchers("/api/user/login", "/api/user/register").permitAll()
+                        // 其他接口需要认证(由拦截器处理)
+                        .anyRequest().permitAll());
+
+        return http.build();
+    }
+}

+ 37 - 0
src/main/java/com/smartreview/configure/WebMvcConfig.java

@@ -0,0 +1,37 @@
+package com.smartreview.configure;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Web MVC配置
+ */
+@Configuration
+@RequiredArgsConstructor
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    private final LoginInterceptor loginInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(loginInterceptor)
+                .addPathPatterns("/api/**")
+                .excludePathPatterns(
+                        "/api/user/login",
+                        "/api/user/register",
+                        "/api/document/callback/mineru");
+    }
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+                .allowedOriginPatterns("*")
+                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+                .allowedHeaders("*")
+                .allowCredentials(true)
+                .maxAge(3600);
+    }
+}

+ 67 - 0
src/main/java/com/smartreview/controller/BlockController.java

@@ -0,0 +1,67 @@
+package com.smartreview.controller;
+
+import com.smartreview.service.BlockService;
+import com.smartreview.utils.SecurityUtil;
+import com.smartreview.vo.Response;
+import com.smartreview.vo.block.BlockDetailVO;
+import com.smartreview.vo.block.UpdateBlockContentRequestVO;
+import com.smartreview.vo.block.UpdateBlockProgressRequestVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 内容块Block
+ */
+@RestController
+@RequestMapping("/api/block")
+@RequiredArgsConstructor
+public class BlockController {
+
+    private final BlockService blockService;
+    private final SecurityUtil securityUtil;
+
+    /**
+     * 获取Block详情
+     */
+    @GetMapping("/{blockId}")
+    public Response<BlockDetailVO> getBlockDetail(@PathVariable Long blockId) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(blockService.getBlockDetail(blockId, userId));
+    }
+
+    /**
+     * 更新Block进度状态
+     */
+    @PutMapping("/{blockId}/progress")
+    public Response<Void> updateBlockProgress(
+            @PathVariable Long blockId,
+            @RequestBody UpdateBlockProgressRequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        blockService.updateBlockProgress(blockId, request.getStatus(), userId);
+        return Response.success();
+    }
+
+    /**
+     * 修改Block内容
+     * 修改后会清除关联的知识点、题目、用户答案,并重新触发知识点生成
+     */
+    @PutMapping("/{blockId}/content")
+    public Response<Void> updateBlockContent(
+            @PathVariable Long blockId,
+            @RequestBody UpdateBlockContentRequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        blockService.updateBlockContent(blockId, request.getContent(), userId);
+        return Response.success();
+    }
+
+    /**
+     * 删除Block
+     * 删除Block及其关联数据(知识点、题目、用户答案等)
+     */
+    @DeleteMapping("/{blockId}")
+    public Response<Void> deleteBlock(@PathVariable Long blockId) {
+        Long userId = securityUtil.getCurrentUserId();
+        blockService.deleteBlock(blockId, userId);
+        return Response.success();
+    }
+}

+ 77 - 0
src/main/java/com/smartreview/controller/CheckpointController.java

@@ -0,0 +1,77 @@
+package com.smartreview.controller;
+
+import com.smartreview.enums.QuestionType;
+import com.smartreview.service.QuestionService;
+import com.smartreview.utils.SecurityUtil;
+import com.smartreview.vo.Response;
+import com.smartreview.vo.question.QuestionGenerationResponseVO;
+import com.smartreview.vo.question.QuestionVO;
+import com.smartreview.vo.question.SubmitAnswerRequestVO;
+import com.smartreview.vo.question.SubmitAnswerResponseVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 题目Checkpoint
+ */
+@RestController
+@RequestMapping("/api/checkpoint")
+@RequiredArgsConstructor
+public class CheckpointController {
+
+    private final QuestionService questionService;
+    private final SecurityUtil securityUtil;
+
+    /**
+     * 获取或生成题目(异步)
+     *
+     * @param blockId  块ID
+     * @param forceNew 是否强制生成新题目(默认false)
+     * @return 生成响应(包含状态和题目)
+     */
+    @GetMapping("/generate/{blockId}")
+    public Response<QuestionGenerationResponseVO> getOrGenerateQuestions(
+            @PathVariable Long blockId,
+            @RequestParam(required = false, defaultValue = "false") boolean forceNew) {
+        Long userId = securityUtil.getCurrentUserId();
+        QuestionGenerationResponseVO response = questionService.getOrGenerateQuestions(blockId, userId, forceNew);
+        return Response.success(response);
+    }
+
+    /**
+     * 提交答案并批改
+     */
+    @PostMapping("/submit")
+    public Response<SubmitAnswerResponseVO> submitAnswer(@RequestBody SubmitAnswerRequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        SubmitAnswerResponseVO response = questionService.submitAnswer(
+                request.getQuestionId(),
+                request.getAnswer(),
+                userId,
+                request.getIsWeak());
+        return Response.success(response);
+    }
+
+    /**
+     * 批量提交答案并批改
+     */
+    @PostMapping("/submit/batch")
+    public Response<List<SubmitAnswerResponseVO>> submitAnswers(@RequestBody List<SubmitAnswerRequestVO> requests) {
+        Long userId = securityUtil.getCurrentUserId();
+        List<SubmitAnswerResponseVO> response = questionService.submitAnswers(requests, userId);
+        return Response.success(response);
+    }
+
+    /**
+     * 重新生成题目
+     */
+    @PostMapping("/regenerate/{questionId}")
+    public Response<QuestionVO> regenerateQuestion(@PathVariable Long questionId,
+                                                   @RequestParam(required = false) QuestionType type) {
+        Long userId = securityUtil.getCurrentUserId();
+        QuestionVO response = questionService.regenerateQuestion(questionId, userId,type);
+        return Response.success(response);
+    }
+}

+ 112 - 0
src/main/java/com/smartreview/controller/DocumentController.java

@@ -0,0 +1,112 @@
+package com.smartreview.controller;
+
+import com.smartreview.service.DocumentService;
+import com.smartreview.utils.SecurityUtil;
+import com.smartreview.vo.Response;
+import com.smartreview.vo.document.*;
+import com.smartreview.vo.mineru.MineruCallbackRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * 文档Document
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/document")
+@RequiredArgsConstructor
+public class DocumentController {
+
+    private final DocumentService documentService;
+    private final SecurityUtil securityUtil;
+
+    /**
+     * 上传文档到OSS
+     */
+    @PostMapping("/upload")
+    public Response<String> uploadDocument(@RequestParam("file") MultipartFile file) {
+        Long userId = securityUtil.getCurrentUserId();
+        String documentId = documentService.uploadDocument(file, userId);
+        return Response.success("上传成功", documentId);
+    }
+
+    /**
+     * 解析文档(SSE流式返回进度)- 旧接口,保留兼容
+     * 流程:解析Markdown → 切分Block → AI提取知识点
+     *
+     * @deprecated 使用 POST /parse 代替
+     */
+    @Deprecated
+    @PostMapping(value = "/parse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<ParseProgressVO> parseDocumentStream(@RequestBody ParseDocumentRequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        return documentService.parseDocument(request, userId);
+    }
+
+    /**
+     * 获取或解析文档v2
+     * 
+     * 支持的格式:md, pdf, dot, doc, docx, ppt, pptx, png, pjp, jfif, jpe, pjpeg, jpeg,
+     * jpg
+     * 
+     * 逻辑:
+     * 1. 检查文档是否存在正在进行的解析任务,如有则直接返回当前状态
+     * 2. 如果文档状态为READY:
+     * - 若 forceParse=false(默认),直接返回已完成状态
+     * - 若 forceParse=true,清理所有旧数据(章节、内容块、知识点、题目、答题记录、对话等)并重新解析
+     * 3. 如果文档状态为FAILED或未解析,启动解析任务
+     * 
+     * @return 解析状态VO
+     */
+    @PostMapping("/parse/v2")
+    public Response<DocumentParseStatusVO> getOrParseDocument(@RequestBody ParseDocumentV2RequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        DocumentParseStatusVO response = documentService.getOrParseDocument(request, userId);
+        return Response.success(response);
+    }
+
+    /**
+     * Mineru解析完成回调接口
+     * 此接口由Mineru平台调用,不需要用户认证
+     */
+    @PostMapping("/callback/mineru")
+    public String handleMineruCallback(@RequestBody MineruCallbackRequest callback) {
+        log.info("Received Mineru callback");
+        documentService.handleMineruCallback(callback);
+        return "ok";
+    }
+
+    /**
+     * 获取用户所有文档列表
+     */
+    @GetMapping("/list")
+    public Response<List<DocumentListVO>> getDocumentList() {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(documentService.getDocumentList(userId));
+    }
+
+    /**
+     * 获取文档详情(含章节目录树)
+     */
+    @GetMapping("/{id}")
+    public Response<DocumentDetailVO> getDocumentDetail(@PathVariable String id) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(documentService.getDocumentDetail(id, userId));
+    }
+
+    /**
+     * 删除文档
+     */
+    @DeleteMapping("/{id}")
+    public Response<Void> deleteDocument(@PathVariable String id) {
+        Long userId = securityUtil.getCurrentUserId();
+        documentService.deleteDocument(id, userId);
+        return Response.success();
+    }
+}

+ 50 - 0
src/main/java/com/smartreview/controller/ReportController.java

@@ -0,0 +1,50 @@
+package com.smartreview.controller;
+
+import com.smartreview.service.ReportService;
+import com.smartreview.utils.SecurityUtil;
+import com.smartreview.vo.Response;
+import com.smartreview.vo.report.ReviewReportVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 报告Report
+ */
+@RestController
+@RequestMapping("/api/report")
+@RequiredArgsConstructor
+public class ReportController {
+
+    private final ReportService reportService;
+    private final SecurityUtil securityUtil;
+
+    /**
+     * 获取复习报告数据(进度、正确率、薄弱点、错题列表)
+     */
+    @GetMapping("/{documentId}")
+    public Response<ReviewReportVO> getReport(@PathVariable String documentId) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(reportService.getReport(documentId, userId));
+    }
+
+    /**
+     * 导出PDF报告
+     */
+    @GetMapping("/{documentId}/pdf")
+    public ResponseEntity<byte[]> exportPdfReport(@PathVariable String documentId) {
+        Long userId = securityUtil.getCurrentUserId();
+        byte[] pdfBytes = reportService.exportPdfReport(documentId, userId);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_PDF);
+        headers.setContentDispositionFormData("attachment", "review_report.pdf");
+        headers.setContentLength(pdfBytes.length);
+
+        return ResponseEntity.ok()
+                .headers(headers)
+                .body(pdfBytes);
+    }
+}

+ 101 - 0
src/main/java/com/smartreview/controller/ReviewChatController.java

@@ -0,0 +1,101 @@
+package com.smartreview.controller;
+
+import com.smartreview.enums.ConversationType;
+import com.smartreview.enums.QuestionType;
+import com.smartreview.service.ReviewChatService;
+import com.smartreview.utils.SecurityUtil;
+import com.smartreview.vo.Response;
+import com.smartreview.vo.chat.*;
+import com.smartreview.vo.question.QuestionVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * AI对话Chat
+ */
+@RestController
+@RequestMapping("/api/chat")
+@RequiredArgsConstructor
+public class ReviewChatController {
+
+    private final ReviewChatService reviewChatService;
+    private final SecurityUtil securityUtil;
+
+    /**
+     * 创建新对话
+     * 支持两种类型:FREE_CHAT(自由对话)、KNOWLEDGE_QA(知识点问答/划词提问)
+     */
+    @PostMapping("/conversation")
+    public Response<ConversationVO> createConversation(@RequestBody CreateConversationRequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(reviewChatService.createConversation(request, userId));
+    }
+
+    /**
+     * 获取对话列表
+     * 
+     * @param documentId 文档ID
+     * @param type       对话类型(可选):FREE_CHAT / KNOWLEDGE_QA
+     */
+    @GetMapping("/conversations")
+    public Response<List<ConversationVO>> getConversations(
+            @RequestParam String documentId,
+            @RequestParam(required = false) ConversationType type) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(reviewChatService.getConversations(documentId, type, userId));
+    }
+
+    /**
+     * 获取对话详情(包含消息历史)
+     */
+    @GetMapping("/conversation/{conversationId}")
+    public Response<ConversationDetailVO> getConversationDetail(@PathVariable Long conversationId) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(reviewChatService.getConversationDetail(conversationId, userId));
+    }
+
+    /**
+     * 发送消息(SSE流式响应)
+     * 在已有对话中发送消息,支持多轮对话
+     */
+    @PostMapping(value = "/message", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<ChatStreamResponseVO> sendMessage(@RequestBody SendMessageRequestVO request) {
+        Long userId = securityUtil.getCurrentUserId();
+        return reviewChatService.sendMessage(request, userId);
+    }
+
+    /**
+     * 关闭对话
+     */
+    @PostMapping("/conversation/{conversationId}/close")
+    public Response<Void> closeConversation(@PathVariable Long conversationId) {
+        Long userId = securityUtil.getCurrentUserId();
+        reviewChatService.closeConversation(conversationId, userId);
+        return Response.success(null);
+    }
+
+    /**
+     * 删除对话
+     */
+    @DeleteMapping("/conversation/{conversationId}")
+    public Response<Void> deleteConversation(@PathVariable Long conversationId) {
+        Long userId = securityUtil.getCurrentUserId();
+        reviewChatService.deleteConversation(conversationId, userId);
+        return Response.success(null);
+    }
+
+    /**
+     * 从对话生成题目(仅限划词提问)
+     */
+    @PostMapping("/conversation/{conversationId}/generate-question")
+    public Response<QuestionVO> generateQuestionFromConversation(
+            @PathVariable Long conversationId,
+            @RequestParam(required = false) QuestionType type) {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(reviewChatService.generateQuestionFromConversation(conversationId, userId, type));
+    }
+}

+ 45 - 0
src/main/java/com/smartreview/controller/UserController.java

@@ -0,0 +1,45 @@
+package com.smartreview.controller;
+
+import com.smartreview.service.UserService;
+import com.smartreview.utils.SecurityUtil;
+import com.smartreview.vo.Response;
+import com.smartreview.vo.user.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 用户User
+ */
+@RestController
+@RequestMapping("/api/user")
+@RequiredArgsConstructor
+public class UserController {
+
+    private final UserService userService;
+    private final SecurityUtil securityUtil;
+
+    /**
+     * 用户登录
+     */
+    @PostMapping("/login")
+    public Response<LoginResponseVO> login(@RequestBody LoginRequestVO request) {
+        return Response.success(userService.login(request));
+    }
+
+    /**
+     * 用户注册
+     */
+    @PostMapping("/register")
+    public Response<LoginResponseVO> register(@RequestBody RegisterRequestVO request) {
+        return Response.success(userService.register(request));
+    }
+
+    /**
+     * 获取当前用户信息
+     */
+    @GetMapping("/info")
+    public Response<UserInfoVO> getUserInfo() {
+        Long userId = securityUtil.getCurrentUserId();
+        return Response.success(userService.getUserInfo(userId));
+    }
+}

+ 23 - 0
src/main/java/com/smartreview/enums/BlockStatus.java

@@ -0,0 +1,23 @@
+package com.smartreview.enums;
+
+/**
+ * 内容块状态枚举
+ */
+public enum BlockStatus {
+    /**
+     * 未读
+     */
+    UNREAD,
+    /**
+     * 正在阅读
+     */
+    READING,
+    /**
+     * 已生成问题
+     */
+    GENERATED_QUESTION,
+    /**
+     * 已读
+     */
+    COMPLETED
+}

+ 11 - 0
src/main/java/com/smartreview/enums/BlockType.java

@@ -0,0 +1,11 @@
+package com.smartreview.enums;
+
+/**
+ * Block类型枚举
+ * READ_ONLY: 仅阅读(如纯标题、分隔符等无实际学习内容)
+ * LEARNABLE: 需要学习的内容块
+ */
+public enum BlockType {
+    READ_ONLY, // 仅阅读
+    LEARNABLE // 需要学习
+}

+ 41 - 0
src/main/java/com/smartreview/enums/ConversationType.java

@@ -0,0 +1,41 @@
+package com.smartreview.enums;
+
+/**
+ * 对话类型枚举
+ */
+public enum ConversationType {
+    /**
+     * 自由对话 - 用户自由提问
+     */
+    FREE_CHAT("free_chat", "自由对话"),
+
+    /**
+     * 知识点问答 - 划词提问
+     */
+    KNOWLEDGE_QA("knowledge_qa", "知识点问答");
+
+    private final String code;
+    private final String description;
+
+    ConversationType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public static ConversationType fromCode(String code) {
+        for (ConversationType type : values()) {
+            if (type.code.equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("Unknown conversation type: " + code);
+    }
+}

+ 63 - 0
src/main/java/com/smartreview/enums/DocumentStatus.java

@@ -0,0 +1,63 @@
+package com.smartreview.enums;
+
+/**
+ * 文档状态枚举
+ */
+public enum DocumentStatus {
+    /**
+     * 已上传(文件已上传到OSS,等待解析)
+     */
+    UPLOADED,
+    /**
+     * 正在上传到Mineru平台
+     */
+    UPLOADING_TO_MINERU,
+    /**
+     * Mineru排队中
+     */
+    MINERU_PENDING,
+    /**
+     * Mineru正在解析
+     */
+    MINERU_RUNNING,
+    /**
+     * Mineru格式转换中
+     */
+    MINERU_CONVERTING,
+    /**
+     * Mineru解析完成,正在下载结果
+     */
+    DOWNLOADING_RESULT,
+    /**
+     * 正在解压处理结果
+     */
+    EXTRACTING_RESULT,
+    /**
+     * 解析中(解析Markdown内容)
+     */
+    PARSING,
+    /**
+     * 切分文档结构中
+     */
+    CHUNKING,
+    /**
+     * 提取知识点中
+     */
+    EXTRACTING,
+    /**
+     * 索引中(定位知识点)
+     */
+    INDEXING,
+    /**
+     * 就绪,可以复习
+     */
+    READY,
+    /**
+     * 已归档
+     */
+    ARCHIVED,
+    /**
+     * 解析失败
+     */
+    FAILED
+}

+ 19 - 0
src/main/java/com/smartreview/enums/QuestionGenerationStatus.java

@@ -0,0 +1,19 @@
+package com.smartreview.enums;
+
+/**
+ * 题目生成任务状态
+ */
+public enum QuestionGenerationStatus {
+    /**
+     * 生成中
+     */
+    GENERATING,
+    /**
+     * 已完成
+     */
+    COMPLETED,
+    /**
+     * 失败
+     */
+    FAILED
+}

+ 19 - 0
src/main/java/com/smartreview/enums/QuestionSource.java

@@ -0,0 +1,19 @@
+package com.smartreview.enums;
+
+/**
+ * 题目来源类型枚举
+ */
+public enum QuestionSource {
+    /**
+     * 当前Block正常生成
+     */
+    CURRENT_BLOCK,
+    /**
+     * 复习之前的知识点生成
+     */
+    REVIEW,
+    /**
+     * 划词生成的题目
+     */
+    SELECTION_GENERATED
+}

+ 27 - 0
src/main/java/com/smartreview/enums/QuestionStatus.java

@@ -0,0 +1,27 @@
+package com.smartreview.enums;
+
+/**
+ * 题目状态枚举
+ */
+public enum QuestionStatus {
+    /**
+     * 正在使用
+     */
+    ACTIVE,
+    /**
+     * 已废弃
+     */
+    DEPRECATED,
+    /**
+     * 回答正确
+     */
+    ANSWERED_RIGHT,
+    /**
+     * 回答错误
+     */
+    ANSWERED_WRONG,
+    /**
+     * 标记为薄弱点
+     */
+    ANSWERED_WEAK
+}

+ 19 - 0
src/main/java/com/smartreview/enums/QuestionType.java

@@ -0,0 +1,19 @@
+package com.smartreview.enums;
+
+/**
+ * 题目类型枚举
+ */
+public enum QuestionType {
+    /**
+     * 单选题
+     */
+    SINGLE,
+    /**
+     * 多选题
+     */
+    MULTIPLE,
+    /**
+     * 问答题
+     */
+    ESSAY
+}

+ 41 - 0
src/main/java/com/smartreview/exception/GlobalExceptionHandler.java

@@ -0,0 +1,41 @@
+package com.smartreview.exception;
+
+import com.smartreview.vo.Response;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * 全局异常处理器
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    /**
+     * 处理自定义异常
+     */
+    @ExceptionHandler(SmartReviewException.class)
+    public Response<Void> handleSmartReviewException(SmartReviewException e) {
+        log.warn("业务异常: {}", e.getMessage());
+        return Response.error(e.getCode(), e.getMessage());
+    }
+
+    /**
+     * 处理参数校验异常
+     */
+    @ExceptionHandler(IllegalArgumentException.class)
+    public Response<Void> handleIllegalArgumentException(IllegalArgumentException e) {
+        log.warn("参数异常: {}", e.getMessage());
+        return Response.badRequest(e.getMessage());
+    }
+
+    /**
+     * 处理其他异常
+     */
+    @ExceptionHandler(Exception.class)
+    public Response<Void> handleException(Exception e) {
+        log.error("系统异常", e);
+        return Response.error("系统异常,请稍后重试");
+    }
+}

+ 39 - 0
src/main/java/com/smartreview/exception/SmartReviewException.java

@@ -0,0 +1,39 @@
+package com.smartreview.exception;
+
+import lombok.Getter;
+
+/**
+ * 自定义异常类
+ */
+@Getter
+public class SmartReviewException extends RuntimeException {
+
+    private final int code;
+    private final String message;
+
+    public SmartReviewException(int code, String message) {
+        super(message);
+        this.code = code;
+        this.message = message;
+    }
+
+    public static SmartReviewException error(String message) {
+        return new SmartReviewException(500, message);
+    }
+
+    public static SmartReviewException badRequest(String message) {
+        return new SmartReviewException(400, message);
+    }
+
+    public static SmartReviewException notFound(String message) {
+        return new SmartReviewException(404, message);
+    }
+
+    public static SmartReviewException notLogin() {
+        return new SmartReviewException(401, "未登录或登录已过期");
+    }
+
+    public static SmartReviewException forbidden(String message) {
+        return new SmartReviewException(403, message);
+    }
+}

+ 68 - 0
src/main/java/com/smartreview/po/Block.java

@@ -0,0 +1,68 @@
+package com.smartreview.po;
+
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.enums.BlockType;
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 内容块实体类
+ */
+@Data
+@Entity(name = "blocks")
+public class Block {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "document_id", nullable = false)
+    private String documentId;
+
+    @Column(name = "chapter_id")
+    private Long chapterId;
+
+    @Column(columnDefinition = "LONGTEXT")
+    private String content;
+
+    @Column(name = "sort_order")
+    private Integer sortOrder;
+
+    @Enumerated(EnumType.STRING)
+    private BlockStatus status = BlockStatus.UNREAD;
+
+    @Enumerated(EnumType.STRING)
+    @Column(name = "type")
+    private BlockType type = BlockType.LEARNABLE;
+
+    /**
+     * 错题数量
+     */
+    @Column(name = "wrong_answer_count")
+    private Integer wrongAnswerCount = 0;
+
+    /**
+     * 薄弱点数量
+     */
+    @Column(name = "weak_point_count")
+    private Integer weakPointCount = 0;
+
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+}

+ 41 - 0
src/main/java/com/smartreview/po/Chapter.java

@@ -0,0 +1,41 @@
+package com.smartreview.po;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+/**
+ * 章节实体类
+ */
+@Data
+@Entity(name = "chapters")
+public class Chapter {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "document_id", nullable = false)
+    private String documentId;
+
+    @Column(name = "parent_id")
+    private Long parentId;
+
+    @Column(nullable = false, columnDefinition = "MEDIUMTEXT")
+    private String title;
+
+    /**
+     * 标题级别 1=H1, 2=H2, 3=H3
+     */
+    private Integer level;
+
+    @Column(name = "sort_order")
+    private Integer sortOrder;
+
+    @Column(name = "created_at")
+    private java.time.LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = java.time.LocalDateTime.now();
+    }
+}

+ 92 - 0
src/main/java/com/smartreview/po/Conversation.java

@@ -0,0 +1,92 @@
+package com.smartreview.po;
+
+import com.smartreview.enums.ConversationType;
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 对话会话实体类
+ * 一个Conversation包含多个ChatMessage,支持多轮对话
+ */
+@Data
+@Entity(name = "conversations")
+public class Conversation {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * 用户ID
+     */
+    @Column(name = "user_id", nullable = false)
+    private Long userId;
+
+    /**
+     * 关联的文档ID
+     */
+    @Column(name = "document_id", nullable = false)
+    private String documentId;
+
+    /**
+     * 关联的Block ID(可选,知识点问答时关联具体Block)
+     */
+    @Column(name = "block_id")
+    private Long blockId;
+
+    /**
+     * 关联的知识点ID(划词提问时直接保存匹配到的知识点ID)
+     */
+    @Column(name = "knowledge_point_id")
+    private Long knowledgePointId;
+
+    /**
+     * 对话类型:FREE_CHAT(自由对话) / KNOWLEDGE_QA(知识点问答/划词提问)
+     */
+    @Enumerated(EnumType.STRING)
+    @Column(name = "type", nullable = false)
+    private ConversationType type;
+
+    /**
+     * 对话标题(可选,用于显示)
+     */
+    @Column(name = "title")
+    private String title;
+
+    /**
+     * 划词选中的文本(知识点问答时使用)
+     */
+    @Column(name = "selected_text", columnDefinition = "TEXT")
+    private String selectedText;
+
+    /**
+     * 对话是否已结束
+     */
+    @Column(name = "is_closed")
+    private Boolean isClosed = false;
+
+    /**
+     * 创建时间
+     */
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    /**
+     * 最后更新时间
+     */
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+}

+ 129 - 0
src/main/java/com/smartreview/po/Document.java

@@ -0,0 +1,129 @@
+package com.smartreview.po;
+
+import com.smartreview.enums.DocumentStatus;
+import jakarta.persistence.*;
+import lombok.Data;
+import org.hibernate.annotations.UuidGenerator;
+
+import java.time.LocalDateTime;
+
+/**
+ * 复习文档实体类
+ */
+@Data
+@Entity(name = "documents")
+public class Document {
+
+    @Id
+    @UuidGenerator
+    private String id;
+
+    @Column(name = "user_id", nullable = false)
+    private Long userId;
+
+    @Column(nullable = false)
+    private String title;
+
+    /**
+     * 原始文件名(包含扩展名)
+     */
+    @Column(name = "original_filename")
+    private String originalFilename;
+
+    /**
+     * 文件类型(md, pdf, doc, docx, ppt, pptx, png, jpg, jpeg)
+     */
+    @Column(name = "file_type")
+    private String fileType;
+
+    /**
+     * OSS文件路径(objectName),用于生成签名URL
+     */
+    @Column(name = "oss_path")
+    private String ossPath;
+
+    /**
+     * Mineru任务ID(用于非Markdown文档的解析)
+     */
+    @Column(name = "mineru_task_id")
+    private String mineruTaskId;
+
+    /**
+     * Mineru批次ID(用于批量文件上传解析)
+     */
+    @Column(name = "mineru_batch_id")
+    private String mineruBatchId;
+
+    /**
+     * Mineru解析进度信息(JSON格式存储)
+     */
+    @Column(name = "mineru_progress", columnDefinition = "TEXT")
+    private String mineruProgress;
+
+    /**
+     * Mineru解析失败原因
+     */
+    @Column(name = "mineru_error_msg")
+    private String mineruErrorMsg;
+
+    /**
+     * 转换后的Markdown文件OSS路径
+     */
+    @Column(name = "converted_md_path")
+    private String convertedMdPath;
+
+    @Column(name = "total_blocks")
+    private Integer totalBlocks = 0;
+
+    @Column(name = "completed_blocks")
+    private Integer completedBlocks = 0;
+
+    @Enumerated(EnumType.STRING)
+    private DocumentStatus status = DocumentStatus.PARSING;
+
+    /**
+     * 状态描述信息(用于前端显示)
+     */
+    @Column(name = "status_message")
+    private String statusMessage;
+
+    /**
+     * 解析进度百分比 0-100
+     */
+    @Column(name = "parse_progress")
+    private Integer parseProgress = 0;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @Column(name = "last_reviewed_at")
+    private LocalDateTime lastReviewedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+
+    /**
+     * 判断是否为Markdown文件
+     */
+    public boolean isMarkdown() {
+        return "md".equalsIgnoreCase(fileType);
+    }
+
+    /**
+     * 判断是否需要通过Mineru转换
+     */
+    public boolean needsMineruConversion() {
+        return fileType != null && !isMarkdown();
+    }
+}

+ 71 - 0
src/main/java/com/smartreview/po/KnowledgePoint.java

@@ -0,0 +1,71 @@
+package com.smartreview.po;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 知识点实体类(后端内部使用,用于生成题目)
+ */
+@Data
+@Entity(name = "knowledge_points")
+public class KnowledgePoint {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "block_id", nullable = false)
+    private Long blockId;
+
+    /**
+     * 知识点内容
+     */
+    @Column(columnDefinition = "TEXT")
+    private String content;
+
+    /**
+     * 原文定位文本
+     */
+    @Column(name = "source_text", columnDefinition = "TEXT")
+    private String sourceText;
+
+    /**
+     * 原文起始位置
+     */
+    @Column(name = "source_start")
+    private Integer sourceStart;
+
+    /**
+     * 原文结束位置
+     */
+    @Column(name = "source_end")
+    private Integer sourceEnd;
+
+    /**
+     * 是否来自用户提问(划词提问会记录为知识点)
+     */
+    @Column(name = "is_from_chat")
+    private Boolean isFromChat = false;
+
+    /**
+     * 还需要复习的次数(默认0,划词提问时设为2,最多2次)
+     */
+    @Column(name = "review_times_needed")
+    private Integer reviewTimesNeeded = 0;
+
+    /**
+     * 上次复习的时间
+     */
+    @Column(name = "last_reviewed_at")
+    private LocalDateTime lastReviewedAt;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+    }
+}

+ 97 - 0
src/main/java/com/smartreview/po/Question.java

@@ -0,0 +1,97 @@
+package com.smartreview.po;
+
+import com.smartreview.enums.QuestionSource;
+import com.smartreview.enums.QuestionStatus;
+import com.smartreview.enums.QuestionType;
+import com.smartreview.utils.StringListJsonConverter;
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 题目实体类(动态生成后持久化)
+ */
+@Data
+@Entity(name = "questions")
+public class Question {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "block_id", nullable = false)
+    private Long blockId;
+
+    /**
+     * 知识点ID(关联的知识点,null表示自由知识点)
+     */
+    @Column(name = "knowledge_point_id")
+    private Long knowledgePointId;
+
+    /**
+     * 题目来源类型:当前Block/复习
+     */
+    @Enumerated(EnumType.STRING)
+    @Column(name = "question_source")
+    private QuestionSource questionSource;
+
+    /**
+     * 题目生成的触发Block ID(即用户当前所在的Block)
+     */
+    @Column(name = "generated_block_id")
+    private Long generatedBlockId;
+
+    /**
+     * 题目状态:正在使用/已废弃
+     */
+    @Enumerated(EnumType.STRING)
+    @Column(nullable = false)
+    private QuestionStatus status = QuestionStatus.ACTIVE;
+
+    /**
+     * 题目类型:单选/多选/问答
+     */
+    @Enumerated(EnumType.STRING)
+    private QuestionType type;
+
+    /**
+     * 题目文本
+     */
+    @Column(name = "question_text", columnDefinition = "TEXT")
+    private String questionText;
+
+    /**
+     * 选项列表(JSON格式存储)
+     */
+    @Convert(converter = StringListJsonConverter.class)
+    @Column(columnDefinition = "TEXT")
+    private List<String> options;
+
+    /**
+     * 正确答案(选择题为选项索引如"A"或"A,B",问答题为参考答案)
+     */
+    @Column(name = "correct_answer", columnDefinition = "TEXT")
+    private String correctAnswer;
+
+    /**
+     * 题目解析
+     */
+    @Column(columnDefinition = "TEXT")
+    private String explanation;
+
+    /**
+     * 原文引用依据
+     */
+    @Column(name = "source_evidence", columnDefinition = "TEXT")
+    private String sourceEvidence;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+    }
+}

+ 55 - 0
src/main/java/com/smartreview/po/QuestionGenerationTask.java

@@ -0,0 +1,55 @@
+package com.smartreview.po;
+
+import com.smartreview.enums.QuestionGenerationStatus;
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 题目生成任务实体
+ */
+@Data
+@Entity(name = "question_generation_tasks")
+public class QuestionGenerationTask {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "block_id", nullable = false)
+    private Long blockId;
+
+    @Column(name = "user_id", nullable = false)
+    private Long userId;
+
+    /**
+     * 任务状态
+     */
+    @Enumerated(EnumType.STRING)
+    @Column(nullable = false)
+    private QuestionGenerationStatus status;
+
+    /**
+     * 生成的题目数量
+     */
+    @Column(name = "generated_count")
+    private Integer generatedCount;
+
+    /**
+     * 错误信息(如果失败)
+     */
+    @Column(name = "error_message", columnDefinition = "TEXT")
+    private String errorMessage;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @Column(name = "completed_at")
+    private LocalDateTime completedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+    }
+}

+ 59 - 0
src/main/java/com/smartreview/po/ReviewChatMessage.java

@@ -0,0 +1,59 @@
+package com.smartreview.po;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 对话消息实体类
+ * 属于某个Conversation,支持多轮对话
+ */
+@Data
+@Entity(name = "chat_messages")
+public class ReviewChatMessage {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * 所属对话ID
+     */
+    @Column(name = "conversation_id", nullable = false)
+    private Long conversationId;
+
+    /**
+     * 用户ID
+     */
+    @Column(name = "user_id", nullable = false)
+    private Long userId;
+
+    /**
+     * 消息角色:user / assistant / system
+     */
+    private String role;
+
+    /**
+     * 消息内容
+     */
+    @Column(columnDefinition = "LONGTEXT")
+    private String content;
+
+    /**
+     * 消息序号(在对话中的顺序)
+     */
+    @Column(name = "sequence_no")
+    private Integer sequenceNo;
+
+    /**
+     * 创建时间
+     */
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+    }
+}

+ 39 - 0
src/main/java/com/smartreview/po/User.java

@@ -0,0 +1,39 @@
+package com.smartreview.po;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+/**
+ * 用户实体类
+ */
+@Data
+@Entity(name = "users")
+public class User {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(nullable = false, unique = true)
+    private String username;
+
+    @Column(nullable = false)
+    private String password;
+
+    @Column(name = "created_at")
+    private java.time.LocalDateTime createdAt;
+
+    @Column(name = "updated_at")
+    private java.time.LocalDateTime updatedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = java.time.LocalDateTime.now();
+        updatedAt = java.time.LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = java.time.LocalDateTime.now();
+    }
+}

+ 53 - 0
src/main/java/com/smartreview/po/UserAnswer.java

@@ -0,0 +1,53 @@
+package com.smartreview.po;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户答题记录实体类
+ */
+@Data
+@Entity(name = "user_answers")
+public class UserAnswer {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "user_id", nullable = false)
+    private Long userId;
+
+    @Column(name = "question_id", nullable = false)
+    private Long questionId;
+
+    @Column(name = "block_id", nullable = false)
+    private Long blockId;
+
+    /**
+     * 用户答案
+     */
+    @Column(name = "user_answer", columnDefinition = "TEXT")
+    private String userAnswer;
+
+    /**
+     * 是否正确
+     */
+    @Column(name = "is_correct")
+    private Boolean isCorrect;
+
+    /**
+     * AI反馈(问答题批改结果)
+     */
+    @Column(name = "ai_feedback", columnDefinition = "TEXT")
+    private String aiFeedback;
+
+    @Column(name = "answered_at")
+    private LocalDateTime answeredAt;
+
+    @PrePersist
+    protected void onCreate() {
+        answeredAt = LocalDateTime.now();
+    }
+}

+ 24 - 0
src/main/java/com/smartreview/repository/BlockRepository.java

@@ -0,0 +1,24 @@
+package com.smartreview.repository;
+
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.po.Block;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface BlockRepository extends JpaRepository<Block, Long> {
+
+    List<Block> findByDocumentIdOrderBySortOrder(String documentId);
+
+    List<Block> findByChapterIdOrderBySortOrder(Long chapterId);
+
+    List<Block> findByDocumentIdAndStatus(String documentId, BlockStatus status);
+
+    int countByDocumentId(String documentId);
+
+    int countByDocumentIdAndStatus(String documentId, BlockStatus status);
+
+    void deleteByDocumentId(String documentId);
+}

+ 17 - 0
src/main/java/com/smartreview/repository/ChapterRepository.java

@@ -0,0 +1,17 @@
+package com.smartreview.repository;
+
+import com.smartreview.po.Chapter;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface ChapterRepository extends JpaRepository<Chapter, Long> {
+
+    List<Chapter> findByDocumentIdOrderBySortOrder(String documentId);
+
+    List<Chapter> findByParentIdOrderBySortOrder(Long parentId);
+
+    void deleteByDocumentId(String documentId);
+}

+ 65 - 0
src/main/java/com/smartreview/repository/ConversationRepository.java

@@ -0,0 +1,65 @@
+package com.smartreview.repository;
+
+import com.smartreview.enums.ConversationType;
+import com.smartreview.po.Conversation;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 对话会话Repository
+ */
+@Repository
+public interface ConversationRepository extends JpaRepository<Conversation, Long> {
+
+        /**
+         * 根据用户ID和文档ID查询对话列表
+         */
+        List<Conversation> findByUserIdAndDocumentIdOrderByUpdatedAtDesc(Long userId, String documentId);
+
+        /**
+         * 根据用户ID、文档ID和类型查询对话列表
+         */
+        List<Conversation> findByUserIdAndDocumentIdAndTypeOrderByUpdatedAtDesc(Long userId, String documentId,
+                        ConversationType type);
+
+        /**
+         * 根据用户ID、文档ID和BlockID查询对话列表
+         */
+        List<Conversation> findByUserIdAndDocumentIdAndBlockIdOrderByUpdatedAtDesc(Long userId, String documentId,
+                        Long blockId);
+
+        /**
+         * 查找用户在某文档下最近的一个未关闭的自由对话
+         */
+        Optional<Conversation> findFirstByUserIdAndDocumentIdAndTypeAndIsClosedFalseOrderByUpdatedAtDesc(
+                        Long userId, String documentId, ConversationType type);
+
+        /**
+         * 查找用户在某Block下最近的一个未关闭的知识点问答对话
+         */
+        Optional<Conversation> findFirstByUserIdAndBlockIdAndTypeAndIsClosedFalseOrderByUpdatedAtDesc(
+                        Long userId, Long blockId, ConversationType type);
+
+        /**
+         * 根据用户ID查询所有对话
+         */
+        List<Conversation> findByUserIdOrderByUpdatedAtDesc(Long userId);
+
+        /**
+         * 统计用户在某文档下的对话数量
+         */
+        long countByUserIdAndDocumentId(Long userId, String documentId);
+
+        /**
+         * 根据文档ID查询所有对话
+         */
+        List<Conversation> findByDocumentId(String documentId);
+
+        /**
+         * 根据文档ID删除所有对话
+         */
+        void deleteByDocumentId(String documentId);
+}

+ 16 - 0
src/main/java/com/smartreview/repository/DocumentRepository.java

@@ -0,0 +1,16 @@
+package com.smartreview.repository;
+
+import com.smartreview.enums.DocumentStatus;
+import com.smartreview.po.Document;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface DocumentRepository extends JpaRepository<Document, String> {
+
+    List<Document> findByUserIdOrderByUpdatedAtDesc(Long userId);
+
+    List<Document> findByUserIdAndStatusOrderByUpdatedAtDesc(Long userId, DocumentStatus status);
+}

+ 26 - 0
src/main/java/com/smartreview/repository/KnowledgePointRepository.java

@@ -0,0 +1,26 @@
+package com.smartreview.repository;
+
+import com.smartreview.po.KnowledgePoint;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface KnowledgePointRepository extends JpaRepository<KnowledgePoint, Long> {
+
+    List<KnowledgePoint> findByBlockId(Long blockId);
+
+    List<KnowledgePoint> findByBlockIdIn(List<Long> blockIds);
+
+    List<KnowledgePoint> findByBlockIdAndIsFromChat(Long blockId, Boolean isFromChat);
+
+    void deleteByBlockId(Long blockId);
+
+    /**
+     * 根据文档ID查找所有知识点
+     */
+    @Query("SELECT kp FROM knowledge_points kp JOIN blocks b ON kp.blockId = b.id WHERE b.documentId = :documentId")
+    List<KnowledgePoint> findByBlockId_DocumentId(String documentId);
+}

+ 28 - 0
src/main/java/com/smartreview/repository/QuestionGenerationTaskRepository.java

@@ -0,0 +1,28 @@
+package com.smartreview.repository;
+
+import com.smartreview.enums.QuestionGenerationStatus;
+import com.smartreview.po.QuestionGenerationTask;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface QuestionGenerationTaskRepository extends JpaRepository<QuestionGenerationTask, Long> {
+
+    /**
+     * 查找Block的最新任务
+     */
+    Optional<QuestionGenerationTask> findFirstByBlockIdAndUserIdOrderByCreatedAtDesc(Long blockId, Long userId);
+
+    /**
+     * 查找Block正在进行的任务
+     */
+    Optional<QuestionGenerationTask> findFirstByBlockIdAndUserIdAndStatusOrderByCreatedAtDesc(
+            Long blockId, Long userId, QuestionGenerationStatus status);
+
+    /**
+     * 根据blockId删除所有任务
+     */
+    void deleteByBlockId(Long blockId);
+}

+ 46 - 0
src/main/java/com/smartreview/repository/QuestionRepository.java

@@ -0,0 +1,46 @@
+package com.smartreview.repository;
+
+import com.smartreview.enums.QuestionSource;
+import com.smartreview.enums.QuestionStatus;
+import com.smartreview.po.Question;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface QuestionRepository extends JpaRepository<Question, Long> {
+
+    List<Question> findByBlockId(Long blockId);
+
+    List<Question> findByBlockIdAndStatus(Long blockId, QuestionStatus status);
+
+    /**
+     * 根据blockId和来源类型查找题目
+     */
+    List<Question> findByBlockIdAndQuestionSource(Long blockId, QuestionSource questionSource);
+
+    /**
+     * 根据blockIds和状态查找题目
+     */
+    List<Question> findByBlockIdInAndStatus(List<Long> blockIds, QuestionStatus status);
+
+    /**
+     * 根据生成的触发Block ID查找题目
+     */
+    List<Question> findByGeneratedBlockIdAndStatus(Long generatedBlockId, QuestionStatus status);
+
+    List<Question> findByBlockIdIn(List<Long> blockIds);
+
+    void deleteByBlockId(Long blockId);
+
+    @Modifying
+    @Query("UPDATE questions q SET q.status = :status WHERE q.generatedBlockId = :generatedBlockId")
+    void updateStatusByGeneratedBlockId(Long generatedBlockId, QuestionStatus status);
+
+    @Modifying
+    @Query("UPDATE questions q SET q.status = :status WHERE q.blockId = :blockId")
+    void updateStatusByBlockId(Long blockId, QuestionStatus status);
+}

+ 36 - 0
src/main/java/com/smartreview/repository/ReviewChatMessageRepository.java

@@ -0,0 +1,36 @@
+package com.smartreview.repository;
+
+import com.smartreview.po.ReviewChatMessage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface ReviewChatMessageRepository extends JpaRepository<ReviewChatMessage, Long> {
+
+    /**
+     * 根据对话ID查询消息列表,按序号排序
+     */
+    List<ReviewChatMessage> findByConversationIdOrderBySequenceNo(Long conversationId);
+
+    /**
+     * 根据对话ID查询消息列表,按创建时间排序
+     */
+    List<ReviewChatMessage> findByConversationIdOrderByCreatedAt(Long conversationId);
+
+    /**
+     * 统计对话中的消息数量
+     */
+    long countByConversationId(Long conversationId);
+
+    /**
+     * 删除对话的所有消息
+     */
+    void deleteByConversationId(Long conversationId);
+
+    /**
+     * 根据用户ID查询消息
+     */
+    List<ReviewChatMessage> findByUserIdOrderByCreatedAtDesc(Long userId);
+}

+ 42 - 0
src/main/java/com/smartreview/repository/UserAnswerRepository.java

@@ -0,0 +1,42 @@
+package com.smartreview.repository;
+
+import com.smartreview.po.UserAnswer;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface UserAnswerRepository extends JpaRepository<UserAnswer, Long> {
+
+        List<UserAnswer> findByUserId(Long userId);
+
+        List<UserAnswer> findByUserIdAndBlockId(Long userId, Long blockId);
+
+        List<UserAnswer> findByUserIdAndIsCorrect(Long userId, Boolean isCorrect);
+
+        UserAnswer findByUserIdAndQuestionId(Long userId, Long questionId);
+
+        /**
+         * 根据questionId查询最新的用户答案
+         */
+        java.util.Optional<UserAnswer> findTopByQuestionIdOrderByAnsweredAtDesc(Long questionId);
+
+        @Query("SELECT ua FROM user_answers ua WHERE ua.userId = :userId AND ua.blockId IN " +
+                        "(SELECT b.id FROM blocks b WHERE b.documentId = :documentId)")
+        List<UserAnswer> findByUserIdAndDocumentId(Long userId, String documentId);
+
+        @Query("SELECT ua FROM user_answers ua WHERE ua.userId = :userId AND ua.isCorrect = false AND ua.blockId IN " +
+                        "(SELECT b.id FROM blocks b WHERE b.documentId = :documentId)")
+        List<UserAnswer> findWrongAnswersByUserIdAndDocumentId(Long userId, String documentId);
+
+        int countByUserIdAndBlockId(Long userId, Long blockId);
+
+        int countByUserIdAndBlockIdAndIsCorrect(Long userId, Long blockId, Boolean isCorrect);
+
+        /**
+         * 根据blockId删除所有用户答案
+         */
+        void deleteByBlockId(Long blockId);
+}

+ 15 - 0
src/main/java/com/smartreview/repository/UserRepository.java

@@ -0,0 +1,15 @@
+package com.smartreview.repository;
+
+import com.smartreview.po.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface UserRepository extends JpaRepository<User, Long> {
+
+    Optional<User> findByUsername(String username);
+
+    boolean existsByUsername(String username);
+}

+ 93 - 0
src/main/java/com/smartreview/service/AIService.java

@@ -0,0 +1,93 @@
+package com.smartreview.service;
+
+import com.smartreview.enums.ConversationType;
+import com.smartreview.po.KnowledgePoint;
+import com.smartreview.po.ReviewChatMessage;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * AI服务接口(直接调用DashScope模型)
+ */
+public interface AIService {
+
+    /**
+     * 提取知识点(从Block内容)
+     * 
+     * @param blockContent Block原文内容
+     * @return 知识点列表
+     */
+    List<KnowledgePoint> extractKnowledgePoints(String blockContent);
+
+    /**
+     * 为多个知识点批量生成题目(用于当前Block)
+     * 
+     * @param blockContent    Block原文内容
+     * @param knowledgePoints 知识点列表
+     * @return 题目JSON数组字符串,每个题目对应一个知识点
+     */
+    String generateQuestionsForKnowledgePoints(String blockContent, List<KnowledgePoint> knowledgePoints);
+
+    /**
+     * 为单个知识点生成题目(用于复习题)
+     * 
+     * @param blockContent   Block原文内容
+     * @param knowledgePoint 知识点
+     * @param type           题目类型(可选)
+     * @return 题目JSON字符串,包含 knowledgePointId 字段
+     */
+    String generateQuestionForKnowledgePoint(String blockContent, KnowledgePoint knowledgePoint,
+            com.smartreview.enums.QuestionType type);
+
+    /**
+     * 批改问答题
+     * 
+     * @param question      题目
+     * @param correctAnswer 参考答案
+     * @param userAnswer    用户答案
+     * @return 批改结果JSON字符串
+     */
+    String gradeEssayAnswer(String question, String correctAnswer, String userAnswer);
+
+    /**
+     * 生成详细解析(针对薄弱点)
+     *
+     * @param question      题目
+     * @param correctAnswer 正确答案
+     * @param explanation   原解析
+     * @param userAnswer    用户答案
+     * @return 详细解析文本
+     */
+    String generateDetailedExplanation(String question, String correctAnswer, String explanation, String userAnswer);
+
+    /**
+     * 生成对话回复(流式)
+     *
+     * @param conversationType 对话类型(自由对话/知识点问答)
+     * @param blockContent     当前Block上下文(可选)
+     * @param selectedText     划词选中的文本(知识点问答时使用)
+     * @param historyMessages  历史消息列表(用于多轮对话)
+     * @return 流式响应
+     */
+    Flux<String> chat(ConversationType conversationType, String blockContent, String selectedText,
+            String userMessage, List<ReviewChatMessage> historyMessages);
+
+    /**
+     * 单轮对话(同步响应,用于内部调用)
+     * 
+     * @param systemPrompt 系统提示词
+     * @param userMessage  用户消息
+     * @return AI回复
+     */
+    String chatSync(String systemPrompt, String userMessage);
+
+    /**
+     * 根据对话内容生成对话标题
+     * 
+     * @param userMessage 用户的第一条消息
+     * @param aiResponse  AI的第一条回复
+     * @return 生成的对话标题(简短,不超过20个字)
+     */
+    String generateConversationTitle(String userMessage, String aiResponse);
+}

+ 32 - 0
src/main/java/com/smartreview/service/BlockService.java

@@ -0,0 +1,32 @@
+package com.smartreview.service;
+
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.vo.block.BlockDetailVO;
+
+/**
+ * Block服务接口
+ */
+public interface BlockService {
+
+    /**
+     * 获取Block详情
+     */
+    BlockDetailVO getBlockDetail(Long blockId, Long userId);
+
+    /**
+     * 更新Block进度状态
+     */
+    void updateBlockProgress(Long blockId, BlockStatus status, Long userId);
+
+    /**
+     * 修改Block内容
+     * 修改内容后会清除关联的知识点、题目、用户答案,并重新触发知识点生成
+     */
+    void updateBlockContent(Long blockId, String newContent, Long userId);
+
+    /**
+     * 删除Block
+     * 删除Block及其关联数据(知识点、题目、用户答案等)
+     */
+    void deleteBlock(Long blockId, Long userId);
+}

+ 24 - 0
src/main/java/com/smartreview/service/DocumentCleanupService.java

@@ -0,0 +1,24 @@
+package com.smartreview.service;
+
+/**
+ * 文档清理服务
+ * 用于在事务上下文中清理文档相关数据
+ */
+public interface DocumentCleanupService {
+
+    /**
+     * 清理文档的旧数据(事务方法)
+     * 仅清理Block、Chapter和KnowledgePoint
+     * 
+     * @param documentId 文档ID
+     */
+    void cleanOldDocumentData(String documentId);
+
+    /**
+     * 完全清理文档的所有关联数据(用于重新解析)
+     * 包括:Block、Chapter、KnowledgePoint、Question、UserAnswer、QuestionGenerationTask、Conversation、ReviewChatMessage
+     * 
+     * @param documentId 文档ID
+     */
+    void cleanAllDocumentData(String documentId);
+}

+ 64 - 0
src/main/java/com/smartreview/service/DocumentService.java

@@ -0,0 +1,64 @@
+package com.smartreview.service;
+
+import com.smartreview.vo.document.*;
+import com.smartreview.vo.mineru.MineruCallbackRequest;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * 文档服务接口
+ */
+public interface DocumentService {
+
+    /**
+     * 上传文档到OSS并创建文档记录,返回文档ID
+     */
+    String uploadDocument(MultipartFile file, Long userId);
+
+    /**
+     * 解析文档(SSE流式返回进度)- 旧接口,保留兼容
+     * 流程:解析Markdown → 切分Block → AI提取知识点
+     * 
+     * @deprecated 使用 getOrParseDocument 代替
+     */
+    @Deprecated
+    Flux<ParseProgressVO> parseDocument(ParseDocumentRequestVO request, Long userId);
+
+    /**
+     * 获取或解析文档(合并接口)
+     * 
+     * 逻辑:
+     * 1. 检查文档是否存在正在进行的解析任务,如有则直接返回当前状态
+     * 2. 如果文档状态为READY:
+     * - 若 forceParse=false,直接返回已完成状态
+     * - 若 forceParse=true,清理所有旧数据并重新解析
+     * 3. 如果文档状态为FAILED或其他,根据参数决定是否重新解析
+     * 
+     * @param request 解析请求参数
+     * @param userId  用户ID
+     * @return 解析状态VO
+     */
+    DocumentParseStatusVO getOrParseDocument(ParseDocumentV2RequestVO request, Long userId);
+
+    /**
+     * 处理Mineru回调通知
+     */
+    void handleMineruCallback(MineruCallbackRequest callback);
+
+    /**
+     * 获取用户所有文档列表
+     */
+    List<DocumentListVO> getDocumentList(Long userId);
+
+    /**
+     * 获取文档详情(含章节目录树)
+     */
+    DocumentDetailVO getDocumentDetail(String documentId, Long userId);
+
+    /**
+     * 删除文档
+     */
+    void deleteDocument(String documentId, Long userId);
+}

+ 21 - 0
src/main/java/com/smartreview/service/KnowledgeIndexerService.java

@@ -0,0 +1,21 @@
+package com.smartreview.service;
+
+/**
+ * 知识点索引服务
+ */
+public interface KnowledgeIndexerService {
+
+    /**
+     * 异步索引文档中的知识点(定位原文位置)
+     * 
+     * @param documentId 文档ID
+     */
+    void indexDocument(String documentId);
+
+    /**
+     * 异步索引单个Block的知识点
+     * 
+     * @param blockId Block ID
+     */
+    void indexBlock(Long blockId);
+}

+ 72 - 0
src/main/java/com/smartreview/service/MineruService.java

@@ -0,0 +1,72 @@
+package com.smartreview.service;
+
+import com.smartreview.vo.mineru.*;
+
+import java.io.InputStream;
+
+/**
+ * Mineru文档解析服务接口
+ */
+public interface MineruService {
+
+    /**
+     * 通过URL创建解析任务
+     *
+     * @param fileUrl       文件URL(需要是可公网访问的URL)
+     * @param dataId        业务数据ID(可选)
+     * @param enableOcr     是否启用OCR
+     * @param enableFormula 是否开启公式识别
+     * @param enableTable   是否开启表格识别
+     * @param pageRanges    页码范围(可选)
+     * @return 任务响应
+     */
+    MineruTaskResponse createTaskByUrl(String fileUrl, String dataId, Boolean enableOcr,
+            Boolean enableFormula, Boolean enableTable, String pageRanges);
+
+    /**
+     * 申请文件上传链接(用于本地文件上传)
+     *
+     * @param fileName      文件名
+     * @param dataId        业务数据ID
+     * @param enableFormula 是否开启公式识别
+     * @param enableTable   是否开启表格识别
+     * @return 批量上传响应(包含上传链接)
+     */
+    MineruBatchUploadResponse applyUploadUrl(String fileName, String dataId,
+            Boolean enableFormula, Boolean enableTable);
+
+    /**
+     * 上传文件到Mineru
+     *
+     * @param uploadUrl   上传链接
+     * @param inputStream 文件输入流
+     * @return 是否上传成功
+     */
+    boolean uploadFile(String uploadUrl, InputStream inputStream);
+
+    /**
+     * 查询任务结果
+     *
+     * @param taskId 任务ID
+     * @return 任务结果响应
+     */
+    MineruTaskResultResponse getTaskResult(String taskId);
+
+    /**
+     * 下载解析结果压缩包并提取Markdown内容
+     *
+     * @param zipUrl 压缩包URL
+     * @return Markdown文件内容
+     */
+    String downloadAndExtractMarkdown(String zipUrl);
+
+    /**
+     * 校验回调签名
+     *
+     * @param checksum 签名
+     * @param content  内容
+     * @param seed     签名种子
+     * @return 是否校验通过
+     */
+    boolean verifyCallback(String checksum, String content, String seed);
+}

+ 52 - 0
src/main/java/com/smartreview/service/QuestionService.java

@@ -0,0 +1,52 @@
+package com.smartreview.service;
+
+import com.smartreview.enums.QuestionType;
+import com.smartreview.vo.question.QuestionGenerationResponseVO;
+import com.smartreview.vo.question.QuestionVO;
+import com.smartreview.vo.question.SubmitAnswerRequestVO;
+import com.smartreview.vo.question.SubmitAnswerResponseVO;
+
+import java.util.List;
+
+/**
+ * 题目服务接口
+ */
+public interface QuestionService {
+
+    /**
+     * 获取或生成题目(异步)
+     *
+     * @param blockId  块ID
+     * @param userId   用户ID
+     * @param forceNew 是否强制生成新题目
+     * @return 生成响应(包含状态和题目)
+     */
+    QuestionGenerationResponseVO getOrGenerateQuestions(Long blockId, Long userId, boolean forceNew);
+
+    /**
+     * 动态生成Checkpoint题目(同步,内部使用)
+     * 第一阶段:为当前Block的每个知识点生成题目
+     * 第二阶段:为需要复习的知识点生成题目
+     */
+    List<QuestionVO> generateQuestions(Long blockId, Long userId);
+
+    /**
+     * 提交答案并批改
+     */
+    SubmitAnswerResponseVO submitAnswer(Long questionId, String answer, Long userId, Boolean isWeak);
+
+    /**
+     * 批量提交答案并批改
+     */
+    List<SubmitAnswerResponseVO> submitAnswers(List<SubmitAnswerRequestVO> requests, Long userId);
+
+    /**
+     * 重新生成题目(将旧题目设置为废弃,生成新题目)
+     *
+     * @param questionId 题目ID
+     * @param userId     用户ID
+     * @param type       题目类型(可选)
+     * @return 新生成的题目
+     */
+    QuestionVO regenerateQuestion(Long questionId, Long userId, QuestionType type);
+}

+ 19 - 0
src/main/java/com/smartreview/service/ReportService.java

@@ -0,0 +1,19 @@
+package com.smartreview.service;
+
+import com.smartreview.vo.report.ReviewReportVO;
+
+/**
+ * 复习报告服务接口
+ */
+public interface ReportService {
+
+    /**
+     * 获取复习报告数据
+     */
+    ReviewReportVO getReport(String documentId, Long userId);
+
+    /**
+     * 导出PDF报告
+     */
+    byte[] exportPdfReport(String documentId, Long userId);
+}

+ 77 - 0
src/main/java/com/smartreview/service/ReviewChatService.java

@@ -0,0 +1,77 @@
+package com.smartreview.service;
+
+import com.smartreview.enums.ConversationType;
+import com.smartreview.vo.chat.*;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * 复习对话服务接口
+ */
+public interface ReviewChatService {
+
+    /**
+     * 创建新对话
+     * 
+     * @param request 创建对话请求
+     * @param userId  用户ID
+     * @return 对话信息
+     */
+    ConversationVO createConversation(CreateConversationRequestVO request, Long userId);
+
+    /**
+     * 获取对话列表
+     * 
+     * @param documentId 文档ID
+     * @param type       对话类型(可选)
+     * @param userId     用户ID
+     * @return 对话列表
+     */
+    List<ConversationVO> getConversations(String documentId, ConversationType type, Long userId);
+
+    /**
+     * 获取对话详情(包含消息历史)
+     * 
+     * @param conversationId 对话ID
+     * @param userId         用户ID
+     * @return 对话详情
+     */
+    ConversationDetailVO getConversationDetail(Long conversationId, Long userId);
+
+    /**
+     * 发送消息(流式响应)
+     * 
+     * @param request 发送消息请求
+     * @param userId  用户ID
+     * @return 流式响应
+     */
+    Flux<ChatStreamResponseVO> sendMessage(SendMessageRequestVO request, Long userId);
+
+    /**
+     * 关闭对话
+     * 
+     * @param conversationId 对话ID
+     * @param userId         用户ID
+     */
+    void closeConversation(Long conversationId, Long userId);
+
+    /**
+     * 删除对话
+     * 
+     * @param conversationId 对话ID
+     * @param userId         用户ID
+     */
+    void deleteConversation(Long conversationId, Long userId);
+
+    /**
+     * 从对话生成题目(仅限划词提问)
+     * 
+     * @param conversationId 对话ID
+     * @param userId         用户ID
+     * @param type           题目类型(可选)
+     * @return 生成的题目
+     */
+    com.smartreview.vo.question.QuestionVO generateQuestionFromConversation(Long conversationId, Long userId,
+            com.smartreview.enums.QuestionType type);
+}

+ 30 - 0
src/main/java/com/smartreview/service/UserService.java

@@ -0,0 +1,30 @@
+package com.smartreview.service;
+
+import com.smartreview.po.User;
+import com.smartreview.vo.user.*;
+
+/**
+ * 用户服务接口
+ */
+public interface UserService {
+
+    /**
+     * 用户登录
+     */
+    LoginResponseVO login(LoginRequestVO request);
+
+    /**
+     * 用户注册
+     */
+    LoginResponseVO register(RegisterRequestVO request);
+
+    /**
+     * 获取当前用户信息
+     */
+    UserInfoVO getUserInfo(Long userId);
+
+    /**
+     * 根据ID获取用户
+     */
+    User getUserById(Long userId);
+}

+ 596 - 0
src/main/java/com/smartreview/service/impl/AIServiceImpl.java

@@ -0,0 +1,596 @@
+package com.smartreview.service.impl;
+
+import com.alibaba.dashscope.aigc.generation.Generation;
+import com.alibaba.dashscope.aigc.generation.GenerationParam;
+import com.alibaba.dashscope.aigc.generation.GenerationResult;
+import com.alibaba.dashscope.common.Message;
+import com.alibaba.dashscope.common.ResponseFormat;
+import com.alibaba.dashscope.common.Role;
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.smartreview.configure.DashScopeConfig;
+import com.smartreview.enums.ConversationType;
+import com.smartreview.enums.QuestionType;
+import com.smartreview.po.KnowledgePoint;
+import com.smartreview.po.ReviewChatMessage;
+import com.smartreview.service.AIService;
+import io.reactivex.Flowable;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * AI服务实现(直接调用阿里云DashScope模型)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AIServiceImpl implements AIService {
+
+    private final DashScopeConfig dashScopeConfig;
+    private final Gson gson = new Gson();
+
+    @Override
+    public List<KnowledgePoint> extractKnowledgePoints(String blockContent) {
+        String systemPrompt = """
+                你是一个专业的知识点提取助手。请分析学习内容,提取关键知识点。
+
+                要求:
+                1. 提取3-5个关键知识点
+                2. 每个知识点要简洁明了,一句话概括
+                3. 代码块和公式视为辅助内容,不作为知识点
+                4. 必须定位到原文中的具体文字(sourceText)
+                5. 提取出来的sourceText使用原Markdown的格式
+                6. 提取出来的sourceText不能只有公式,必须包含介绍该公式的文字
+                7. 只返回JSON数组,不要其他文字
+
+                返回格式:
+                [
+                  {"content": "知识点描述", "sourceText": "原文中的相关文字"},
+                  ...
+                ]
+                """;
+
+        String userMessage = "请分析以下学习内容并提取知识点:\n\n" + blockContent;
+
+        try {
+            // 使用 JSON 模式确保返回结构化数据
+            String response = callModelSyncWithJsonMode(dashScopeConfig.getKnowledgeExtractorModel(), systemPrompt,
+                    userMessage, false);
+
+            // 提取JSON部分
+            String jsonStr = extractJson(response);
+            JsonArray arr = gson.fromJson(jsonStr, JsonArray.class);
+            List<KnowledgePoint> result = new ArrayList<>();
+
+            for (int i = 0; i < arr.size(); i++) {
+                JsonObject obj = arr.get(i).getAsJsonObject();
+                KnowledgePoint kp = new KnowledgePoint();
+                kp.setContent(obj.get("content").getAsString());
+                kp.setSourceText(obj.has("sourceText") ? obj.get("sourceText").getAsString() : null);
+                result.add(kp);
+            }
+
+            return result;
+        } catch (Exception e) {
+            log.error("提取知识点失败", e);
+            return new ArrayList<>();
+        }
+    }
+
+    @Override
+    public String generateQuestionsForKnowledgePoints(String blockContent, List<KnowledgePoint> knowledgePoints) {
+        StringBuilder kpListText = new StringBuilder();
+        for (int i = 0; i < knowledgePoints.size(); i++) {
+            KnowledgePoint kp = knowledgePoints.get(i);
+            kpListText.append(String.format("%d. ID=%d, 内容:%s, 原文:%s\n",
+                    i + 1,
+                    kp.getId(),
+                    kp.getContent(),
+                    kp.getSourceText() != null ? kp.getSourceText() : "无"));
+        }
+
+        String systemPrompt = """
+                你是一个专业的题目生成助手。请根据给定的多个知识点,为每个知识点生成一道测试题。
+
+                要求:
+                1. 为每个知识点生成一道题目
+                2. 题目类型随机选择:单选(SINGLE)、多选(MULTIPLE)、问答(ESSAY)
+                3. 单选题提供4个选项A/B/C/D
+                4. 多选题提供4-5个选项
+                5. 问答题设计开放性问题
+                6. 必须提供正确答案和解析
+                7. 解析中要引用原文作为依据
+                8. 只返回JSON数组,不要其他文字
+                9. 选择题选项的长度要统一,不能有太明显的长短差异
+                10. 数组中题目顺序必须与知识点列表顺序一致
+
+
+                返回JSON格式(数组):
+                [
+                  {
+                    "type": "SINGLE/MULTIPLE/ESSAY",
+                    "questionText": "题目内容",
+                    "options": ["A. 选项1", "B. 选项2", ...],
+                    "correctAnswer": "A / A,B,C / 参考答案文本",
+                    "explanation": "解析说明",
+                    "sourceEvidence": "原文依据"
+                  },
+                  ...
+                ]
+                """;
+
+        String userMessage = String.format("""
+                【学习内容】
+                %s
+
+                【知识点列表】
+                %s
+
+                请为上述每个知识点生成一道测试题,确保题目覆盖该知识点的核心内容。
+                返回的JSON数组中,第i个题目对应第i个知识点。
+                """,
+                blockContent,
+                kpListText);
+
+        try {
+            return callModelSyncWithJsonMode(dashScopeConfig.getQuestionGeneratorModel(), systemPrompt, userMessage,
+                    false);
+        } catch (Exception e) {
+            log.error("批量生成题目失败", e);
+            // 返回默认题目数组
+            StringBuilder fallback = new StringBuilder("[");
+            for (int i = 0; i < knowledgePoints.size(); i++) {
+                if (i > 0)
+                    fallback.append(",");
+                fallback.append(String.format("""
+                        {
+                          "type": "ESSAY",
+                          "questionText": "请说明:%s",
+                          "options": null,
+                          "correctAnswer": "根据原文内容回答",
+                          "explanation": "这是基于知识点的开放性问答题",
+                          "sourceEvidence": "%s"
+                        }
+                        """,
+                        knowledgePoints.get(i).getContent(),
+                        knowledgePoints.get(i).getSourceText() != null ? knowledgePoints.get(i).getSourceText() : ""));
+            }
+            fallback.append("]");
+            return fallback.toString();
+        }
+    }
+
+    @Override
+    public String generateQuestionForKnowledgePoint(String blockContent, KnowledgePoint knowledgePoint,
+            QuestionType type) {
+        StringBuilder requirements = new StringBuilder();
+        if (type == null) {
+            requirements.append("1. 题目类型随机选择:单选(SINGLE)、多选(MULTIPLE)、问答(ESSAY)\n");
+            requirements.append("2. 单选题提供4个选项A/B/C/D\n");
+            requirements.append("3. 多选题提供4-5个选项\n");
+            requirements.append("4. 问答题设计开放性问题");
+        } else {
+            requirements.append("1. 题目类型必须为:").append(type.name());
+            if (type == QuestionType.SINGLE) {
+                requirements.append("\n2. 提供4个选项A/B/C/D");
+            } else if (type == QuestionType.MULTIPLE) {
+                requirements.append("\n2. 提供4-5个选项");
+            } else if (type == QuestionType.ESSAY) {
+                requirements.append("\n2. 设计开放性问题");
+            }
+        }
+
+        requirements.append("\n3. 必须提供正确答案和解析");
+        requirements.append("\n4. 解析中要引用原文作为依据");
+        requirements.append("\n5. 只返回JSON,不要其他文字");
+        if (type != QuestionType.ESSAY) {
+            requirements.append("\n6. 选择题选项的长度要统一,不能有太明显的长短差异");
+        }
+
+        String systemPrompt = """
+                你是一个专业的题目生成助手。请根据指定的知识点生成一道测试题。
+
+                要求:
+                %s
+
+                返回JSON格式:
+                {
+                  "type": "SINGLE/MULTIPLE/ESSAY",
+                  "questionText": "题目内容",
+                  "options": ["A. 选项1", "B. 选项2", ...],
+                  "correctAnswer": "A / A,B,C / 参考答案文本",
+                  "explanation": "解析说明",
+                  "sourceEvidence": "原文依据"
+                }
+                """.formatted(requirements.toString());
+
+        String userMessage = String.format("""
+                【学习内容】
+                %s
+
+                【目标知识点】
+                %s
+
+                【原文依据】
+                %s
+
+                请针对这个知识点生成一道测试题,确保题目覆盖该知识点的核心内容。
+                """,
+                blockContent,
+                knowledgePoint.getContent(),
+                knowledgePoint.getSourceText() != null ? knowledgePoint.getSourceText() : "无");
+
+        try {
+            return callModelSyncWithJsonMode(dashScopeConfig.getQuestionGeneratorModel(), systemPrompt, userMessage,
+                    true);
+        } catch (Exception e) {
+            log.error("为知识点 {} 生成题目失败", knowledgePoint.getId(), e);
+            return String.format("""
+                    {
+                      "type": "ESSAY",
+                      "questionText": "请说明:%s",
+                      "options": null,
+                      "correctAnswer": "根据原文内容回答",
+                      "explanation": "这是基于知识点的开放性问答题",
+                      "sourceEvidence": "%s"
+                    }
+                    """,
+                    knowledgePoint.getContent(),
+                    knowledgePoint.getSourceText() != null ? knowledgePoint.getSourceText() : "");
+        }
+    }
+
+    @Override
+    public String gradeEssayAnswer(String questionText, String correctAnswer, String userAnswer) {
+        String systemPrompt = """
+                你是一个专业的答案批改助手。请批改问答题的答案。
+
+                要求:
+                1. 给出0-100分的评分
+                2. 提供详细的反馈意见
+                3. 如果答案不完整或错误,给出回看建议
+                4. 只返回JSON,不要其他文字
+
+                返回JSON格式:
+                {
+                  "score": 85,
+                  "feedback": "反馈意见...",
+                  "reviewSuggestion": "建议回看的内容..."
+                }
+                """;
+
+        String userMessage = """
+                【题目】
+                %s
+
+                【参考答案】
+                %s
+
+                【学生答案】
+                %s
+
+                请批改这道题。
+                """.formatted(questionText, correctAnswer, userAnswer);
+
+        try {
+            // 使用 JSON 模式确保返回结构化数据
+            return callModelSyncWithJsonMode(dashScopeConfig.getAnswerGraderModel(), systemPrompt, userMessage, false);
+        } catch (Exception e) {
+            log.error("批改答案失败", e);
+            return "{\"score\": 60, \"feedback\": \"系统繁忙,暂定为及格\", \"reviewSuggestion\": \"请自行核对参考答案\"}";
+        }
+    }
+
+    @Override
+    public String generateDetailedExplanation(String question, String correctAnswer, String explanation,
+            String userAnswer) {
+        String systemPrompt = """
+                你是一个耐心的辅导老师。学生对这道题感到困惑(标记为薄弱点),请提供一份非常详细的解析。
+
+                要求:
+                1. 结合题目、正确答案和原解析进行深入讲解。
+                2. 分析学生的答案(如果有),指出可能的误区。
+                3. 将知识点拆解,用通俗易懂的语言解释。
+                4. 给出记忆或理解的小技巧。
+                5. 直接返回解析文本,不需要JSON格式。
+                """;
+
+        String userMessage = """
+                【题目】
+                %s
+
+                【正确答案】
+                %s
+
+                【原解析】
+                %s
+
+                【学生答案】
+                %s
+
+                请生成详细解析。
+                """.formatted(question, correctAnswer, explanation, userAnswer != null ? userAnswer : "未作答");
+
+        try {
+            return chatSync(systemPrompt, userMessage);
+        } catch (Exception e) {
+            log.error("生成详细解析失败", e);
+            return explanation + "\n\n(系统注:生成详细解析失败,以上为原解析)";
+        }
+    }
+
+    @Override
+    public Flux<String> chat(ConversationType conversationType, String blockContent, String selectedText,
+            String userMessage, List<ReviewChatMessage> historyMessages) {
+        // 构建系统提示词
+        String systemPrompt = buildSystemPrompt(conversationType, blockContent, selectedText);
+
+        // 构建消息列表
+        List<Message> messages = new ArrayList<>();
+
+        // 添加系统消息
+        messages.add(Message.builder()
+                .role(Role.SYSTEM.getValue())
+                .content(systemPrompt)
+                .build());
+
+        // 添加历史消息(最近20条,保持对话上下文)
+        if (historyMessages != null && !historyMessages.isEmpty()) {
+            int start = Math.max(0, historyMessages.size() - 20);
+            for (int i = start; i < historyMessages.size(); i++) {
+                ReviewChatMessage msg = historyMessages.get(i);
+                String role = "user".equals(msg.getRole()) ? Role.USER.getValue() : Role.ASSISTANT.getValue();
+                messages.add(Message.builder()
+                        .role(role)
+                        .content(msg.getContent())
+                        .build());
+            }
+        }
+
+        // 添加当前用户消息
+        messages.add(Message.builder()
+                .role(Role.USER.getValue())
+                .content(userMessage)
+                .build());
+
+        try {
+            return callModelStream(dashScopeConfig.getReviewAssistantModel(), messages);
+        } catch (Exception e) {
+            log.error("对话失败", e);
+            return Flux.just("抱歉,对话出现问题,请稍后重试。");
+        }
+    }
+
+    @Override
+    public String chatSync(String systemPrompt, String userMessage) {
+        try {
+            return callModelSync(dashScopeConfig.getReviewAssistantModel(), systemPrompt, userMessage);
+        } catch (Exception e) {
+            log.error("同步对话失败", e);
+            return "抱歉,对话出现问题,请稍后重试。";
+        }
+    }
+
+    @Override
+    public String generateConversationTitle(String userMessage, String aiResponse) {
+        String systemPrompt = """
+                你是一个标题生成助手。请根据用户和AI的对话内容,生成一个简短的对话标题。
+
+                要求:
+                1. 标题要简洁明了,概括对话的主题
+                2. 标题长度不超过15个字
+                3. 不要使用引号、书名号等符号
+                4. 直接返回标题文本,不要任何其他内容
+                """;
+
+        String userPrompt = """
+                用户消息:%s
+
+                AI回复:%s
+
+                请生成一个简短的对话标题:
+                """.formatted(
+                userMessage.length() > 200 ? userMessage.substring(0, 200) + "..." : userMessage,
+                aiResponse.length() > 200 ? aiResponse.substring(0, 200) + "..." : aiResponse);
+
+        try {
+            String title = callModelSync(dashScopeConfig.getTitleGeneratorModel(), systemPrompt, userPrompt);
+            // 清理标题,去除可能的引号和多余空白
+            title = title.trim()
+                    .replaceAll("^[\"'「」『』《》【】]+", "")
+                    .replaceAll("[\"'「」『』《》【】]+$", "")
+                    .trim();
+            // 限制长度
+            if (title.length() > 20) {
+                title = title.substring(0, 20) + "...";
+            }
+            return title.isEmpty() ? "新对话" : title;
+        } catch (Exception e) {
+            log.error("生成对话标题失败", e);
+            return "新对话";
+        }
+    }
+
+    /**
+     * 构建系统提示词
+     */
+    private String buildSystemPrompt(ConversationType type, String blockContent, String selectedText) {
+        if (type == ConversationType.KNOWLEDGE_QA) {
+            // 知识点问答/划词提问
+            return """
+                    你是一位专业的学习助教,正在帮助学生理解学习内容。
+
+                    【当前学习内容】
+                    %s
+
+                    【学生划词选中的内容】
+                    %s
+
+                    要求:
+                    1. 重点解释学生划词选中的内容
+                    2. 结合上下文进行深入讲解
+                    3. 可以举例说明,帮助理解
+                    4. 回答要有条理,可以使用列表或分点说明
+                    5. 如果学生的问题超出当前内容范围,也可以适当扩展
+                    """.formatted(
+                    blockContent != null ? blockContent : "(未提供具体内容)",
+                    selectedText != null ? selectedText : "(未选中具体内容)");
+        } else {
+            // 自由对话
+            return """
+                    你是一位专业的学习助教,正在帮助学生复习和学习。
+
+                    %s
+
+                    要求:
+                    1. 用简洁明了的语言回答学生的问题
+                    2. 帮助学生更好地理解和掌握知识点
+                    3. 回答要有条理,可以使用列表或分点说明
+                    4. 可以主动提供相关的学习建议
+                    5. 如果学生问的内容你不确定,请诚实说明
+                    """.formatted(
+                    blockContent != null ? "【当前学习内容参考】\n" + blockContent : "");
+        }
+    }
+
+    /**
+     * 同步调用模型(普通模式)
+     */
+    private String callModelSync(String modelName, String systemPrompt, String userMessage) throws Exception {
+        List<Message> messages = new ArrayList<>();
+        messages.add(Message.builder()
+                .role(Role.SYSTEM.getValue())
+                .content(systemPrompt)
+                .build());
+        messages.add(Message.builder()
+                .role(Role.USER.getValue())
+                .content(userMessage)
+                .build());
+
+        GenerationParam param = GenerationParam.builder()
+                .apiKey(dashScopeConfig.getApiKey())
+                .model(modelName)
+                .messages(messages)
+                .resultFormat(GenerationParam.ResultFormat.MESSAGE)
+                .build();
+
+        Generation generation = new Generation();
+        GenerationResult result = generation.call(param);
+
+        return result.getOutput().getChoices().get(0).getMessage().getContent();
+    }
+
+    /**
+     * 同步调用模型(JSON 结构化输出模式)
+     * 用于需要返回结构化 JSON 数据的场景,如知识点提取、题目生成、答案批改
+     *
+     * @param modelName    模型名称
+     * @param systemPrompt 系统提示词
+     * @param userMessage  用户消息
+     * @param enableSearch 是否启用联网搜索
+     * @return JSON格式的响应内容
+     * @throws Exception 调用异常
+     */
+    private String callModelSyncWithJsonMode(String modelName, String systemPrompt, String userMessage,
+            boolean enableSearch) throws Exception {
+        List<Message> messages = new ArrayList<>();
+        messages.add(Message.builder()
+                .role(Role.SYSTEM.getValue())
+                .content(systemPrompt)
+                .build());
+        messages.add(Message.builder()
+                .role(Role.USER.getValue())
+                .content(userMessage)
+                .build());
+
+        // 启用 JSON 结构化输出模式
+        ResponseFormat jsonMode = ResponseFormat.builder()
+                .type("json_object")
+                .build();
+
+        GenerationParam.GenerationParamBuilder<?, ?> paramBuilder = GenerationParam.builder()
+                .apiKey(dashScopeConfig.getApiKey())
+                .model(modelName)
+                .messages(messages)
+                .resultFormat(GenerationParam.ResultFormat.MESSAGE)
+                .responseFormat(jsonMode);
+
+        // 根据参数决定是否启用联网搜索
+        if (enableSearch) {
+            paramBuilder.enableSearch(true);
+        }
+
+        GenerationParam param = paramBuilder.build();
+
+        Generation generation = new Generation();
+        GenerationResult result = generation.call(param);
+
+        return result.getOutput().getChoices().get(0).getMessage().getContent();
+    }
+
+    /**
+     * 流式调用模型
+     */
+    private Flux<String> callModelStream(String modelName, List<Message> messages) {
+        try {
+            GenerationParam param = GenerationParam.builder()
+                    .apiKey(dashScopeConfig.getApiKey())
+                    .model(modelName)
+                    .messages(messages)
+                    .resultFormat(GenerationParam.ResultFormat.MESSAGE)
+                    .incrementalOutput(true)
+                    .enableSearch(true)
+                    .build();
+
+            Generation generation = new Generation();
+            Flowable<GenerationResult> resultFlowable = generation.streamCall(param);
+
+            return Flux.from(resultFlowable)
+                    .map(result -> {
+                        if (result.getOutput() != null
+                                && result.getOutput().getChoices() != null
+                                && !result.getOutput().getChoices().isEmpty()
+                                && result.getOutput().getChoices().get(0).getMessage() != null) {
+                            return result.getOutput().getChoices().get(0).getMessage().getContent();
+                        }
+                        return "";
+                    })
+                    .filter(text -> text != null && !text.isEmpty());
+        } catch (Exception e) {
+            log.error("流式调用失败", e);
+            return Flux.just("抱歉,对话出现问题,请稍后重试。");
+        }
+    }
+
+    /**
+     * 从响应中提取JSON
+     */
+    private String extractJson(String response) {
+        if (response == null)
+            return "[]";
+
+        // 尝试找到JSON数组或对象
+        int arrayStart = response.indexOf('[');
+        int objectStart = response.indexOf('{');
+
+        if (arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart)) {
+            int arrayEnd = response.lastIndexOf(']');
+            if (arrayEnd > arrayStart) {
+                return response.substring(arrayStart, arrayEnd + 1);
+            }
+        } else if (objectStart >= 0) {
+            int objectEnd = response.lastIndexOf('}');
+            if (objectEnd > objectStart) {
+                return response.substring(objectStart, objectEnd + 1);
+            }
+        }
+
+        return response;
+    }
+}

+ 254 - 0
src/main/java/com/smartreview/service/impl/BlockServiceImpl.java

@@ -0,0 +1,254 @@
+package com.smartreview.service.impl;
+
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.enums.BlockType;
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.Block;
+import com.smartreview.po.Chapter;
+import com.smartreview.po.Document;
+import com.smartreview.po.KnowledgePoint;
+import com.smartreview.repository.*;
+import com.smartreview.service.AIService;
+import com.smartreview.service.BlockService;
+import com.smartreview.service.KnowledgeIndexerService;
+import com.smartreview.vo.block.BlockDetailVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Block服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BlockServiceImpl implements BlockService {
+
+    private final BlockRepository blockRepository;
+    private final DocumentRepository documentRepository;
+    private final ChapterRepository chapterRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+    private final QuestionRepository questionRepository;
+    private final UserAnswerRepository userAnswerRepository;
+    private final QuestionGenerationTaskRepository questionGenerationTaskRepository;
+    private final AIService aiService;
+    private final KnowledgeIndexerService knowledgeIndexerService;
+
+    @Override
+    public BlockDetailVO getBlockDetail(Long blockId, Long userId) {
+        Block block = blockRepository.findById(blockId)
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+
+        // 验证权限
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此内容");
+        }
+
+        // 更新最后复习时间
+        document.setLastReviewedAt(LocalDateTime.now());
+        documentRepository.save(document);
+
+        // 获取该文档的所有blocks以计算索引
+        List<Block> allBlocks = blockRepository.findByDocumentIdOrderBySortOrder(block.getDocumentId());
+        int currentIndex = 0;
+        for (int i = 0; i < allBlocks.size(); i++) {
+            if (allBlocks.get(i).getId().equals(blockId)) {
+                currentIndex = i + 1;
+                break;
+            }
+        }
+
+        BlockDetailVO vo = new BlockDetailVO();
+        vo.setId(block.getId());
+        vo.setDocumentId(block.getDocumentId());
+        vo.setChapterId(block.getChapterId());
+        vo.setContent(block.getContent());
+        vo.setSortOrder(block.getSortOrder());
+        vo.setStatus(block.getStatus());
+        vo.setTotalBlocks(allBlocks.size());
+        vo.setCurrentIndex(currentIndex);
+        vo.setWrongAnswerCount(block.getWrongAnswerCount() != null ? block.getWrongAnswerCount() : 0);
+        vo.setWeakPointCount(block.getWeakPointCount() != null ? block.getWeakPointCount() : 0);
+
+        return vo;
+    }
+
+    @Override
+    public void updateBlockProgress(Long blockId, BlockStatus status, Long userId) {
+        Block block = blockRepository.findById(blockId)
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+
+        // 验证权限
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权修改此内容");
+        }
+
+        // READ_ONLY类型的block状态不能被修改
+        if (block.getType() == BlockType.READ_ONLY) {
+            throw SmartReviewException.badRequest("只读内容块的状态不能被修改");
+        }
+
+        BlockStatus oldStatus = block.getStatus();
+        block.setStatus(status);
+        blockRepository.save(block);
+
+        // 更新文档完成数(只统计LEARNABLE类型的block)
+        if (block.getType() == BlockType.LEARNABLE) {
+            if (oldStatus != BlockStatus.COMPLETED && status == BlockStatus.COMPLETED) {
+                document.setCompletedBlocks(document.getCompletedBlocks() + 1);
+                documentRepository.save(document);
+            } else if (oldStatus == BlockStatus.COMPLETED && status != BlockStatus.COMPLETED) {
+                document.setCompletedBlocks(Math.max(0, document.getCompletedBlocks() - 1));
+                documentRepository.save(document);
+            }
+        }
+    }
+
+    @Override
+    @Transactional
+    public void updateBlockContent(Long blockId, String newContent, Long userId) {
+        Block block = blockRepository.findById(blockId)
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+
+        // 验证权限
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权修改此内容");
+        }
+
+        // 清除关联数据
+        clearBlockRelatedData(blockId);
+
+        // 记录原状态,用于更新文档统计
+        BlockStatus oldStatus = block.getStatus();
+
+        // 更新Block内容
+        block.setContent(newContent);
+        block.setStatus(BlockStatus.UNREAD); // 重置状态
+        block.setWrongAnswerCount(0);
+        block.setWeakPointCount(0);
+        blockRepository.save(block);
+
+        // 如果原状态是COMPLETED,需要更新文档统计
+        if (block.getType() == BlockType.LEARNABLE && oldStatus == BlockStatus.COMPLETED) {
+            document.setCompletedBlocks(Math.max(0, document.getCompletedBlocks() - 1));
+            documentRepository.save(document);
+        }
+
+        // 异步重新生成知识点
+        regenerateKnowledgePointsAsync(block);
+
+        log.info("Block内容已更新,ID: {},已触发知识点重新生成", blockId);
+    }
+
+    @Override
+    @Transactional
+    public void deleteBlock(Long blockId, Long userId) {
+        Block block = blockRepository.findById(blockId)
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+
+        // 验证权限
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权删除此内容");
+        }
+
+        // 处理Chapter层级关系:将子Chapter托付给被删除Block对应Chapter的父级
+        if (block.getChapterId() != null) {
+            Chapter chapter = chapterRepository.findById(block.getChapterId()).orElse(null);
+            if (chapter != null) {
+                // 查找所有以该Chapter为父级的子Chapter
+                List<Chapter> childChapters = chapterRepository.findByParentIdOrderBySortOrder(chapter.getId());
+                for (Chapter childChapter : childChapters) {
+                    // 将子Chapter的parentId更新为被删除Chapter的parentId
+                    childChapter.setParentId(chapter.getParentId());
+                    chapterRepository.save(childChapter);
+                }
+                // 删除该Chapter
+                chapterRepository.delete(chapter);
+            }
+        }
+
+        // 清除关联数据
+        clearBlockRelatedData(blockId);
+
+        // 更新文档统计(如果是LEARNABLE类型,需要更新统计)
+        if (block.getType() == BlockType.LEARNABLE) {
+            document.setTotalBlocks(Math.max(0, document.getTotalBlocks() - 1));
+            if (block.getStatus() == BlockStatus.COMPLETED) {
+                document.setCompletedBlocks(Math.max(0, document.getCompletedBlocks() - 1));
+            }
+            documentRepository.save(document);
+        }
+
+        // 删除Block
+        blockRepository.delete(block);
+
+        log.info("Block已删除,ID: {}", blockId);
+    }
+
+    /**
+     * 清除Block关联的所有数据(知识点、题目、用户答案、题目生成任务)
+     */
+    private void clearBlockRelatedData(Long blockId) {
+        // 删除用户答案
+        userAnswerRepository.deleteByBlockId(blockId);
+        log.debug("已删除Block {} 的用户答案", blockId);
+
+        // 删除题目
+        questionRepository.deleteByBlockId(blockId);
+        log.debug("已删除Block {} 的题目", blockId);
+
+        // 删除知识点
+        knowledgePointRepository.deleteByBlockId(blockId);
+        log.debug("已删除Block {} 的知识点", blockId);
+
+        // 删除题目生成任务
+        questionGenerationTaskRepository.deleteByBlockId(blockId);
+        log.debug("已删除Block {} 的题目生成任务", blockId);
+    }
+
+    /**
+     * 异步重新生成知识点
+     */
+    @Async
+    @Transactional
+    public void regenerateKnowledgePointsAsync(Block block) {
+        try {
+            if (block.getType() == BlockType.LEARNABLE) {
+                List<KnowledgePoint> kps = aiService.extractKnowledgePoints(block.getContent());
+                for (KnowledgePoint kp : kps) {
+                    kp.setBlockId(block.getId());
+                    kp.setIsFromChat(false);
+                    knowledgePointRepository.save(kp);
+                }
+                log.info("Block {} 知识点重新生成完成,共生成 {} 个知识点", block.getId(), kps.size());
+
+                // 在事务提交后再触发知识点索引,避免并发事务冲突
+                Long blockId = block.getId();
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCommit() {
+                        knowledgeIndexerService.indexBlock(blockId);
+                        log.info("Block {} 已触发知识点索引任务", blockId);
+                    }
+                });
+            }
+        } catch (Exception e) {
+            log.error("Block {} 知识点重新生成失败", block.getId(), e);
+        }
+    }
+}

+ 104 - 0
src/main/java/com/smartreview/service/impl/DocumentCleanupServiceImpl.java

@@ -0,0 +1,104 @@
+package com.smartreview.service.impl;
+
+import com.smartreview.po.Block;
+import com.smartreview.po.Conversation;
+import com.smartreview.repository.*;
+import com.smartreview.service.DocumentCleanupService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 文档清理服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DocumentCleanupServiceImpl implements DocumentCleanupService {
+
+    private final BlockRepository blockRepository;
+    private final ChapterRepository chapterRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+    private final QuestionRepository questionRepository;
+    private final UserAnswerRepository userAnswerRepository;
+    private final QuestionGenerationTaskRepository questionGenerationTaskRepository;
+    private final ConversationRepository conversationRepository;
+    private final ReviewChatMessageRepository reviewChatMessageRepository;
+
+    @Override
+    @Transactional
+    public void cleanOldDocumentData(String documentId) {
+        log.info("开始清理文档旧数据: {}", documentId);
+
+        // 删除所有Block下的知识点
+        List<Block> oldBlocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+        for (Block oldBlock : oldBlocks) {
+            knowledgePointRepository.deleteByBlockId(oldBlock.getId());
+        }
+
+        // 删除所有Block
+        blockRepository.deleteByDocumentId(documentId);
+
+        // 删除所有章节
+        chapterRepository.deleteByDocumentId(documentId);
+
+        log.info("文档旧数据清理完成: {}", documentId);
+    }
+
+    @Override
+    @Transactional
+    public void cleanAllDocumentData(String documentId) {
+        log.info("开始完全清理文档所有关联数据: {}", documentId);
+
+        // 获取所有Block
+        List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+        List<Long> blockIds = blocks.stream().map(Block::getId).toList();
+
+        if (!blockIds.isEmpty()) {
+            // 1. 删除用户答案
+            for (Long blockId : blockIds) {
+                userAnswerRepository.deleteByBlockId(blockId);
+            }
+            log.debug("已删除文档相关的用户答案: documentId={}", documentId);
+
+            // 2. 删除题目
+            for (Long blockId : blockIds) {
+                questionRepository.deleteByBlockId(blockId);
+            }
+            log.debug("已删除文档相关的题目: documentId={}", documentId);
+
+            // 3. 删除题目生成任务
+            for (Long blockId : blockIds) {
+                questionGenerationTaskRepository.deleteByBlockId(blockId);
+            }
+            log.debug("已删除文档相关的题目生成任务: documentId={}", documentId);
+
+            // 4. 删除知识点
+            for (Long blockId : blockIds) {
+                knowledgePointRepository.deleteByBlockId(blockId);
+            }
+            log.debug("已删除文档相关的知识点: documentId={}", documentId);
+        }
+
+        // 5. 删除对话和聊天记录
+        List<Conversation> conversations = conversationRepository.findByDocumentId(documentId);
+        for (Conversation conversation : conversations) {
+            reviewChatMessageRepository.deleteByConversationId(conversation.getId());
+        }
+        conversationRepository.deleteByDocumentId(documentId);
+        log.debug("已删除文档相关的对话和聊天记录: documentId={}", documentId);
+
+        // 6. 删除所有Block
+        blockRepository.deleteByDocumentId(documentId);
+        log.debug("已删除文档的所有Block: documentId={}", documentId);
+
+        // 7. 删除所有章节
+        chapterRepository.deleteByDocumentId(documentId);
+        log.debug("已删除文档的所有章节: documentId={}", documentId);
+
+        log.info("文档所有关联数据清理完成: {}", documentId);
+    }
+}

+ 895 - 0
src/main/java/com/smartreview/service/impl/DocumentServiceImpl.java

@@ -0,0 +1,895 @@
+package com.smartreview.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.enums.BlockType;
+import com.smartreview.enums.DocumentStatus;
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.*;
+import com.smartreview.repository.*;
+import com.smartreview.service.*;
+import com.smartreview.utils.MarkdownParser;
+import com.smartreview.utils.OssUtil;
+import com.smartreview.vo.document.*;
+import com.smartreview.vo.mineru.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Sinks;
+import reactor.core.scheduler.Schedulers;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 文档服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DocumentServiceImpl implements DocumentService {
+
+    /**
+     * 支持的文件类型
+     * 参考Mineru支持的格式: pdf, dot, doc, docx, ppt, pptx, png, pjp, jfif, jpe, pjpeg,
+     * jpeg, jpg
+     */
+    private static final Set<String> SUPPORTED_FILE_TYPES = Set.of(
+            "md", "pdf", "dot", "doc", "docx", "ppt", "pptx",
+            "png", "jpg", "jpeg", "jpe", "jfif", "pjp", "pjpeg");
+
+    private final DocumentRepository documentRepository;
+    private final ChapterRepository chapterRepository;
+    private final BlockRepository blockRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+    private final ConversationRepository conversationRepository;
+    private final ReviewChatMessageRepository chatMessageRepository;
+    private final OssUtil ossUtil;
+    private final AIService aiService;
+    private final MineruService mineruService;
+    private final MarkdownParser markdownParser;
+    private final KnowledgeIndexerService knowledgeIndexerService;
+    private final DocumentCleanupService documentCleanupService;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Override
+    public String uploadDocument(MultipartFile file, Long userId) {
+        try {
+            String originalName = file.getOriginalFilename();
+            String ossPath = "documents/" + userId + "/" + System.currentTimeMillis() + "_" + originalName;
+
+            // 获取文件类型
+            String fileType = extractFileType(originalName);
+            if (!SUPPORTED_FILE_TYPES.contains(fileType.toLowerCase())) {
+                throw SmartReviewException.badRequest("不支持的文件类型: " + fileType);
+            }
+
+            // 上传并拿到 objectName(ossPath)
+            String storedPath = ossUtil.upload(ossPath, file.getInputStream());
+
+            // 以文件名(去扩展名)作为默认标题
+            String title = originalName;
+            if (title != null) {
+                int idx = title.lastIndexOf('.');
+                if (idx > 0) {
+                    title = title.substring(0, idx);
+                }
+            }
+            if (title == null || title.isBlank()) {
+                title = "未命名文档";
+            }
+
+            Document document = new Document();
+            document.setUserId(userId);
+            document.setTitle(title);
+            document.setOriginalFilename(originalName);
+            document.setFileType(fileType.toLowerCase());
+            document.setOssPath(storedPath);
+            document.setStatus(DocumentStatus.UPLOADED);
+            document.setStatusMessage("已上传,等待解析");
+            document.setParseProgress(0);
+            document.setTotalBlocks(0);
+            document.setCompletedBlocks(0);
+            document = documentRepository.save(document);
+
+            return document.getId();
+        } catch (SmartReviewException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("上传文档失败", e);
+            throw SmartReviewException.error("上传文档失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 提取文件扩展名
+     */
+    private String extractFileType(String filename) {
+        if (filename == null || filename.isBlank()) {
+            return "";
+        }
+        int idx = filename.lastIndexOf('.');
+        if (idx > 0 && idx < filename.length() - 1) {
+            return filename.substring(idx + 1);
+        }
+        return "";
+    }
+
+    @Override
+    public Flux<ParseProgressVO> parseDocument(ParseDocumentRequestVO request, Long userId) {
+        Sinks.Many<ParseProgressVO> sink = Sinks.many().unicast().onBackpressureBuffer();
+
+        // 异步处理解析任务
+        Flux.just(request)
+                .subscribeOn(Schedulers.boundedElastic())
+                .subscribe(req -> {
+                    try {
+                        // 先查已有文档(如果传入文档ID)
+                        Document existingDocument = null;
+                        if (req.getDocumentId() != null) {
+                            existingDocument = documentRepository.findById(req.getDocumentId())
+                                    .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+                            if (!existingDocument.getUserId().equals(userId)) {
+                                throw SmartReviewException.forbidden("无权访问此文档");
+                            }
+                        }
+
+                        // 阶段1:解析Markdown
+                        emitProgress(sink, "PARSING", 10, "正在解析Markdown文档...", null);
+
+                        String content = req.getContent();
+                        if (content == null && existingDocument != null && existingDocument.getOssPath() != null) {
+                            // 使用存储的 ossPath 生成签名URL后下载内容
+                            String signedUrl = ossUtil.generatePresignedUrl(existingDocument.getOssPath(), 3600);
+                            content = downloadContent(signedUrl);
+                        }
+                        if (content == null || content.isBlank()) {
+                            throw SmartReviewException.badRequest("文档内容为空");
+                        }
+
+                        // 阶段2:切分章节和Block
+                        emitProgress(sink, "CHUNKING", 30, "正在切分文档结构...", null);
+                        MarkdownParser.ParseResult parseResult = markdownParser.parse(content);
+
+                        // 如果是重新解析已有文档,清理旧数据
+                        if (existingDocument != null) {
+                            documentCleanupService.cleanOldDocumentData(existingDocument.getId());
+                        }
+
+                        // 保存文档
+                        Document document = existingDocument != null ? existingDocument : new Document();
+                        document.setUserId(userId);
+                        document.setTitle(req.getTitle() != null
+                                ? req.getTitle()
+                                : (existingDocument != null ? existingDocument.getTitle() : "未命名文档"));
+                        document.setStatus(DocumentStatus.PARSING);
+                        document.setTotalBlocks(0);
+                        document.setCompletedBlocks(0);
+                        document = documentRepository.save(document);
+
+                        // 保存章节
+                        Map<Integer, Long> chapterIdMap = new HashMap<>();
+                        for (MarkdownParser.ChapterInfo chapterInfo : parseResult.getChapters()) {
+                            Chapter chapter = new Chapter();
+                            chapter.setDocumentId(document.getId());
+                            chapter.setTitle(chapterInfo.getTitle());
+                            chapter.setLevel(chapterInfo.getLevel());
+                            chapter.setSortOrder(chapterInfo.getSortOrder());
+                            chapter.setParentId(chapterInfo.getParentIndex() != null
+                                    ? chapterIdMap.get(chapterInfo.getParentIndex())
+                                    : null);
+                            chapter = chapterRepository.save(chapter);
+                            chapterIdMap.put(chapterInfo.getSortOrder(), chapter.getId());
+                        }
+
+                        // 保存Block并提取知识点
+                        emitProgress(sink, "EXTRACTING", 50, "正在提取知识点...", null);
+                        List<MarkdownParser.BlockInfo> blocks = parseResult.getBlocks();
+                        int totalBlocks = blocks.size();
+
+                        for (int i = 0; i < blocks.size(); i++) {
+                            MarkdownParser.BlockInfo blockInfo = blocks.get(i);
+
+                            Block block = new Block();
+                            block.setDocumentId(document.getId());
+                            block.setChapterId(
+                                    blockInfo.getChapterIndex() != null ? chapterIdMap.get(blockInfo.getChapterIndex())
+                                            : null);
+                            block.setContent(blockInfo.getContent());
+                            block.setSortOrder(i + 1);
+                            // 设置block类型和初始状态:readonly类型默认为COMPLETED,learnable类型为UNREAD
+                            block.setType(blockInfo.getType());
+                            block.setStatus(blockInfo.getType() == BlockType.READ_ONLY
+                                    ? BlockStatus.COMPLETED
+                                    : BlockStatus.UNREAD);
+                            block = blockRepository.save(block);
+
+                            // AI提取知识点,跳过仅阅读的block
+                            if (blockInfo.getType() == BlockType.LEARNABLE) {
+                                try {
+                                    List<KnowledgePoint> kps = aiService.extractKnowledgePoints(blockInfo.getContent());
+                                    for (KnowledgePoint kp : kps) {
+                                        kp.setBlockId(block.getId());
+                                        kp.setIsFromChat(false);
+                                        knowledgePointRepository.save(kp);
+                                    }
+                                } catch (Exception e) {
+                                    log.warn("提取知识点失败,Block ID: {}", block.getId(), e);
+                                }
+                            } else {
+                                log.debug("跳过仅阅读block的知识点提取,Block ID: {}", block.getId());
+                            }
+
+                            int progress = 50 + (int) ((i + 1.0) / totalBlocks * 40);
+                            emitProgress(sink, "EXTRACTING", progress,
+                                    String.format("正在处理第 %d/%d 个内容块...", i + 1, totalBlocks), null);
+                        }
+
+                        // 更新文档状态(totalBlocks只统计LEARNABLE类型)
+                        long learnableBlockCount = blocks.stream()
+                                .filter(b -> b.getType() == BlockType.LEARNABLE)
+                                .count();
+                        document.setTotalBlocks((int) learnableBlockCount);
+                        document.setStatus(DocumentStatus.INDEXING);
+                        documentRepository.save(document);
+
+                        // 触发异步索引任务
+                        knowledgeIndexerService.indexDocument(document.getId());
+
+                        emitProgress(sink, "DONE", 100, "解析完成,正在后台索引知识点", document.getId());
+                        sink.tryEmitComplete();
+
+                    } catch (Exception e) {
+                        log.error("解析文档失败", e);
+                        ParseProgressVO errorVo = new ParseProgressVO();
+                        errorVo.setStage("ERROR");
+                        errorVo.setProgress(0);
+                        errorVo.setMessage("解析失败:" + e.getMessage());
+                        sink.tryEmitNext(errorVo);
+                        sink.tryEmitComplete();
+                    }
+                });
+
+        return sink.asFlux();
+    }
+
+    private void emitProgress(Sinks.Many<ParseProgressVO> sink, String stage, int progress, String message,
+                              String documentId) {
+        ParseProgressVO vo = new ParseProgressVO();
+        vo.setStage(stage);
+        vo.setProgress(progress);
+        vo.setMessage(message);
+        vo.setDocumentId(documentId);
+        sink.tryEmitNext(vo);
+    }
+
+    private String downloadContent(String fileUrl) {
+        try {
+            URL url = new URL(fileUrl);
+            try (BufferedReader reader = new BufferedReader(
+                    new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) {
+                return reader.lines().collect(Collectors.joining("\n"));
+            }
+        } catch (Exception e) {
+            throw SmartReviewException.error("下载文档内容失败:" + e.getMessage());
+        }
+    }
+
+    // ==================== 解析方法(支持多种格式) ====================
+
+    @Override
+    public DocumentParseStatusVO getOrParseDocument(ParseDocumentV2RequestVO request, Long userId) {
+        // 验证文档ID
+        if (request.getDocumentId() == null && (request.getContent() == null || request.getContent().isBlank())) {
+            throw SmartReviewException.badRequest("文档ID或内容不能为空");
+        }
+
+        Document document;
+        boolean isNewDocument = false;
+
+        if (request.getDocumentId() != null) {
+            document = documentRepository.findById(request.getDocumentId())
+                    .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+            if (!document.getUserId().equals(userId)) {
+                throw SmartReviewException.forbidden("无权访问此文档");
+            }
+        } else {
+            // 直接传入内容的情况,创建新文档
+            document = new Document();
+            document.setUserId(userId);
+            document.setTitle(request.getTitle() != null ? request.getTitle() : "未命名文档");
+            document.setFileType("md");
+            document.setStatus(DocumentStatus.UPLOADED);
+            document.setStatusMessage("等待解析");
+            document.setParseProgress(0);
+            document = documentRepository.save(document);
+            isNewDocument = true;
+        }
+
+        DocumentStatus currentStatus = document.getStatus();
+        boolean forceParse = Boolean.TRUE.equals(request.getForceParse());
+
+        // 1. 检查是否正在解析中
+        if (isParsingStatus(currentStatus)) {
+            // 如果是Mineru处理中的状态,主动查询一次最新状态
+            if (isMineruProcessingStatus(currentStatus) && document.getMineruTaskId() != null) {
+                try {
+                    refreshMineruStatus(document);
+                    document = documentRepository.findById(document.getId()).orElse(document);
+                } catch (Exception e) {
+                    log.warn("刷新Mineru状态失败: {}", e.getMessage());
+                }
+            }
+            return buildParseStatusVO(document);
+        }
+
+        // 2. 如果已完成(READY状态)
+        if (currentStatus == DocumentStatus.READY) {
+            if (!forceParse) {
+                // 不强制重新解析,直接返回完成状态
+                return DocumentParseStatusVO.completed(document.getId());
+            }
+            // 强制重新解析,清理所有旧数据
+            log.info("强制重新解析文档,清理旧数据: documentId={}", document.getId());
+            documentCleanupService.cleanAllDocumentData(document.getId());
+        }
+
+        // 3. 如果是已上传/失败状态,或强制重新解析,则启动解析任务
+        if (currentStatus == DocumentStatus.UPLOADED || currentStatus == DocumentStatus.FAILED || forceParse
+                || isNewDocument) {
+
+            // 更新标题(如果传入了)
+            if (request.getTitle() != null && !request.getTitle().isBlank()) {
+                document.setTitle(request.getTitle());
+            }
+
+            // 重置状态
+            document.setStatus(DocumentStatus.PARSING);
+            document.setStatusMessage("正在准备解析");
+            document.setParseProgress(0);
+            document.setMineruErrorMsg(null);
+            documentRepository.save(document);
+
+            // 异步执行解析任务
+            final Document finalDocument = document;
+            final String contentToProcess = request.getContent();
+
+            // 判断是否为Markdown文件
+            if (document.isMarkdown() || (request.getContent() != null && !request.getContent().isBlank())) {
+                // Markdown直接解析 - 异步执行
+                Schedulers.boundedElastic().schedule(() -> {
+                    try {
+                        processMarkdownParsing(finalDocument, contentToProcess);
+                    } catch (Exception e) {
+                        log.error("异步解析Markdown失败, documentId: {}", finalDocument.getId(), e);
+                    }
+                });
+            } else {
+                // 其他格式通过Mineru转换 - 异步执行
+                Schedulers.boundedElastic().schedule(() -> {
+                    try {
+                        processMineruConversion(finalDocument, request);
+                    } catch (Exception e) {
+                        log.error("异步启动Mineru转换失败, documentId: {}", finalDocument.getId(), e);
+                    }
+                });
+            }
+
+            return DocumentParseStatusVO.parsing(document.getId(), DocumentStatus.PARSING, "解析任务已启动", 0);
+        }
+
+        // 其他状态,返回当前状态
+        return buildParseStatusVO(document);
+    }
+
+    /**
+     * 判断是否为正在解析的状态
+     */
+    private boolean isParsingStatus(DocumentStatus status) {
+        return status == DocumentStatus.PARSING ||
+                status == DocumentStatus.CHUNKING ||
+                status == DocumentStatus.EXTRACTING ||
+                status == DocumentStatus.INDEXING ||
+                status == DocumentStatus.UPLOADING_TO_MINERU ||
+                status == DocumentStatus.MINERU_PENDING ||
+                status == DocumentStatus.MINERU_RUNNING ||
+                status == DocumentStatus.MINERU_CONVERTING ||
+                status == DocumentStatus.DOWNLOADING_RESULT ||
+                status == DocumentStatus.EXTRACTING_RESULT;
+    }
+
+    /**
+     * 处理Markdown解析(内部方法,用于异步调用)
+     */
+    private void processMarkdownParsing(Document document, String content) {
+        try {
+            updateDocumentStatus(document, DocumentStatus.PARSING, "正在解析Markdown文档", 10);
+
+            // 获取内容
+            String markdownContent = content;
+            if (markdownContent == null || markdownContent.isBlank()) {
+                if (document.getConvertedMdPath() != null) {
+                    // 使用转换后的Markdown文件
+                    String signedUrl = ossUtil.generatePresignedUrl(document.getConvertedMdPath(), 3600);
+                    markdownContent = downloadContent(signedUrl);
+                } else if (document.getOssPath() != null) {
+                    // 使用原始上传的文件
+                    String signedUrl = ossUtil.generatePresignedUrl(document.getOssPath(), 3600);
+                    markdownContent = downloadContent(signedUrl);
+                }
+            }
+
+            if (markdownContent == null || markdownContent.isBlank()) {
+                throw SmartReviewException.badRequest("文档内容为空");
+            }
+
+            // 解析Markdown
+            parseMarkdownContent(document, markdownContent);
+
+        } catch (Exception e) {
+            log.error("解析Markdown失败, documentId: {}", document.getId(), e);
+            updateDocumentStatus(document, DocumentStatus.FAILED, "解析失败: " + e.getMessage(), 0);
+            document.setMineruErrorMsg(e.getMessage());
+            documentRepository.save(document);
+        }
+    }
+
+    /**
+     * 处理Mineru转换(内部方法,用于异步调用)
+     */
+    private void processMineruConversion(Document document, ParseDocumentV2RequestVO request) {
+        try {
+            // 检查文件是否已上传到OSS
+            if (document.getOssPath() == null || document.getOssPath().isBlank()) {
+                throw SmartReviewException.badRequest("文档文件未上传,无法解析");
+            }
+
+            // 生成OSS签名URL供Mineru访问
+            String signedUrl = ossUtil.generatePresignedUrl(document.getOssPath(), 7200); // 2小时有效期
+
+            updateDocumentStatus(document, DocumentStatus.UPLOADING_TO_MINERU, "正在提交到文档解析平台", 5);
+
+            // 调用Mineru API创建任务
+            MineruTaskResponse taskResponse = mineruService.createTaskByUrl(
+                    signedUrl,
+                    document.getId(),
+                    request.getEnableOcr(),
+                    request.getEnableFormula(),
+                    request.getEnableTable(),
+                    request.getPageRanges());
+
+            // 保存任务ID
+            document.setMineruTaskId(taskResponse.getData().getTaskId());
+            updateDocumentStatus(document, DocumentStatus.MINERU_PENDING, "已提交解析任务,等待处理", 10);
+            documentRepository.save(document);
+
+            log.info("Mineru task created for document: {}, taskId: {}",
+                    document.getId(), taskResponse.getData().getTaskId());
+
+        } catch (Exception e) {
+            log.error("启动Mineru转换失败, documentId: {}", document.getId(), e);
+            updateDocumentStatus(document, DocumentStatus.FAILED, "文档转换失败: " + e.getMessage(), 0);
+            document.setMineruErrorMsg(e.getMessage());
+            documentRepository.save(document);
+        }
+    }
+
+    /**
+     * 异步开始Markdown解析(保留用于外部调用,如Mineru回调后)
+     */
+    @Async
+    public void startMarkdownParsingAsync(Document document, String content) {
+        processMarkdownParsing(document, content);
+    }
+
+    /**
+     * 异步开始Mineru转换(保留用于外部调用)
+     */
+    @Async
+    public void startMineruConversionAsync(Document document, ParseDocumentV2RequestVO request) {
+        processMineruConversion(document, request);
+    }
+
+    /**
+     * 解析Markdown内容并保存
+     */
+    @Transactional
+    public void parseMarkdownContent(Document document, String content) {
+        // 切分章节和Block
+        updateDocumentStatus(document, DocumentStatus.CHUNKING, "正在切分文档结构", 30);
+        MarkdownParser.ParseResult parseResult = markdownParser.parse(content);
+
+        // 清理旧数据
+        documentCleanupService.cleanOldDocumentData(document.getId());
+
+        // 保存章节
+        Map<Integer, Long> chapterIdMap = new HashMap<>();
+        for (MarkdownParser.ChapterInfo chapterInfo : parseResult.getChapters()) {
+            Chapter chapter = new Chapter();
+            chapter.setDocumentId(document.getId());
+            chapter.setTitle(chapterInfo.getTitle());
+            chapter.setLevel(chapterInfo.getLevel());
+            chapter.setSortOrder(chapterInfo.getSortOrder());
+            chapter.setParentId(chapterInfo.getParentIndex() != null
+                    ? chapterIdMap.get(chapterInfo.getParentIndex())
+                    : null);
+            chapter = chapterRepository.save(chapter);
+            chapterIdMap.put(chapterInfo.getSortOrder(), chapter.getId());
+        }
+
+        // 提取知识点
+        updateDocumentStatus(document, DocumentStatus.EXTRACTING, "正在提取知识点", 50);
+        List<MarkdownParser.BlockInfo> blocks = parseResult.getBlocks();
+        int totalBlocks = blocks.size();
+
+        for (int i = 0; i < blocks.size(); i++) {
+            MarkdownParser.BlockInfo blockInfo = blocks.get(i);
+
+            Block block = new Block();
+            block.setDocumentId(document.getId());
+            block.setChapterId(
+                    blockInfo.getChapterIndex() != null ? chapterIdMap.get(blockInfo.getChapterIndex()) : null);
+            block.setContent(blockInfo.getContent());
+            block.setSortOrder(i + 1);
+            block.setType(blockInfo.getType());
+            block.setStatus(blockInfo.getType() == BlockType.READ_ONLY ? BlockStatus.COMPLETED : BlockStatus.UNREAD);
+            block = blockRepository.save(block);
+
+            // AI提取知识点
+            if (blockInfo.getType() == BlockType.LEARNABLE) {
+                try {
+                    List<KnowledgePoint> kps = aiService.extractKnowledgePoints(blockInfo.getContent());
+                    for (KnowledgePoint kp : kps) {
+                        kp.setBlockId(block.getId());
+                        kp.setIsFromChat(false);
+                        knowledgePointRepository.save(kp);
+                    }
+                } catch (Exception e) {
+                    log.warn("提取知识点失败,Block ID: {}", block.getId(), e);
+                }
+            }
+
+            int progress = 50 + (int) ((i + 1.0) / totalBlocks * 40);
+            updateDocumentStatus(document, DocumentStatus.EXTRACTING,
+                    String.format("正在处理第 %d/%d 个内容块", i + 1, totalBlocks), progress);
+        }
+
+        // 更新文档状态
+        long learnableBlockCount = blocks.stream()
+                .filter(b -> b.getType() == BlockType.LEARNABLE)
+                .count();
+        document.setTotalBlocks((int) learnableBlockCount);
+        updateDocumentStatus(document, DocumentStatus.INDEXING, "正在索引知识点", 95);
+        documentRepository.save(document);
+
+        // 触发异步索引任务
+        knowledgeIndexerService.indexDocument(document.getId());
+    }
+
+    /**
+     * 判断是否为Mineru处理中的状态
+     */
+    private boolean isMineruProcessingStatus(DocumentStatus status) {
+        return status == DocumentStatus.MINERU_PENDING
+                || status == DocumentStatus.MINERU_RUNNING
+                || status == DocumentStatus.MINERU_CONVERTING;
+    }
+
+    /**
+     * 刷新Mineru任务状态
+     */
+    private void refreshMineruStatus(Document document) {
+        MineruTaskResultResponse result = mineruService.getTaskResult(document.getMineruTaskId());
+
+        if (result.isDone()) {
+            // 解析完成,开始下载结果
+            handleMineruTaskDone(document, result.getData().getFullZipUrl());
+        } else if (result.isFailed()) {
+            // 解析失败
+            updateDocumentStatus(document, DocumentStatus.FAILED,
+                    "文档解析失败: " + result.getData().getErrMsg(), 0);
+            document.setMineruErrorMsg(result.getData().getErrMsg());
+            documentRepository.save(document);
+        } else if (result.isProcessing()) {
+            // 更新进度
+            String state = result.getData().getState();
+            MineruTaskResultResponse.ExtractProgress progress = result.getData().getExtractProgress();
+
+            DocumentStatus newStatus = switch (state) {
+                case "pending" -> DocumentStatus.MINERU_PENDING;
+                case "running" -> DocumentStatus.MINERU_RUNNING;
+                case "converting" -> DocumentStatus.MINERU_CONVERTING;
+                default -> document.getStatus();
+            };
+
+            String message = switch (state) {
+                case "pending" -> "排队等待解析中";
+                case "running" -> progress != null
+                        ? String.format("正在解析中 (%d/%d 页)", progress.getExtractedPages(), progress.getTotalPages())
+                        : "正在解析中";
+                case "converting" -> "正在转换格式";
+                default -> document.getStatusMessage();
+            };
+
+            int progressPercent = 10;
+            if (progress != null && progress.getTotalPages() != null && progress.getTotalPages() > 0) {
+                progressPercent = 10 + (int) ((progress.getExtractedPages() * 30.0) / progress.getTotalPages());
+            }
+
+            updateDocumentStatus(document, newStatus, message, progressPercent);
+
+            // 保存进度信息
+            if (progress != null) {
+                try {
+                    document.setMineruProgress(objectMapper.writeValueAsString(progress));
+                } catch (JsonProcessingException e) {
+                    log.warn("序列化Mineru进度失败", e);
+                }
+            }
+            documentRepository.save(document);
+        }
+    }
+
+    /**
+     * 处理Mineru任务完成
+     */
+    private void handleMineruTaskDone(Document document, String zipUrl) {
+        try {
+            updateDocumentStatus(document, DocumentStatus.DOWNLOADING_RESULT, "正在下载解析结果", 45);
+            documentRepository.save(document);
+
+            // 下载并提取Markdown
+            updateDocumentStatus(document, DocumentStatus.EXTRACTING_RESULT, "正在提取Markdown文件", 48);
+            String markdownContent = mineruService.downloadAndExtractMarkdown(zipUrl);
+
+            // 上传Markdown文件到OSS
+            if (markdownContent != null && !markdownContent.isBlank()) {
+                try {
+                    String originalName = document.getOriginalFilename();
+                    String fileName = "converted_" + System.currentTimeMillis() + ".md";
+                    if (originalName != null && !originalName.isBlank()) {
+                        int idx = originalName.lastIndexOf('.');
+                        if (idx > 0) {
+                            fileName = originalName.substring(0, idx) + "_converted.md";
+                        } else {
+                            fileName = originalName + "_converted.md";
+                        }
+                    }
+
+                    String ossPath = "documents/" + document.getUserId() + "/converted/" + fileName;
+                    ossUtil.upload(ossPath,
+                            new java.io.ByteArrayInputStream(markdownContent.getBytes(StandardCharsets.UTF_8)));
+
+                    document.setConvertedMdPath(ossPath);
+                    documentRepository.save(document);
+                    log.info("Converted markdown uploaded to OSS: {}", ossPath);
+                } catch (Exception e) {
+                    log.warn("Failed to upload converted markdown to OSS", e);
+                    // 不中断流程,继续解析
+                }
+            }
+
+            // 继续解析Markdown
+            startMarkdownParsingAsync(document, markdownContent);
+
+        } catch (Exception e) {
+            log.error("处理Mineru结果失败, documentId: {}", document.getId(), e);
+            updateDocumentStatus(document, DocumentStatus.FAILED, "处理解析结果失败: " + e.getMessage(), 0);
+            document.setMineruErrorMsg(e.getMessage());
+            documentRepository.save(document);
+        }
+    }
+
+    @Override
+    public void handleMineruCallback(MineruCallbackRequest callback) {
+        try {
+            // 解析回调内容
+            MineruTaskResultResponse.TaskResultData taskData = objectMapper.readValue(
+                    callback.getContent(), MineruTaskResultResponse.TaskResultData.class);
+
+            String dataId = taskData.getDataId(); // 这是我们存储的documentId
+            if (dataId == null || dataId.isBlank()) {
+                log.warn("Mineru回调缺少data_id");
+                return;
+            }
+
+            Document document = documentRepository.findById(dataId).orElse(null);
+            if (document == null) {
+                log.warn("Mineru回调文档不存在: {}", dataId);
+                return;
+            }
+
+            log.info("Received Mineru callback for document: {}, state: {}", dataId, taskData.getState());
+
+            if ("done".equals(taskData.getState())) {
+                handleMineruTaskDone(document, taskData.getFullZipUrl());
+            } else if ("failed".equals(taskData.getState())) {
+                updateDocumentStatus(document, DocumentStatus.FAILED,
+                        "文档解析失败: " + taskData.getErrMsg(), 0);
+                document.setMineruErrorMsg(taskData.getErrMsg());
+                documentRepository.save(document);
+            }
+
+        } catch (Exception e) {
+            log.error("处理Mineru回调失败", e);
+        }
+    }
+
+    /**
+     * 构建解析状态VO
+     */
+    private DocumentParseStatusVO buildParseStatusVO(Document document) {
+        DocumentParseStatusVO vo = new DocumentParseStatusVO();
+        vo.setDocumentId(document.getId());
+        vo.setStatus(document.getStatus());
+        vo.setStatusMessage(document.getStatusMessage());
+        vo.setProgress(document.getParseProgress());
+
+        // 解析Mineru进度信息
+        if (document.getMineruProgress() != null) {
+            try {
+                MineruTaskResultResponse.ExtractProgress mineruProgress = objectMapper.readValue(
+                        document.getMineruProgress(), MineruTaskResultResponse.ExtractProgress.class);
+
+                DocumentParseStatusVO.MineruProgressInfo progressInfo = new DocumentParseStatusVO.MineruProgressInfo();
+                progressInfo.setExtractedPages(mineruProgress.getExtractedPages());
+                progressInfo.setTotalPages(mineruProgress.getTotalPages());
+                progressInfo.setStartTime(mineruProgress.getStartTime());
+                vo.setMineruProgress(progressInfo);
+            } catch (Exception e) {
+                log.warn("解析Mineru进度信息失败", e);
+            }
+        }
+
+        // 判断是否完成
+        DocumentStatus status = document.getStatus();
+        vo.setFinished(status == DocumentStatus.READY
+                || status == DocumentStatus.INDEXING
+                || status == DocumentStatus.FAILED);
+        vo.setSuccess(status == DocumentStatus.READY || status == DocumentStatus.INDEXING);
+
+        if (status == DocumentStatus.FAILED) {
+            vo.setErrorMessage(document.getMineruErrorMsg() != null
+                    ? document.getMineruErrorMsg()
+                    : document.getStatusMessage());
+        }
+
+        return vo;
+    }
+
+    /**
+     * 更新文档状态
+     */
+    private void updateDocumentStatus(Document document, DocumentStatus status, String message, int progress) {
+        document.setStatus(status);
+        document.setStatusMessage(message);
+        document.setParseProgress(progress);
+        documentRepository.save(document);
+        log.debug("Document {} status updated to: {} - {}", document.getId(), status, message);
+    }
+
+    @Override
+    public List<DocumentListVO> getDocumentList(Long userId) {
+        List<Document> documents = documentRepository.findByUserIdOrderByUpdatedAtDesc(userId);
+        return documents.stream().map(doc -> {
+            DocumentListVO vo = new DocumentListVO();
+            vo.setId(doc.getId());
+            vo.setTitle(doc.getTitle());
+            vo.setTotalBlocks(doc.getTotalBlocks());
+            vo.setCompletedBlocks(doc.getCompletedBlocks());
+            vo.setProgressPercent(
+                    doc.getTotalBlocks() > 0 ? (doc.getCompletedBlocks() * 100.0 / doc.getTotalBlocks()) : 0);
+            vo.setStatus(doc.getStatus());
+            vo.setLastReviewedAt(doc.getLastReviewedAt());
+            vo.setCreatedAt(doc.getCreatedAt());
+            return vo;
+        }).collect(Collectors.toList());
+    }
+
+    @Override
+    public DocumentDetailVO getDocumentDetail(String documentId, Long userId) {
+        Document document = documentRepository.findById(documentId)
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此文档");
+        }
+
+        DocumentDetailVO vo = new DocumentDetailVO();
+        vo.setId(document.getId());
+        vo.setTitle(document.getTitle());
+        // 使用 ossPath 生成签名URL返回给前端,有效期1小时
+        if (document.getOssPath() != null) {
+            vo.setFileUrl(ossUtil.generatePresignedUrl(document.getOssPath(), 3600));
+        }
+        vo.setTotalBlocks(document.getTotalBlocks());
+        vo.setCompletedBlocks(document.getCompletedBlocks());
+        vo.setStatus(document.getStatus().name());
+
+        // 构建章节目录树
+        List<Chapter> chapters = chapterRepository.findByDocumentIdOrderBySortOrder(documentId);
+        List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+        vo.setChapters(buildChapterTree(chapters, blocks));
+
+        return vo;
+    }
+
+    private List<ChapterTreeVO> buildChapterTree(List<Chapter> chapters, List<Block> blocks) {
+        Map<Long, List<Block>> blocksByChapter = blocks.stream()
+                .filter(b -> b.getChapterId() != null)
+                .collect(Collectors.groupingBy(Block::getChapterId));
+
+        Map<Long, ChapterTreeVO> chapterMap = new LinkedHashMap<>();
+        List<ChapterTreeVO> rootChapters = new ArrayList<>();
+
+        for (Chapter chapter : chapters) {
+            ChapterTreeVO treeVO = new ChapterTreeVO();
+            treeVO.setId(chapter.getId());
+            treeVO.setTitle(chapter.getTitle());
+            treeVO.setLevel(chapter.getLevel());
+            treeVO.setChildren(new ArrayList<>());
+
+            // 添加该章节下的blocks
+            List<Block> chapterBlocks = blocksByChapter.getOrDefault(chapter.getId(), new ArrayList<>());
+            treeVO.setBlocks(chapterBlocks.stream().map(b -> {
+                BlockBriefVO briefVO = new BlockBriefVO();
+                briefVO.setId(b.getId());
+                briefVO.setSortOrder(b.getSortOrder());
+                briefVO.setStatus(b.getStatus());
+                briefVO.setType(b.getType());
+                briefVO.setContent(b.getContent());
+                return briefVO;
+            }).collect(Collectors.toList()));
+
+            chapterMap.put(chapter.getId(), treeVO);
+
+            if (chapter.getParentId() == null) {
+                rootChapters.add(treeVO);
+            } else {
+                ChapterTreeVO parent = chapterMap.get(chapter.getParentId());
+                if (parent != null) {
+                    parent.getChildren().add(treeVO);
+                }
+            }
+        }
+
+        return rootChapters;
+    }
+
+    @Override
+    @Transactional
+    public void deleteDocument(String documentId, Long userId) {
+        Document document = documentRepository.findById(documentId)
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权删除此文档");
+        }
+
+        // 删除关联数据:先删除对话及其消息
+        List<Conversation> conversations = conversationRepository
+                .findByUserIdAndDocumentIdOrderByUpdatedAtDesc(userId, documentId);
+        for (Conversation conversation : conversations) {
+            chatMessageRepository.deleteByConversationId(conversation.getId());
+        }
+        conversations.forEach(conversationRepository::delete);
+
+        List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+        for (Block block : blocks) {
+            knowledgePointRepository.deleteByBlockId(block.getId());
+        }
+
+        blockRepository.deleteByDocumentId(documentId);
+        chapterRepository.deleteByDocumentId(documentId);
+        documentRepository.delete(document);
+    }
+}

+ 175 - 0
src/main/java/com/smartreview/service/impl/KnowledgeIndexerServiceImpl.java

@@ -0,0 +1,175 @@
+package com.smartreview.service.impl;
+
+import com.smartreview.enums.DocumentStatus;
+import com.smartreview.po.Block;
+import com.smartreview.po.Document;
+import com.smartreview.po.KnowledgePoint;
+import com.smartreview.repository.BlockRepository;
+import com.smartreview.repository.DocumentRepository;
+import com.smartreview.repository.KnowledgePointRepository;
+import com.smartreview.service.KnowledgeIndexerService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class KnowledgeIndexerServiceImpl implements KnowledgeIndexerService {
+
+    private final DocumentRepository documentRepository;
+    private final BlockRepository blockRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+
+    @Async
+    @Override
+    @Transactional
+    public void indexDocument(String documentId) {
+        log.info("开始索引文档知识点: {}", documentId);
+        try {
+            Document document = documentRepository.findById(documentId).orElse(null);
+            if (document == null) {
+                log.warn("文档不存在,停止索引: {}", documentId);
+                return;
+            }
+
+            // 获取所有Block
+            List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+
+            for (Block block : blocks) {
+                if (block.getContent() == null || block.getContent().isBlank()) {
+                    continue;
+                }
+
+                // 获取Block下的知识点
+                List<KnowledgePoint> kps = knowledgePointRepository.findByBlockId(block.getId());
+
+                for (KnowledgePoint kp : kps) {
+                    String sourceText = kp.getSourceText();
+                    if (sourceText != null && !sourceText.isBlank()) {
+                        // 使用智能匹配定位知识点(忽略空白字符差异)
+                        int[] positions = findSmartMatch(block.getContent(), sourceText);
+
+                        if (positions != null) {
+                            int start = positions[0];
+                            int end = positions[1];
+
+                            kp.setSourceStart(start);
+                            kp.setSourceEnd(end);
+                            knowledgePointRepository.save(kp);
+                            log.debug("知识点定位成功: kpId={}, start={}, end={}", kp.getId(), start, end);
+                        } else {
+                            log.debug("知识点原文未在Block中找到: kpId={}, sourceText={}", kp.getId(), sourceText);
+                        }
+                    }
+                }
+            }
+
+            // 更新文档状态为 READY
+            document.setStatus(DocumentStatus.READY);
+            document.setStatusMessage("文档解析完成");
+            document.setParseProgress(100);
+            documentRepository.save(document);
+            log.info("文档索引完成,状态更新为READY: {}", documentId);
+
+        } catch (Exception e) {
+            log.error("文档索引失败: {}", documentId, e);
+            // 可以在这里设置文档状态为 ERROR 或者保留在 INDEXING 供重试
+        }
+    }
+
+    @Async
+    @Override
+    @Transactional
+    public void indexBlock(Long blockId) {
+        log.info("开始索引Block知识点: {}", blockId);
+        try {
+            Block block = blockRepository.findById(blockId).orElse(null);
+            if (block == null) {
+                log.warn("Block不存在,停止索引: {}", blockId);
+                return;
+            }
+
+            if (block.getContent() == null || block.getContent().isBlank()) {
+                log.debug("Block内容为空,跳过索引: {}", blockId);
+                return;
+            }
+
+            // 获取Block下的知识点
+            List<KnowledgePoint> kps = knowledgePointRepository.findByBlockId(blockId);
+
+            for (KnowledgePoint kp : kps) {
+                String sourceText = kp.getSourceText();
+                if (sourceText != null && !sourceText.isBlank()) {
+                    int[] positions = findSmartMatch(block.getContent(), sourceText);
+
+                    if (positions != null) {
+                        kp.setSourceStart(positions[0]);
+                        kp.setSourceEnd(positions[1]);
+                        knowledgePointRepository.save(kp);
+                        log.debug("知识点定位成功: kpId={}, start={}, end={}", kp.getId(), positions[0], positions[1]);
+                    } else {
+                        log.debug("知识点原文未在Block中找到: kpId={}", kp.getId());
+                    }
+                }
+            }
+
+            log.info("Block索引完成: {}", blockId);
+
+        } catch (Exception e) {
+            log.error("Block索引失败: {}", blockId, e);
+        }
+    }
+
+    /**
+     * 在内容中查找源文本的真实位置(忽略空白字符差异)
+     * 
+     * @param content    要搜索的文本内容
+     * @param sourceText 要查找的知识点原文
+     * @return int[]{start, end} 最佳匹配位置的起始和结束索引,如果未找到则返回null
+     */
+    private int[] findSmartMatch(String content, String sourceText) {
+        // 移除sourceText中的所有空白字符
+        String cleanSource = sourceText.replaceAll("\\s+", "");
+        if (cleanSource.isEmpty())
+            return null;
+
+        // 构建content的非空白字符映射
+        // contentChars: 存储非空白字符
+        // contentIndices: 存储这些字符在原content中的索引
+        java.util.List<Character> contentChars = new java.util.ArrayList<>();
+        java.util.List<Integer> contentIndices = new java.util.ArrayList<>();
+
+        for (int i = 0; i < content.length(); i++) {
+            char c = content.charAt(i);
+            if (!Character.isWhitespace(c)) {
+                contentChars.add(c);
+                contentIndices.add(i);
+            }
+        }
+
+        // 将content非空白字符转为字符串进行查找
+        StringBuilder sb = new StringBuilder(contentChars.size());
+        for (Character c : contentChars)
+            sb.append(c);
+        String cleanContent = sb.toString();
+
+        int matchIndex = cleanContent.indexOf(cleanSource);
+        if (matchIndex != -1) {
+            // 映射回原始索引
+            int startOriginalIndex = contentIndices.get(matchIndex);
+            // 结束索引是匹配的最后一个字符的索引 + 1
+            // 注意:endMatchIndex是cleanContent中的索引,对应contentIndices中的位置
+            int endMatchIndex = matchIndex + cleanSource.length() - 1;
+            int endOriginalIndex = contentIndices.get(endMatchIndex) + 1;
+
+            return new int[] { startOriginalIndex, endOriginalIndex };
+        }
+
+        return null;
+    }
+}

+ 334 - 0
src/main/java/com/smartreview/service/impl/MineruServiceImpl.java

@@ -0,0 +1,334 @@
+package com.smartreview.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.service.MineruService;
+import com.smartreview.vo.mineru.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * Mineru文档解析服务实现
+ */
+@Slf4j
+@Service
+public class MineruServiceImpl implements MineruService {
+
+    private static final String MINERU_API_BASE = "https://mineru.net/api/v4";
+    private static final String CREATE_TASK_URL = MINERU_API_BASE + "/extract/task";
+    private static final String BATCH_UPLOAD_URL = MINERU_API_BASE + "/file-urls/batch";
+    private static final String TASK_RESULT_URL = MINERU_API_BASE + "/extract/task/";
+
+    @Value("${mineru.api-token:}")
+    private String apiToken;
+
+    @Value("${mineru.callback-url:}")
+    private String callbackUrl;
+
+    @Value("${mineru.callback-seed:}")
+    private String callbackSeed;
+
+    @Value("${mineru.user-uid:}")
+    private String userUid;
+
+    @Value("${mineru.model-version:vlm}")
+    private String modelVersion;
+
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+
+    public MineruServiceImpl() {
+        this.objectMapper = new ObjectMapper();
+        this.objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL);
+
+        this.restTemplate = new RestTemplate();
+        // 配置 RestTemplate 使用自定义的 ObjectMapper
+        org.springframework.http.converter.json.MappingJackson2HttpMessageConverter converter = new org.springframework.http.converter.json.MappingJackson2HttpMessageConverter();
+        converter.setObjectMapper(this.objectMapper);
+        this.restTemplate.getMessageConverters().removeIf(
+                c -> c instanceof org.springframework.http.converter.json.MappingJackson2HttpMessageConverter);
+        this.restTemplate.getMessageConverters().add(converter);
+    }
+
+    @Override
+    public MineruTaskResponse createTaskByUrl(String fileUrl, String dataId, Boolean enableOcr,
+            Boolean enableFormula, Boolean enableTable, String pageRanges) {
+        try {
+            MineruTaskRequest request = new MineruTaskRequest();
+            request.setUrl(fileUrl);
+            request.setDataId(dataId);
+            request.setIsOcr(enableOcr);
+            request.setEnableFormula(enableFormula != null ? enableFormula : true);
+            request.setEnableTable(enableTable != null ? enableTable : true);
+            request.setPageRanges(pageRanges);
+            request.setModelVersion(modelVersion);
+
+            // 设置回调
+            if (callbackUrl != null && !callbackUrl.isBlank()) {
+                request.setCallback(callbackUrl);
+                request.setSeed(callbackSeed);
+            }
+
+            // 手动序列化为 JSON 字符串,确保格式正确
+            String jsonBody = objectMapper.writeValueAsString(request);
+            log.info("Creating Mineru task for URL: {}", fileUrl);
+            log.debug("Mineru request body: {}", jsonBody);
+
+            HttpHeaders headers = createHeaders();
+            // 直接发送 JSON 字符串而不是对象
+            HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);
+
+            ResponseEntity<MineruTaskResponse> response = restTemplate.exchange(
+                    CREATE_TASK_URL,
+                    HttpMethod.POST,
+                    entity,
+                    MineruTaskResponse.class);
+
+            MineruTaskResponse result = response.getBody();
+            if (result == null || !result.isSuccess()) {
+                String errorMsg = result != null ? result.getMsg() : "Unknown error";
+                log.error("Failed to create Mineru task: {}", errorMsg);
+                throw SmartReviewException.error("创建Mineru解析任务失败: " + errorMsg);
+            }
+
+            log.info("Mineru task created successfully, taskId: {}", result.getData().getTaskId());
+            return result;
+        } catch (Exception e) {
+            log.error("Error creating Mineru task", e);
+            if (e instanceof SmartReviewException) {
+                throw (SmartReviewException) e;
+            }
+            throw SmartReviewException.error("创建Mineru解析任务失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public MineruBatchUploadResponse applyUploadUrl(String fileName, String dataId,
+            Boolean enableFormula, Boolean enableTable) {
+        try {
+            MineruBatchUploadRequest request = new MineruBatchUploadRequest();
+
+            MineruBatchUploadRequest.FileInfo fileInfo = new MineruBatchUploadRequest.FileInfo();
+            fileInfo.setName(fileName);
+            fileInfo.setDataId(dataId);
+
+            List<MineruBatchUploadRequest.FileInfo> files = new ArrayList<>();
+            files.add(fileInfo);
+            request.setFiles(files);
+
+            request.setEnableFormula(enableFormula != null ? enableFormula : true);
+            request.setEnableTable(enableTable != null ? enableTable : true);
+            request.setModelVersion(modelVersion);
+
+            // 设置回调
+            if (callbackUrl != null && !callbackUrl.isBlank()) {
+                request.setCallback(callbackUrl);
+                request.setSeed(callbackSeed);
+            }
+
+            HttpHeaders headers = createHeaders();
+            HttpEntity<MineruBatchUploadRequest> entity = new HttpEntity<>(request, headers);
+
+            log.info("Applying Mineru upload URL for file: {}", fileName);
+            ResponseEntity<MineruBatchUploadResponse> response = restTemplate.exchange(
+                    BATCH_UPLOAD_URL,
+                    HttpMethod.POST,
+                    entity,
+                    MineruBatchUploadResponse.class);
+
+            MineruBatchUploadResponse result = response.getBody();
+            if (result == null || !result.isSuccess()) {
+                String errorMsg = result != null ? result.getMsg() : "Unknown error";
+                log.error("Failed to apply Mineru upload URL: {}", errorMsg);
+                throw SmartReviewException.error("申请Mineru上传链接失败: " + errorMsg);
+            }
+
+            log.info("Mineru upload URL applied, batchId: {}", result.getData().getBatchId());
+            return result;
+        } catch (Exception e) {
+            log.error("Error applying Mineru upload URL", e);
+            if (e instanceof SmartReviewException)
+                throw e;
+            throw SmartReviewException.error("申请Mineru上传链接失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public boolean uploadFile(String uploadUrl, InputStream inputStream) {
+        HttpURLConnection connection = null;
+        try {
+            log.info("Uploading file to Mineru: {}", uploadUrl);
+
+            URL url = new URL(uploadUrl);
+            connection = (HttpURLConnection) url.openConnection();
+            connection.setRequestMethod("PUT");
+            connection.setDoOutput(true);
+            connection.setConnectTimeout(60000);
+            connection.setReadTimeout(300000);
+
+            // 上传文件
+            try (OutputStream os = connection.getOutputStream()) {
+                byte[] buffer = new byte[8192];
+                int bytesRead;
+                while ((bytesRead = inputStream.read(buffer)) != -1) {
+                    os.write(buffer, 0, bytesRead);
+                }
+            }
+
+            int responseCode = connection.getResponseCode();
+            if (responseCode == 200) {
+                log.info("File uploaded to Mineru successfully");
+                return true;
+            } else {
+                log.error("Failed to upload file to Mineru, response code: {}", responseCode);
+                return false;
+            }
+        } catch (Exception e) {
+            log.error("Error uploading file to Mineru", e);
+            return false;
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    @Override
+    public MineruTaskResultResponse getTaskResult(String taskId) {
+        try {
+            HttpHeaders headers = createHeaders();
+            HttpEntity<?> entity = new HttpEntity<>(headers);
+
+            log.debug("Querying Mineru task result for taskId: {}", taskId);
+            ResponseEntity<MineruTaskResultResponse> response = restTemplate.exchange(
+                    TASK_RESULT_URL + taskId,
+                    HttpMethod.GET,
+                    entity,
+                    MineruTaskResultResponse.class);
+
+            MineruTaskResultResponse result = response.getBody();
+            if (result == null || !result.isSuccess()) {
+                String errorMsg = result != null ? result.getMsg() : "Unknown error";
+                log.error("Failed to get Mineru task result: {}", errorMsg);
+                throw SmartReviewException.error("查询Mineru任务结果失败: " + errorMsg);
+            }
+
+            log.debug("Mineru task status: {}", result.getData().getState());
+            return result;
+        } catch (Exception e) {
+            log.error("Error getting Mineru task result", e);
+            if (e instanceof SmartReviewException)
+                throw e;
+            throw SmartReviewException.error("查询Mineru任务结果失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public String downloadAndExtractMarkdown(String zipUrl) {
+        try {
+            log.info("Downloading and extracting Markdown from: {}", zipUrl);
+
+            URL url = new URL(zipUrl);
+            try (ZipInputStream zis = new ZipInputStream(url.openStream(), StandardCharsets.UTF_8)) {
+                ZipEntry entry;
+                while ((entry = zis.getNextEntry()) != null) {
+                    String entryName = entry.getName();
+                    log.debug("Processing zip entry: {}", entryName);
+
+                    // 查找.md文件(通常在根目录或auto目录下)
+                    if (entryName.endsWith(".md") && !entry.isDirectory()) {
+                        // 优先使用full模式的md文件,或者auto目录下的md文件
+                        if (entryName.contains("full") || entryName.contains("auto") ||
+                                !entryName.contains("/") || entryName.split("/").length <= 2) {
+
+                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                            byte[] buffer = new byte[8192];
+                            int len;
+                            while ((len = zis.read(buffer)) > 0) {
+                                baos.write(buffer, 0, len);
+                            }
+                            String content = baos.toString(StandardCharsets.UTF_8);
+                            log.info("Successfully extracted Markdown file: {}, length: {}", entryName,
+                                    content.length());
+                            return content;
+                        }
+                    }
+                }
+            }
+
+            // 如果没有找到合适的md文件,再次遍历寻找任意md文件
+            try (ZipInputStream zis = new ZipInputStream(url.openStream(), StandardCharsets.UTF_8)) {
+                ZipEntry entry;
+                while ((entry = zis.getNextEntry()) != null) {
+                    if (entry.getName().endsWith(".md") && !entry.isDirectory()) {
+                        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                        byte[] buffer = new byte[8192];
+                        int len;
+                        while ((len = zis.read(buffer)) > 0) {
+                            baos.write(buffer, 0, len);
+                        }
+                        String content = baos.toString(StandardCharsets.UTF_8);
+                        log.info("Extracted fallback Markdown file: {}, length: {}", entry.getName(), content.length());
+                        return content;
+                    }
+                }
+            }
+
+            throw SmartReviewException.error("解析结果中未找到Markdown文件");
+        } catch (SmartReviewException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("Error downloading and extracting Markdown", e);
+            throw SmartReviewException.error("下载并提取Markdown失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public boolean verifyCallback(String checksum, String content, String seed) {
+        try {
+            // 计算签名:SHA256(uid + seed + content)
+            String toHash = userUid + seed + content;
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] hashBytes = digest.digest(toHash.getBytes(StandardCharsets.UTF_8));
+
+            StringBuilder hexString = new StringBuilder();
+            for (byte b : hashBytes) {
+                String hex = Integer.toHexString(0xff & b);
+                if (hex.length() == 1)
+                    hexString.append('0');
+                hexString.append(hex);
+            }
+
+            String calculatedChecksum = hexString.toString();
+            boolean valid = calculatedChecksum.equals(checksum);
+
+            if (!valid) {
+                log.warn("Mineru callback checksum verification failed");
+            }
+            return valid;
+        } catch (Exception e) {
+            log.error("Error verifying Mineru callback", e);
+            return false;
+        }
+    }
+
+    private HttpHeaders createHeaders() {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("Authorization", "Bearer " + apiToken);
+        return headers;
+    }
+}

+ 542 - 0
src/main/java/com/smartreview/service/impl/QuestionServiceImpl.java

@@ -0,0 +1,542 @@
+package com.smartreview.service.impl;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.Gson;
+import com.smartreview.enums.*;
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.*;
+import com.smartreview.repository.*;
+import com.smartreview.service.AIService;
+import com.smartreview.service.QuestionService;
+import com.smartreview.vo.question.QuestionGenerationResponseVO;
+import com.smartreview.vo.question.QuestionVO;
+import com.smartreview.vo.question.SubmitAnswerRequestVO;
+import com.smartreview.vo.question.SubmitAnswerResponseVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationContext;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 题目服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class QuestionServiceImpl implements QuestionService {
+
+    private final QuestionRepository questionRepository;
+    private final QuestionGenerationTaskRepository taskRepository;
+    private final BlockRepository blockRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+    private final UserAnswerRepository userAnswerRepository;
+    private final DocumentRepository documentRepository;
+    private final AIService aiService;
+    private final Gson gson = new Gson();
+    private final ApplicationContext applicationContext;
+
+    private QuestionServiceImpl getSelf() {
+        return applicationContext.getBean(QuestionServiceImpl.class);
+    }
+
+    @Override
+    public QuestionGenerationResponseVO getOrGenerateQuestions(Long blockId, Long userId, boolean forceNew) {
+        Block block = blockRepository.findById(blockId)
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+
+        // 验证权限
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此内容");
+        }
+        if (block.getType().equals(BlockType.READ_ONLY)) {
+            throw SmartReviewException.badRequest("只读内容块无法生成题目");
+        }
+        // 检查是否有正在进行的任务
+        var generatingTask = taskRepository.findFirstByBlockIdAndUserIdAndStatusOrderByCreatedAtDesc(
+                blockId, userId, QuestionGenerationStatus.GENERATING);
+
+        if (generatingTask.isPresent()) {
+            return QuestionGenerationResponseVO.generating(generatingTask.get().getId());
+        }
+
+        // 检查是否有已完成的任务和题目
+        var latestTask = taskRepository.findFirstByBlockIdAndUserIdOrderByCreatedAtDesc(blockId, userId);
+
+        if (latestTask.isPresent() && latestTask.get().getStatus() == QuestionGenerationStatus.COMPLETED && !forceNew) {
+            // 返回现有题目
+            List<Question> questions = questionRepository.findByGeneratedBlockIdAndStatus(blockId,
+                    QuestionStatus.ACTIVE);
+            if (!questions.isEmpty()) {
+                List<QuestionVO> questionVOs = questions.stream().map(this::convertToVO).collect(Collectors.toList());
+                return QuestionGenerationResponseVO.completed(latestTask.get().getId(), questionVOs);
+            }
+        }
+
+        // 如果强制生成新题目,标记旧题目为废弃
+        if (forceNew) {
+            // 使用代理调用,确保事务生效
+            getSelf().deprecateOldQuestions(blockId);
+        }
+
+        // 创建新的生成任务
+        QuestionGenerationTask task = new QuestionGenerationTask();
+        task.setBlockId(blockId);
+        task.setUserId(userId);
+        task.setStatus(QuestionGenerationStatus.GENERATING);
+        task = taskRepository.save(task);
+
+        // 异步生成题目
+        Long taskId = task.getId();
+        // 使用代理调用,确保异步生效
+        getSelf().generateQuestionsAsync(taskId, blockId, userId);
+
+        return QuestionGenerationResponseVO.generating(taskId);
+    }
+
+    @Async
+    @Transactional
+    public void generateQuestionsAsync(Long taskId, Long blockId, Long userId) {
+        try {
+            log.info("开始异步生成题目: taskId={}, blockId={}", taskId, blockId);
+            List<QuestionVO> questions = generateQuestions(blockId, userId);
+
+            // 更新任务状态为完成
+            QuestionGenerationTask task = taskRepository.findById(taskId).orElse(null);
+            if (task != null) {
+                task.setStatus(QuestionGenerationStatus.COMPLETED);
+                task.setGeneratedCount(questions.size());
+                task.setCompletedAt(LocalDateTime.now());
+                taskRepository.save(task);
+            }
+
+            log.info("题目生成完成: taskId={}, count={}", taskId, questions.size());
+        } catch (Exception e) {
+            log.error("题目生成失败: taskId={}, blockId={}", taskId, blockId, e);
+
+            // 更新任务状态为失败
+            QuestionGenerationTask task = taskRepository.findById(taskId).orElse(null);
+            if (task != null) {
+                task.setStatus(QuestionGenerationStatus.FAILED);
+                task.setErrorMessage(e.getMessage());
+                task.setCompletedAt(LocalDateTime.now());
+                taskRepository.save(task);
+            }
+        }
+    }
+
+    @Transactional
+    public void deprecateOldQuestions(Long blockId) {
+        // 恢复复习题的复习次数
+        List<Question> oldQuestions = questionRepository.findByGeneratedBlockIdAndStatus(blockId,
+                QuestionStatus.ACTIVE);
+        for (Question q : oldQuestions) {
+            if (q.getQuestionSource() == QuestionSource.REVIEW && q.getKnowledgePointId() != null) {
+                knowledgePointRepository.findById(q.getKnowledgePointId()).ifPresent(kp -> {
+                    kp.setReviewTimesNeeded(
+                            Math.min(2, (kp.getReviewTimesNeeded() == null ? 0 : kp.getReviewTimesNeeded()) + 1));
+                    knowledgePointRepository.save(kp);
+                });
+            }
+        }
+
+        questionRepository.updateStatusByGeneratedBlockId(blockId, QuestionStatus.DEPRECATED);
+        log.info("已将Block {} 的旧题目标记为废弃", blockId);
+    }
+
+    @Override
+    public List<QuestionVO> generateQuestions(Long blockId, Long userId) {
+        Block block = blockRepository.findById(blockId)
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+        // 验证权限
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+
+        List<QuestionVO> questions = new ArrayList<>();
+
+        // 第一阶段:为当前Block的所有知识点批量生成题目
+        List<KnowledgePoint> currentBlockKps = knowledgePointRepository.findByBlockId(blockId);
+        log.info("为Block {} 的 {} 个知识点批量生成题目", blockId, currentBlockKps.size());
+
+        if (!currentBlockKps.isEmpty()) {
+            try {
+                String questionsJson = aiService.generateQuestionsForKnowledgePoints(block.getContent(),
+                        currentBlockKps);
+                // 解析JSON数组
+                JsonArray jsonArray = gson.fromJson(questionsJson, JsonArray.class);
+
+                for (int i = 0; i < jsonArray.size() && i < currentBlockKps.size(); i++) {
+                    try {
+                        String singleQuestionJson = jsonArray.get(i).toString();
+                        Question question = parseQuestionFromJsonWithRetry(singleQuestionJson, blockId,
+                                currentBlockKps.get(i).getId(), QuestionSource.CURRENT_BLOCK, blockId,
+                                block.getContent());
+                        question = questionRepository.save(question);
+                        questions.add(convertToVO(question));
+                    } catch (Exception e) {
+                        log.error("解析第 {} 个题目失败", i, e);
+                    }
+                }
+            } catch (Exception e) {
+                log.error("批量生成题目失败,降级为单个生成", e);
+                // 降级:单个生成
+                for (KnowledgePoint kp : currentBlockKps) {
+                    try {
+                        String questionJson = aiService.generateQuestionForKnowledgePoint(block.getContent(), kp, null);
+                        Question question = parseQuestionFromJsonWithRetry(questionJson, blockId, kp.getId(),
+                                QuestionSource.CURRENT_BLOCK, blockId, block.getContent());
+                        question = questionRepository.save(question);
+                        questions.add(convertToVO(question));
+                    } catch (Exception ex) {
+                        log.error("为知识点 {} 生成题目失败", kp.getId(), ex);
+                    }
+                }
+            }
+        }
+
+        // 第二阶段:生成复习题 - 找上次复习时间离现在最长的最多3个知识点
+        List<KnowledgePoint> reviewKps = knowledgePointRepository
+                .findByBlockId_DocumentId(document.getId()).stream()
+                .filter(kp -> kp.getReviewTimesNeeded() != null && kp.getReviewTimesNeeded() > 0)
+                .filter(kp -> !kp.getBlockId().equals(blockId)) // 排除当前Block
+                .sorted(Comparator.comparing(
+                        kp -> kp.getLastReviewedAt() != null ? kp.getLastReviewedAt() : LocalDateTime.MIN))
+                .limit(3)
+                .collect(Collectors.toList());
+
+        log.info("找到 {} 个需要复习的知识点", reviewKps.size());
+
+        for (KnowledgePoint reviewKp : reviewKps) {
+            try {
+                // 获取该知识点所在的Block信息
+                Block reviewBlock = blockRepository.findById(reviewKp.getBlockId()).orElse(null);
+                if (reviewBlock == null) {
+                    continue;
+                }
+
+                // 更新复习时间和次数
+                reviewKp.setLastReviewedAt(LocalDateTime.now());
+                if (reviewKp.getReviewTimesNeeded() != null && reviewKp.getReviewTimesNeeded() > 0) {
+                    reviewKp.setReviewTimesNeeded(reviewKp.getReviewTimesNeeded() - 1);
+                }
+                knowledgePointRepository.save(reviewKp);
+
+                String questionJson = aiService.generateQuestionForKnowledgePoint(reviewBlock.getContent(), reviewKp, null);
+                Question question = parseQuestionFromJsonWithRetry(questionJson, reviewKp.getBlockId(),
+                        reviewKp.getId(), QuestionSource.REVIEW, blockId, reviewBlock.getContent());
+                question = questionRepository.save(question);
+                questions.add(convertToVO(question));
+            } catch (Exception e) {
+                log.error("为复习知识点 {} 生成题目失败", reviewKp.getId(), e);
+            }
+        }
+
+        log.info("共生成 {} 道题目(当前Block: {}, 复习题: {})",
+                questions.size(), currentBlockKps.size(), reviewKps.size());
+
+        return questions;
+    }
+
+    /**
+     * 从JSON解析题目,失败时根据知识点重试一次
+     */
+    private Question parseQuestionFromJsonWithRetry(String json, Long blockId, Long knowledgePointId,
+                                                    QuestionSource source, Long generatedBlockId, String blockContent) {
+        Question question = parseQuestionFromJson(json, blockId, knowledgePointId, source, generatedBlockId);
+
+        // 如果第一次解析失败且返回了默认题目,尝试重新生成
+        if (question.getQuestionText().equals("请总结这一部分的主要内容。") && knowledgePointId != null) {
+            log.warn("首次解析失败,尝试根据知识点重新生成题目: knowledgePointId={}", knowledgePointId);
+            try {
+                KnowledgePoint kp = knowledgePointRepository.findById(knowledgePointId).orElse(null);
+                if (kp != null && blockContent != null) {
+                    String retryJson = aiService.generateQuestionForKnowledgePoint(blockContent, kp, null);
+                    Question retryQuestion = parseQuestionFromJson(retryJson, blockId, knowledgePointId,
+                            source, generatedBlockId);
+                    // 如果重试成功(题目不是默认题目),使用重试的结果
+                    if (!retryQuestion.getQuestionText().equals("请总结这一部分的主要内容。")) {
+                        log.info("重新生成题目成功: knowledgePointId={}", knowledgePointId);
+                        return retryQuestion;
+                    }
+                }
+            } catch (Exception e) {
+                log.error("重新生成题目失败: knowledgePointId={}", knowledgePointId, e);
+            }
+        }
+
+        return question;
+    }
+
+    private Question parseQuestionFromJson(String json, Long blockId, Long knowledgePointId,
+                                           QuestionSource source, Long generatedBlockId) {
+        try {
+            JsonObject obj = gson.fromJson(json, JsonObject.class);
+
+            Question question = new Question();
+            question.setBlockId(blockId);
+            question.setGeneratedBlockId(generatedBlockId);
+            question.setKnowledgePointId(knowledgePointId);
+            question.setQuestionSource(source);
+
+            // 检查必需字段
+            if (!obj.has("questionText") || obj.get("questionText").isJsonNull()) {
+                throw new IllegalArgumentException("缺少题目文本 (questionText)");
+            }
+            if (!obj.has("type") || obj.get("type").isJsonNull()) {
+                throw new IllegalArgumentException("缺少题目类型 (type)");
+            }
+            if (!obj.has("correctAnswer") || obj.get("correctAnswer").isJsonNull()) {
+                throw new IllegalArgumentException("缺少正确答案 (correctAnswer)");
+            }
+
+            question.setQuestionText(obj.get("questionText").getAsString());
+            question.setCorrectAnswer(obj.get("correctAnswer").getAsString());
+            question.setExplanation(obj.has("explanation") ? obj.get("explanation").getAsString() : null);
+            question.setSourceEvidence(obj.has("sourceEvidence") ? obj.get("sourceEvidence").getAsString() : null);
+
+            String typeStr = obj.get("type").getAsString().toUpperCase();
+            question.setType(QuestionType.valueOf(typeStr));
+
+            if (obj.has("options") && !obj.get("options").isJsonNull()) {
+                List<String> options = Arrays.asList(gson.fromJson(obj.get("options"), String[].class));
+                question.setOptions(options);
+            }
+
+            return question;
+        } catch (Exception e) {
+            log.error("解析题目JSON失败: {}", json, e);
+            // 返回默认问答题
+            Question question = new Question();
+            question.setBlockId(blockId);
+            question.setGeneratedBlockId(generatedBlockId);
+            question.setKnowledgePointId(knowledgePointId);
+            question.setQuestionSource(source);
+            question.setType(QuestionType.ESSAY);
+            question.setQuestionText("请总结这一部分的主要内容。");
+            question.setCorrectAnswer("根据原文内容进行总结");
+            question.setExplanation("这是一道开放性问答题");
+            return question;
+        }
+    }
+
+    private QuestionVO convertToVO(Question question) {
+        QuestionVO vo = new QuestionVO();
+        vo.setId(question.getId());
+        vo.setBlockId(question.getBlockId());
+        vo.setKnowledgePointId(question.getKnowledgePointId());
+        vo.setQuestionSource(question.getQuestionSource());
+        vo.setType(question.getType());
+        vo.setQuestionText(question.getQuestionText());
+        vo.setOptions(question.getOptions());
+        vo.setCorrectAnswer(question.getCorrectAnswer());
+        return vo;
+    }
+
+    @Override
+    public SubmitAnswerResponseVO submitAnswer(Long questionId, String answer, Long userId, Boolean isWeak) {
+        Question question = questionRepository.findById(questionId)
+                .orElseThrow(() -> SmartReviewException.notFound("题目不存在"));
+
+        // 检查是否已经回答过
+        UserAnswer existingAnswer = userAnswerRepository.findByUserIdAndQuestionId(userId, questionId);
+        if (existingAnswer != null) {
+            SubmitAnswerResponseVO response = new SubmitAnswerResponseVO();
+            response.setQuestionId(questionId);
+            response.setCorrectAnswer(question.getCorrectAnswer());
+            response.setExplanation(question.getExplanation());
+            response.setSourceEvidence(question.getSourceEvidence());
+            response.setIsCorrect(existingAnswer.getIsCorrect());
+            response.setAiFeedback(existingAnswer.getAiFeedback());
+            if (Boolean.FALSE.equals(existingAnswer.getIsCorrect()) || Boolean.TRUE.equals(isWeak)) {
+                response.setReviewSuggestion("建议回看本节内容,重点关注相关知识点");
+            }
+            return response;
+        }
+
+        SubmitAnswerResponseVO response = new SubmitAnswerResponseVO();
+        response.setQuestionId(questionId);
+        response.setCorrectAnswer(question.getCorrectAnswer());
+        response.setExplanation(question.getExplanation());
+        response.setSourceEvidence(question.getSourceEvidence());
+
+        boolean isCorrect;
+        String aiFeedback = null;
+
+        if (question.getType() == QuestionType.ESSAY) {
+            // 问答题需要AI批改
+            String gradeResult = aiService.gradeEssayAnswer(
+                    question.getQuestionText(),
+                    question.getCorrectAnswer(),
+                    answer);
+
+            try {
+                JsonObject gradeObj = gson.fromJson(gradeResult, JsonObject.class);
+                int score = gradeObj.has("score") ? gradeObj.get("score").getAsInt() : 60;
+                isCorrect = score >= 60;
+                aiFeedback = gradeObj.has("feedback") ? gradeObj.get("feedback").getAsString() : "答案已评阅";
+                if (gradeObj.has("reviewSuggestion")) {
+                    response.setReviewSuggestion(gradeObj.get("reviewSuggestion").getAsString());
+                }
+            } catch (Exception e) {
+                log.warn("解析批改结果失败", e);
+                isCorrect = true;
+                aiFeedback = "答案已提交";
+            }
+            response.setAiFeedback(aiFeedback);
+        } else {
+            // 选择题直接判断
+            String correctAnswer = question.getCorrectAnswer().toUpperCase().trim();
+            String userAnswer = answer.toUpperCase().trim();
+
+            if (question.getType() == QuestionType.MULTIPLE) {
+                // 多选题:排序后比较
+                String[] correctArr = correctAnswer.split(",");
+                String[] userArr = userAnswer.split(",");
+                Arrays.sort(correctArr);
+                Arrays.sort(userArr);
+                isCorrect = Arrays.equals(correctArr, userArr);
+            } else {
+                isCorrect = correctAnswer.equals(userAnswer);
+            }
+
+            if (!isCorrect) {
+                response.setReviewSuggestion("建议回看本节内容,重点关注相关知识点");
+            }
+        }
+
+        // 如果标记为薄弱点,强制生成详细解析
+        if (Boolean.TRUE.equals(isWeak)) {
+            try {
+                String detailedExplanation = aiService.generateDetailedExplanation(
+                        question.getQuestionText(),
+                        question.getCorrectAnswer(),
+                        question.getExplanation(),
+                        answer);
+                response.setExplanation(detailedExplanation);
+                // 更新题目解析
+                question.setExplanation(detailedExplanation);
+            } catch (Exception e) {
+                log.warn("生成详细解析失败", e);
+            }
+        }
+
+        response.setIsCorrect(isCorrect);
+
+        // 更新题目状态
+        if (Boolean.TRUE.equals(isWeak)) {
+            question.setStatus(QuestionStatus.ANSWERED_WEAK);
+        } else {
+            question.setStatus(isCorrect ? QuestionStatus.ANSWERED_RIGHT : QuestionStatus.ANSWERED_WRONG);
+        }
+        questionRepository.save(question);
+
+        // 如果回答错误或标记为薄弱点,增加对应知识点的复习次数
+        if ((!isCorrect || Boolean.TRUE.equals(isWeak)) && question.getKnowledgePointId() != null) {
+            knowledgePointRepository.findById(question.getKnowledgePointId()).ifPresent(kp -> {
+                kp.setReviewTimesNeeded(
+                        Math.min(2, (kp.getReviewTimesNeeded() == null ? 0 : kp.getReviewTimesNeeded()) + 1));
+                kp.setLastReviewedAt(LocalDateTime.now());
+                knowledgePointRepository.save(kp);
+            });
+        }
+
+        // 更新Block的错题数和薄弱点数(通过统计题目状态)
+        blockRepository.findById(question.getBlockId()).ifPresent(block -> {
+            // 查询该Block下所有question_source为CURRENT_BLOCK的题目
+            List<Question> currentBlockQuestions = questionRepository
+                    .findByBlockIdAndQuestionSource(block.getId(), QuestionSource.CURRENT_BLOCK);
+
+            // 统计错题数(状态为ANSWERED_WRONG)
+            long wrongCount = currentBlockQuestions.stream()
+                    .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WRONG)
+                    .count();
+
+            // 统计薄弱点数(状态为ANSWERED_WEAK)
+            long weakCount = currentBlockQuestions.stream()
+                    .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WEAK)
+                    .count();
+
+            block.setWrongAnswerCount((int) wrongCount);
+            block.setWeakPointCount((int) weakCount);
+            blockRepository.save(block);
+        });
+
+        // 保存答题记录
+        UserAnswer userAnswerEntity = new UserAnswer();
+        userAnswerEntity.setUserId(userId);
+        userAnswerEntity.setQuestionId(questionId);
+        userAnswerEntity.setBlockId(question.getBlockId());
+        userAnswerEntity.setUserAnswer(answer);
+        userAnswerEntity.setIsCorrect(isCorrect);
+        userAnswerEntity.setAiFeedback(aiFeedback);
+        userAnswerRepository.save(userAnswerEntity);
+
+        return response;
+    }
+
+    @Override
+    public List<SubmitAnswerResponseVO> submitAnswers(List<SubmitAnswerRequestVO> requests, Long userId) {
+        return requests.stream()
+                .map(req -> submitAnswer(req.getQuestionId(), req.getAnswer(), userId, req.getIsWeak()))
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    @Transactional
+    public QuestionVO regenerateQuestion(Long questionId, Long userId, QuestionType type) {
+        Question oldQuestion = questionRepository.findById(questionId)
+                .orElseThrow(() -> SmartReviewException.notFound("题目不存在"));
+
+        // 验证权限
+        Block block = blockRepository.findById(oldQuestion.getBlockId())
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+        Document document = documentRepository.findById(block.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此内容");
+        }
+
+        // 检查是否有关联的知识点
+        if (oldQuestion.getKnowledgePointId() == null) {
+            throw SmartReviewException.badRequest("该题目未关联知识点,无法重新生成");
+        }
+
+        KnowledgePoint kp = knowledgePointRepository.findById(oldQuestion.getKnowledgePointId())
+                .orElseThrow(() -> SmartReviewException.notFound("知识点不存在"));
+
+        // 将旧题目设置为废弃状态
+        oldQuestion.setStatus(QuestionStatus.DEPRECATED);
+        questionRepository.save(oldQuestion);
+
+        // 调用AI重新生成题目
+        try {
+            String questionJson = aiService.generateQuestionForKnowledgePoint(block.getContent(), kp, type);
+            Question newQuestion = parseQuestionFromJsonWithRetry(
+                    questionJson,
+                    oldQuestion.getBlockId(),
+                    oldQuestion.getKnowledgePointId(),
+                    oldQuestion.getQuestionSource(),
+                    oldQuestion.getGeneratedBlockId(),
+                    block.getContent());
+            newQuestion.setStatus(QuestionStatus.ACTIVE);
+            newQuestion = questionRepository.save(newQuestion);
+
+            log.info("重新生成题目成功: oldQuestionId={}, newQuestionId={}", questionId, newQuestion.getId());
+            return convertToVO(newQuestion);
+        } catch (Exception e) {
+            log.error("重新生成题目失败", e);
+            throw SmartReviewException.error("重新生成题目失败: " + e.getMessage());
+        }
+    }
+}

+ 2109 - 0
src/main/java/com/smartreview/service/impl/ReportServiceImpl.java

@@ -0,0 +1,2109 @@
+package com.smartreview.service.impl;
+
+import com.itextpdf.html2pdf.ConverterProperties;
+import com.itextpdf.html2pdf.HtmlConverter;
+import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
+import com.itextpdf.io.font.FontProgram;
+import com.itextpdf.io.font.FontProgramFactory;
+import com.itextpdf.layout.font.FontProvider;
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.enums.QuestionStatus;
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.*;
+import com.smartreview.repository.*;
+import com.smartreview.service.ReportService;
+import com.smartreview.vo.report.*;
+import com.vladsch.flexmark.ext.tables.TablesExtension;
+import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
+import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
+import com.vladsch.flexmark.ext.toc.TocExtension;
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.MutableDataSet;
+import lombok.extern.slf4j.Slf4j;
+import org.scilab.forge.jlatexmath.TeXConstants;
+import org.scilab.forge.jlatexmath.TeXFormula;
+import org.scilab.forge.jlatexmath.TeXIcon;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import javax.swing.*;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.util.Base64;
+import java.util.*;
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * 报告服务实现
+ */
+@Slf4j
+@Service
+public class ReportServiceImpl implements ReportService {
+
+    private final DocumentRepository documentRepository;
+    private final BlockRepository blockRepository;
+    private final UserAnswerRepository userAnswerRepository;
+    private final QuestionRepository questionRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+
+    // Markdown解析器
+    private final Parser markdownParser;
+    private final HtmlRenderer htmlRenderer;
+
+    public ReportServiceImpl(DocumentRepository documentRepository,
+            BlockRepository blockRepository,
+            UserAnswerRepository userAnswerRepository,
+            QuestionRepository questionRepository,
+            KnowledgePointRepository knowledgePointRepository) {
+        this.documentRepository = documentRepository;
+        this.blockRepository = blockRepository;
+        this.userAnswerRepository = userAnswerRepository;
+        this.questionRepository = questionRepository;
+        this.knowledgePointRepository = knowledgePointRepository;
+
+        // 初始化Markdown解析器 - 启用扩展支持表格、删除线、自动链接等
+        MutableDataSet options = new MutableDataSet();
+        options.set(Parser.EXTENSIONS, Arrays.asList(
+                TablesExtension.create(),
+                StrikethroughExtension.create(),
+                AutolinkExtension.create(),
+                TocExtension.create()));
+        // 表格配置
+        options.set(TablesExtension.WITH_CAPTION, false);
+        options.set(TablesExtension.COLUMN_SPANS, false);
+        options.set(TablesExtension.MIN_HEADER_ROWS, 1);
+        options.set(TablesExtension.MAX_HEADER_ROWS, 1);
+        options.set(TablesExtension.APPEND_MISSING_COLUMNS, true);
+        options.set(TablesExtension.DISCARD_EXTRA_COLUMNS, true);
+        options.set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true);
+        // HTML渲染器配置 - 不转义嵌入的HTML标签
+        options.set(HtmlRenderer.ESCAPE_HTML, false);
+        options.set(HtmlRenderer.ESCAPE_HTML_BLOCKS, false);
+        options.set(HtmlRenderer.ESCAPE_HTML_COMMENT_BLOCKS, false);
+        options.set(HtmlRenderer.ESCAPE_INLINE_HTML, false);
+        options.set(HtmlRenderer.ESCAPE_INLINE_HTML_COMMENTS, false);
+        options.set(HtmlRenderer.SUPPRESS_HTML, false);
+        options.set(HtmlRenderer.SUPPRESS_HTML_BLOCKS, false);
+        options.set(HtmlRenderer.SUPPRESS_HTML_COMMENT_BLOCKS, false);
+        options.set(HtmlRenderer.SUPPRESS_INLINE_HTML, false);
+        options.set(HtmlRenderer.SUPPRESS_INLINE_HTML_COMMENTS, false);
+        this.markdownParser = Parser.builder(options).build();
+        this.htmlRenderer = HtmlRenderer.builder(options).build();
+    }
+
+    @Override
+    public ReviewReportVO getReport(String documentId, Long userId) {
+        Document document = documentRepository.findById(documentId)
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此文档");
+        }
+
+        ReviewReportVO report = new ReviewReportVO();
+        report.setDocumentId(documentId);
+        report.setDocumentTitle(document.getTitle());
+
+        // 进度统计(只统计LEARNABLE类型的block)
+        List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+        List<Block> learnableBlocks = blocks.stream()
+                .filter(b -> b.getType() == com.smartreview.enums.BlockType.LEARNABLE)
+                .collect(Collectors.toList());
+
+        ProgressSummaryVO progress = new ProgressSummaryVO();
+        progress.setTotalBlocks(learnableBlocks.size());
+        progress.setCompletedBlocks((int) learnableBlocks.stream()
+                .filter(b -> b.getStatus() == BlockStatus.COMPLETED || b.getStatus() == BlockStatus.GENERATED_QUESTION)
+                .count());
+        progress.setReadingBlocks((int) learnableBlocks.stream()
+                .filter(b -> b.getStatus() == BlockStatus.READING).count());
+        progress.setProgressPercent(!learnableBlocks.isEmpty()
+                ? (progress.getCompletedBlocks() * 100.0 / learnableBlocks.size())
+                : 0);
+        report.setProgress(progress);
+
+        // 答题统计
+        List<Long> blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList());
+        List<Question> questions = questionRepository.findByBlockIdIn(blockIds);
+
+        // 根据题目状态统计(过滤掉已废弃的题目)
+        int correctCount = 0;
+        int wrongCount = 0;
+        int weakCount = 0;
+        int validTotal = 0;
+
+        for (Question question : questions) {
+            if (question.getStatus() == QuestionStatus.DEPRECATED
+                    || question.getQuestionSource() == com.smartreview.enums.QuestionSource.SELECTION_GENERATED) {
+                continue;
+            }
+            validTotal++;
+            if (question.getStatus() == QuestionStatus.ANSWERED_RIGHT) {
+                correctCount++;
+            } else if (question.getStatus() == QuestionStatus.ANSWERED_WRONG) {
+                wrongCount++;
+            } else if (question.getStatus() == QuestionStatus.ANSWERED_WEAK) {
+                weakCount++;
+            }
+        }
+
+        AnswerStatisticsVO statistics = new AnswerStatisticsVO();
+        statistics.setTotalQuestions(validTotal);
+        statistics.setCorrectCount(correctCount);
+        statistics.setWrongCount(wrongCount);
+        statistics.setWeakCount(weakCount);
+        statistics.setCorrectRate(validTotal > 0 ? (correctCount * 100.0 / validTotal) : 0);
+        report.setStatistics(statistics);
+
+        // 直接从答错/薄弱题目中获取知识点(只看CURRENT_BLOCK来源的题目,即第一次生成的题目)
+        List<Question> wrongQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds,
+                QuestionStatus.ANSWERED_WRONG).stream()
+                .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
+                .collect(Collectors.toList());
+        List<Question> weakQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds,
+                QuestionStatus.ANSWERED_WEAK).stream()
+                .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
+                .collect(Collectors.toList());
+
+        // 错误知识点统计(来自答错的题目,统计次数)
+        List<WeakPointVO> wrongKnowledgePoints = buildKnowledgePointVOList(wrongQuestionList, true);
+        report.setWrongKnowledgePoints(wrongKnowledgePoints.stream().limit(10).collect(Collectors.toList()));
+
+        // 薄弱知识点统计(来自标记薄弱的题目,不需要次数)
+        List<WeakPointVO> weakKnowledgePoints = buildKnowledgePointVOList(weakQuestionList, false);
+        report.setWeakKnowledgePoints(weakKnowledgePoints.stream().limit(10).collect(Collectors.toList()));
+
+        // 错题列表(包含答错和薄弱的题目)
+        List<WrongQuestionVO> wrongQuestions = new ArrayList<>();
+        for (Question question : wrongQuestionList) {
+            wrongQuestions.add(buildWrongQuestionVO(question, false));
+        }
+        for (Question question : weakQuestionList) {
+            wrongQuestions.add(buildWrongQuestionVO(question, true));
+        }
+        report.setWrongQuestions(wrongQuestions);
+
+        return report;
+    }
+
+    /**
+     * 构建知识点VO列表(按知识点分组统计次数)
+     *
+     * @param questions    题目列表
+     * @param includeCount 是否包含次数统计
+     */
+    private List<WeakPointVO> buildKnowledgePointVOList(List<Question> questions, boolean includeCount) {
+        // 按知识点ID分组统计次数
+        Map<Long, List<Question>> questionsByKpId = questions.stream()
+                .filter(q -> q.getKnowledgePointId() != null)
+                .collect(Collectors.groupingBy(Question::getKnowledgePointId));
+
+        List<WeakPointVO> result = new ArrayList<>();
+        for (Map.Entry<Long, List<Question>> entry : questionsByKpId.entrySet()) {
+            Long kpId = entry.getKey();
+            int count = entry.getValue().size();
+
+            KnowledgePoint kp = knowledgePointRepository.findById(kpId).orElse(null);
+            if (kp != null) {
+                Block block = blockRepository.findById(kp.getBlockId()).orElse(null);
+                WeakPointVO vo = new WeakPointVO();
+                vo.setKnowledgePointId(kpId);
+                vo.setKnowledgePoint(kp.getContent());
+                vo.setBlockId(kp.getBlockId());
+                vo.setBlockPreview(block != null && block.getContent() != null && block.getContent().length() > 30
+                        ? block.getContent().substring(0, 30) + "..."
+                        : (block != null ? block.getContent() : null));
+                vo.setCount(includeCount ? count : 0); // 薄弱知识点不需要次数
+                result.add(vo);
+            }
+        }
+
+        // 按次数降序排序(如果需要次数的话)
+        if (includeCount) {
+            result.sort((a, b) -> Integer.compare(b.getCount(), a.getCount()));
+        }
+        return result;
+    }
+
+    /**
+     * 构建错题VO
+     */
+    private WrongQuestionVO buildWrongQuestionVO(Question question, boolean isWeak) {
+        WrongQuestionVO wq = new WrongQuestionVO();
+        wq.setQuestionId(question.getId());
+        wq.setBlockId(question.getBlockId());
+        wq.setType(question.getType());
+        wq.setQuestionText(question.getQuestionText());
+        wq.setCorrectAnswer(question.getCorrectAnswer());
+        wq.setExplanation(question.getExplanation());
+        wq.setIsWeak(isWeak);
+        // 获取最近的用户答案
+        userAnswerRepository.findTopByQuestionIdOrderByAnsweredAtDesc(question.getId())
+                .ifPresent(answer -> wq.setUserAnswer(answer.getUserAnswer()));
+        return wq;
+    }
+
+    @Override
+    public byte[] exportPdfReport(String documentId, Long userId) {
+        Document document = documentRepository.findById(documentId)
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此文档");
+        }
+
+        ReviewReportVO report = getReport(documentId, userId);
+
+        // 获取所有Block
+        List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
+        List<Long> blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList());
+
+        // 获取所有知识点
+        List<KnowledgePoint> allKnowledgePoints = knowledgePointRepository.findByBlockIdIn(blockIds);
+
+        // 获取所有题目(非废弃的)
+        List<Question> allQuestions = questionRepository.findByBlockIdIn(blockIds).stream()
+                .filter(q -> q.getStatus() != QuestionStatus.DEPRECATED)
+                .collect(Collectors.toList());
+
+        // 构建知识点状态映射(根据题目状态判断知识点状态,只看CURRENT_BLOCK来源的题目)
+        // 只考虑CURRENT_BLOCK来源的题目来判断知识点状态
+        List<Question> currentBlockQuestions = allQuestions.stream()
+                .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
+                .collect(Collectors.toList());
+
+        // 错误知识点ID集合
+        Set<Long> wrongKpIds = currentBlockQuestions.stream()
+                .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WRONG && q.getKnowledgePointId() != null)
+                .map(Question::getKnowledgePointId)
+                .collect(Collectors.toSet());
+
+        // 薄弱知识点ID集合
+        Set<Long> weakKpIds = currentBlockQuestions.stream()
+                .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WEAK && q.getKnowledgePointId() != null)
+                .map(Question::getKnowledgePointId)
+                .collect(Collectors.toSet());
+
+        // 正确回答的知识点ID集合
+        Set<Long> correctKpIds = currentBlockQuestions.stream()
+                .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_RIGHT && q.getKnowledgePointId() != null)
+                .map(Question::getKnowledgePointId)
+                .collect(Collectors.toSet());
+
+        // 有题目关联的知识点ID(已复习过的,也只看CURRENT_BLOCK)
+        Set<Long> reviewedKpIds = currentBlockQuestions.stream()
+                .filter(q -> q.getKnowledgePointId() != null)
+                .map(Question::getKnowledgePointId)
+                .collect(Collectors.toSet());
+
+        // 调试日志
+        log.info("==== PDF报告知识点状态调试 ====");
+        log.info("总知识点数: {}", allKnowledgePoints.size());
+        log.info("CURRENT_BLOCK来源的题目数: {}", currentBlockQuestions.size());
+        log.info("错误知识点IDs: {}", wrongKpIds);
+        log.info("薄弱知识点IDs: {}", weakKpIds);
+        log.info("正确知识点IDs: {}", correctKpIds);
+        log.info("已复习知识点IDs: {}", reviewedKpIds);
+        for (Question q : currentBlockQuestions) {
+            log.info("题目ID:{}, 状态:{}, 来源:{}, 关联知识点ID:{}",
+                    q.getId(), q.getStatus(), q.getQuestionSource(), q.getKnowledgePointId());
+        }
+
+        // 生成HTML
+        String html = generateFullReportHtml(report, blocks, allKnowledgePoints, allQuestions,
+                wrongKpIds, weakKpIds, correctKpIds, reviewedKpIds);
+        log.debug("html: {}", html);
+
+        // 转换为PDF(配置中文字体支持)
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+            ConverterProperties converterProperties = new ConverterProperties();
+            FontProvider fontProvider = new DefaultFontProvider(false, false, false);
+            // 从项目资源目录加载字体文件
+            try {
+                // 字体文件配置:文件名 -> 字体集合索引(ttc文件需要指定索引,ttf文件为-1)
+                String[][] fontConfigs = {
+                        { "simsun.ttc", "0" }, // 宋体
+                        { "simhei.ttf", "-1" }, // 黑体
+                        { "msyh.ttc", "0" }, // 微软雅黑 Regular
+                        { "msyhbd.ttc", "0" }, // 微软雅黑 Bold
+                        { "simfang.ttf", "-1" }, // 仿宋
+                        { "seguiemj.ttf", "-1" }, // Segoe UI Emoji
+                        { "segoeui.ttf", "-1" }, // Segoe UI
+                        { "segoeuib.ttf", "-1" } // Segoe UI Bold
+                };
+
+                for (String[] config : fontConfigs) {
+                    String fontFileName = config[0];
+                    int ttcIndex = Integer.parseInt(config[1]);
+                    try {
+                        org.springframework.core.io.ClassPathResource fontResource = new org.springframework.core.io.ClassPathResource(
+                                "font/" + fontFileName);
+                        if (fontResource.exists()) {
+                            byte[] fontBytes = fontResource.getInputStream().readAllBytes();
+                            if (ttcIndex >= 0) {
+                                // TTC字体集合需要写入临时文件后用路径方式加载
+                                java.io.File tempFile = java.io.File.createTempFile("font_", "_" + fontFileName);
+                                tempFile.deleteOnExit();
+                                java.nio.file.Files.write(tempFile.toPath(), fontBytes);
+                                // 使用 "路径,索引" 格式加载TTC中的指定字体
+                                fontProvider.addFont(tempFile.getAbsolutePath() + "," + ttcIndex);
+                            } else {
+                                // TTF字体直接从字节数组加载
+                                fontProvider.addFont(fontBytes);
+                            }
+                            log.debug("成功加载字体: {}", fontFileName);
+                        } else {
+                            log.warn("字体文件不存在: {}", fontFileName);
+                        }
+                    } catch (Exception e) {
+                        log.warn("加载字体文件失败: {}, 原因: {}", fontFileName, e.getMessage());
+                    }
+                }
+            } catch (Exception fontEx) {
+                log.warn("加载项目字体失败,尝试使用默认字体: {}", fontEx.getMessage());
+                // 如果特定字体加载失败,使用默认字体提供器
+                fontProvider = new DefaultFontProvider(true, false, false);
+            }
+            converterProperties.setFontProvider(fontProvider);
+
+            HtmlConverter.convertToPdf(html, baos, converterProperties);
+            return baos.toByteArray();
+        } catch (Exception e) {
+            log.error("生成PDF报告失败", e);
+            throw SmartReviewException.error("生成PDF报告失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 生成完整报告HTML(包含三部分)- 美观设计版本(兼容iText PDF)
+     */
+    private String generateFullReportHtml(ReviewReportVO report, List<Block> blocks,
+            List<KnowledgePoint> allKnowledgePoints,
+            List<Question> allQuestions,
+            Set<Long> wrongKpIds, Set<Long> weakKpIds, Set<Long> correctKpIds,
+            Set<Long> reviewedKpIds) {
+        StringBuilder html = new StringBuilder();
+        html.append("<!DOCTYPE html><html><head>");
+        html.append("<meta charset=\"UTF-8\">");
+        html.append("<style>");
+
+        // 基础样式 - 现代美观设计(兼容iText,使用table布局替代flex)
+        html.append("* { margin: 0; padding: 0; box-sizing: border-box; }");
+        html.append(
+                "body { font-family: 'Segoe UI Emoji', 'Microsoft YaHei', 'SimHei', 'SimSun', sans-serif; padding: 30px; line-height: 1.8; font-size: 14px; background: #f8f9fa; color: #333; }");
+
+        // 标题样式
+        html.append(
+                "h1 { color: #2c3e50; font-size: 28px; text-align: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 3px solid #3498db; }");
+        html.append(
+                "h2 { color: #fff; font-size: 20px; margin: 30px 0 20px 0; padding: 12px 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; }");
+        html.append(
+                "h3 { color: #2c3e50; font-size: 16px; margin: 20px 0 10px 0; padding-left: 12px; border-left: 4px solid #3498db; }");
+
+        // 统计卡片样式 - 使用table布局
+        html.append(".stats-table { width: 100%; margin: 20px 0; border-collapse: separate; border-spacing: 10px; }");
+        html.append(".stat-card { padding: 20px; border-radius: 12px; color: #fff; text-align: center; }");
+        html.append(".stat-card.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }");
+        html.append(".stat-card.correct { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }");
+        html.append(".stat-card.wrong { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); }");
+        html.append(".stat-card.weak { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }");
+        html.append(".stat-card.progress { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }");
+        html.append(".stat-number { font-size: 32px; font-weight: bold; }");
+        html.append(".stat-label { font-size: 14px; opacity: 0.9; margin-top: 5px; }");
+
+        // 进度条样式
+        html.append(
+                ".progress-bar-container { background: #e0e0e0; border-radius: 10px; height: 20px; margin: 15px 0; overflow: hidden; }");
+        html.append(
+                ".progress-bar { height: 100%; background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%); border-radius: 10px; }");
+
+        // 知识点表格样式
+        html.append(".kp-table { width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #ddd; }");
+        html.append(
+                ".kp-table th { background: #667eea; color: #fff; padding: 12px 15px; text-align: left; font-weight: bold; border: 1px solid #5a6fd6; }");
+        html.append(".kp-table td { padding: 12px 15px; border: 1px solid #ddd; background: #fff; }");
+        html.append(".kp-table tr:nth-child(even) td { background: #f9f9f9; }");
+        html.append(
+                ".count-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-weight: bold; font-size: 12px; }");
+        html.append(".count-badge.wrong { background: #ffebee; color: #c62828; }");
+        html.append(".count-badge.weak { background: #fff3e0; color: #ef6c00; }");
+
+        // 知识点高亮样式
+        html.append(
+                ".kp-wrong { background-color: rgba(244, 67, 54, 0.25); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #f44336; }");
+        html.append(
+                ".kp-weak { background-color: rgba(255, 152, 0, 0.25); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #FF9800; }");
+        html.append(
+                ".kp-correct { background-color: rgba(76, 175, 80, 0.2); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #4CAF50; }");
+        html.append(
+                ".kp-unreviewed { background-color: rgba(158, 158, 158, 0.15); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #9e9e9e; }");
+
+        // 知识点代码块高亮样式
+        html.append(
+                ".kp-block-wrong { border: 2px solid #f44336; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
+        html.append(
+                ".kp-block-wrong pre { background-color: #3d1d1d !important; margin: 0 !important; border-radius: 0 !important; }");
+
+        html.append(
+                ".kp-block-weak { border: 2px solid #FF9800; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
+        html.append(
+                ".kp-block-weak pre { background-color: #3d2d1d !important; margin: 0 !important; border-radius: 0 !important; }");
+
+        html.append(
+                ".kp-block-correct { border: 2px solid #4CAF50; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
+        html.append(
+                ".kp-block-correct pre { background-color: #1d3d1d !important; margin: 0 !important; border-radius: 0 !important; }");
+
+        html.append(
+                ".kp-block-unreviewed { border: 2px solid #9e9e9e; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
+        html.append(
+                ".kp-block-unreviewed pre { background-color: #3d3d3d !important; margin: 0 !important; border-radius: 0 !important; }");
+
+        // Block内容样式 - Markdown渲染
+        html.append(
+                ".block-content { background: #fff; padding: 25px; margin: 15px 0; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border: 1px solid #e8e8e8; }");
+        html.append(
+                ".block-content h1, .block-content h2, .block-content h3, .block-content h4, .block-content h5, .block-content h6 { color: #2c3e50; margin: 15px 0 10px 0; padding: 0; background: none; border: none; border-radius: 0; }");
+        html.append(".block-content h1 { font-size: 22px; border-bottom: 2px solid #eee; padding-bottom: 8px; }");
+        html.append(".block-content h2 { font-size: 18px; color: #34495e; }");
+        html.append(".block-content h3 { font-size: 16px; color: #555; }");
+        html.append(".block-content h4 { font-size: 15px; color: #666; }");
+        html.append(".block-content p { margin: 10px 0; line-height: 1.8; }");
+        html.append(".block-content ul, .block-content ol { margin: 10px 0; padding-left: 25px; }");
+        html.append(".block-content li { margin: 5px 0; line-height: 1.6; }");
+        html.append(
+                ".block-content code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', 'Microsoft YaHei', monospace; font-size: 13px; color: #c7254e; }");
+        html.append(
+                ".block-content pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; overflow-x: auto; margin: 15px 0; white-space: pre-wrap; word-wrap: break-word; }");
+        html.append(".block-content pre code { background: none; color: inherit; padding: 0; }");
+        html.append(
+                ".block-content blockquote { border-left: 4px solid #3498db; padding-left: 15px; margin: 15px 0; color: #666; background: #f9f9f9; padding: 10px 15px; border-radius: 0 8px 8px 0; }");
+        html.append(".block-content strong, .block-content b { color: #2c3e50; font-weight: bold; }");
+        html.append(".block-content em, .block-content i { font-style: italic; color: #555; }");
+        html.append(".block-content del { text-decoration: line-through; color: #999; }");
+        // Markdown表格样式 - 增强边框
+        html.append(
+                ".block-content table { border-collapse: collapse; width: 100%; margin: 15px 0; border: 2px solid #667eea; }");
+        html.append(
+                ".block-content table th { background: #667eea; color: #fff; padding: 12px 15px; text-align: left; font-weight: bold; border: 1px solid #5a6fd6; }");
+        html.append(".block-content table td { padding: 10px 15px; border: 1px solid #ddd; background: #fff; }");
+        html.append(".block-content table tr:nth-child(even) td { background: #f9f9f9; }");
+        // 代码块样式 - 增加语言标签支持
+        html.append(
+                ".block-content pre { background: #2d2d2d; color: #f8f8f2; padding: 25px 15px 15px 15px; border-radius: 8px; overflow-x: auto; margin: 15px 0; white-space: pre-wrap; word-wrap: break-word; position: relative; }");
+        html.append(".block-content pre code { background: none; color: inherit; padding: 0; }");
+        html.append(
+                ".code-lang { position: absolute; top: 0; right: 0; background: #444; color: #fff; padding: 2px 8px; border-radius: 0 8px 0 8px; font-size: 12px; font-family: sans-serif; }");
+        // 数学公式样式(以代码块形式显示)
+        html.append(
+                ".math { font-family: 'Consolas', 'Microsoft YaHei', monospace; background: #f8f8f8; padding: 10px 15px; border-radius: 6px; margin: 10px 0; border-left: 3px solid #9b59b6; color: #333; }");
+        html.append(
+                ".inline-math { font-family: 'Consolas', 'Microsoft YaHei', monospace; background: #f4f4f4; padding: 2px 6px; border-radius: 4px; color: #9b59b6; }");
+        // 水平分割线
+        html.append(
+                ".block-content hr { border: none; height: 2px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); margin: 20px 0; border-radius: 1px; }");
+
+        // 图例样式 - 使用table布局
+        html.append(
+                ".legend-table { width: 100%; background: #fff; border-radius: 8px; margin: 15px 0; border: 1px solid #eee; }");
+        html.append(".legend-table td { padding: 10px 15px; }");
+        html.append(
+                ".legend-color { display: inline-block; width: 20px; height: 20px; border-radius: 4px; vertical-align: middle; margin-right: 8px; }");
+        html.append(".legend-color-wrong { background-color: rgba(244, 67, 54, 0.35); border: 2px solid #f44336; }");
+        html.append(".legend-color-weak { background-color: rgba(255, 152, 0, 0.35); border: 2px solid #FF9800; }");
+        html.append(".legend-color-correct { background-color: rgba(76, 175, 80, 0.25); border: 2px solid #4CAF50; }");
+        html.append(
+                ".legend-color-unreviewed { background-color: rgba(158, 158, 158, 0.25); border: 2px solid #9e9e9e; }");
+
+        // 题目卡片样式
+        html.append(
+                ".question-card { background: #fff; margin: 15px 0; padding: 20px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border-left: 5px solid #ddd; }");
+        html.append(".question-card.wrong { border-left-color: #f44336; }");
+        html.append(".question-card.weak { border-left-color: #FF9800; }");
+        html.append(".question-card.correct { border-left-color: #4CAF50; }");
+        html.append(".question-card.active { border-left-color: #2196F3; }");
+        html.append(".question-header { margin-bottom: 15px; }");
+        html.append(".question-header-table { width: 100%; }");
+        html.append(".question-text { font-size: 15px; line-height: 1.7; color: #2c3e50; }");
+        html.append(
+                ".question-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; white-space: nowrap; }");
+        html.append(".badge-wrong { background: #eb3349; color: #fff; }");
+        html.append(".badge-weak { background: #f5576c; color: #fff; }");
+        html.append(".badge-correct { background: #11998e; color: #fff; }");
+        html.append(".badge-active { background: #4facfe; color: #fff; }");
+        html.append(
+                ".question-type { display: inline-block; padding: 2px 8px; background: #f0f0f0; border-radius: 4px; font-size: 12px; color: #666; margin-bottom: 10px; }");
+        html.append(".options { margin: 15px 0; padding: 10px 15px; background: #f8f9fa; border-radius: 8px; }");
+        html.append(
+                ".options p { margin: 8px 0; padding: 8px 12px; background: #fff; border-radius: 6px; border: 1px solid #eee; }");
+        html.append(".answer-section { margin-top: 15px; padding-top: 15px; border-top: 1px dashed #ddd; }");
+        html.append(".user-answer { color: #f44336; font-weight: bold; }");
+        html.append(".correct-answer { color: #4CAF50; font-weight: bold; }");
+        html.append(
+                ".explanation { margin-top: 15px; padding: 15px; background: #f5f7fa; border-radius: 8px; font-size: 14px; line-height: 1.8; }");
+        html.append(".explanation-title { font-weight: bold; color: #2c3e50; margin-bottom: 8px; }");
+        // 题目区域Markdown样式(加粗、斜体等)
+        html.append(
+                ".question-card strong, .question-card b { color: #2c3e50; font-weight: bold; font-family: 'SimHei', 'Microsoft YaHei', sans-serif; }");
+        html.append(".question-card em, .question-card i { font-style: italic; color: #555; }");
+        html.append(
+                ".question-card code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', 'Microsoft YaHei', monospace; font-size: 13px; color: #c7254e; }");
+        html.append(".question-card p { margin: 8px 0; line-height: 1.7; }");
+        html.append(".question-card ul, .question-card ol { margin: 8px 0; padding-left: 20px; }");
+        html.append(".question-card li { margin: 4px 0; }");
+
+        // 柱状图样式
+        html.append(
+                ".bar-chart { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
+        html.append(".bar-item { margin: 12px 0; }");
+        html.append(".bar-label-row { margin-bottom: 5px; }");
+        html.append(".bar-label-table { width: 100%; }");
+        html.append(".bar-label { font-size: 13px; color: #333; }");
+        html.append(".bar-count { font-size: 13px; font-weight: bold; text-align: right; }");
+        html.append(".bar-bg { background: #ffebee; border-radius: 4px; height: 12px; overflow: hidden; }");
+        html.append(".bar-fill { height: 100%; background: linear-gradient(90deg, #f44336 0%, #ff7043 100%); }");
+        html.append(".bar-fill-weak { background: linear-gradient(90deg, #ff9800 0%, #ffb74d 100%); }");
+
+        // 标签卡片样式
+        html.append(
+                ".tag-container { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
+        html.append(
+                ".tag { display: inline-block; padding: 8px 16px; background: #fff3e0; border: 1px solid #FFB74D; border-radius: 20px; font-size: 13px; color: #e65100; margin: 5px; }");
+
+        // 分页样式
+        html.append(".page-break { page-break-before: always; margin-top: 40px; }");
+        html.append(
+                ".section-divider { height: 3px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); margin: 30px 0; border-radius: 2px; }");
+
+        // 白色背景区块
+        html.append(
+                ".white-box { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
+
+        html.append("</style></head><body>");
+
+        // ==================== 报告标题 ====================
+        html.append("<h1>复习报告-").append(escapeHtml(report.getDocumentTitle())).append("</h1>");
+        // ==================== 第一部分:统计信息 ====================
+        html.append("<h2>第一部分:学习统计</h2>");
+
+        // 统计卡片 - 使用table布局,四个卡片等宽(各25%)
+        html.append("<table class=\"stats-table\"><tr>");
+        html.append("<td width=\"25%\" class=\"stat-card total\"><div class=\"stat-number\">")
+                .append(report.getStatistics().getTotalQuestions())
+                .append("</div><div class=\"stat-label\">总题数</div></td>");
+        html.append("<td width=\"25%\" class=\"stat-card correct\"><div class=\"stat-number\">")
+                .append(report.getStatistics().getCorrectCount())
+                .append("</div><div class=\"stat-label\">正确</div></td>");
+        html.append("<td width=\"25%\" class=\"stat-card wrong\"><div class=\"stat-number\">")
+                .append(report.getStatistics().getWrongCount()).append("</div><div class=\"stat-label\">错误</div></td>");
+        html.append("<td width=\"25%\" class=\"stat-card weak\"><div class=\"stat-number\">")
+                .append(report.getStatistics().getWeakCount()).append("</div><div class=\"stat-label\">薄弱</div></td>");
+        html.append("</tr></table>");
+
+        // 答题分布 - 使用柱状图替代饼图(iText不支持conic-gradient)
+        int total = report.getStatistics().getTotalQuestions();
+        int correct = report.getStatistics().getCorrectCount();
+        int wrong = report.getStatistics().getWrongCount();
+        int weak = report.getStatistics().getWeakCount();
+        int unanswered = total - correct - wrong - weak;
+        if (total > 0) {
+            html.append("<h3>答题分布图</h3>");
+            html.append("<div class=\"bar-chart\">");
+
+            double correctPct = correct * 100.0 / total;
+            double wrongPct = wrong * 100.0 / total;
+            double weakPct = weak * 100.0 / total;
+            double unansweredPct = unanswered * 100.0 / total;
+
+            // 正确题目
+            html.append("<div class=\"bar-item\">");
+            html.append("<table class=\"bar-label-table\"><tr>");
+            html.append("<td class=\"bar-label\" style=\"color: #4CAF50;\">正确</td>");
+            html.append("<td class=\"bar-count\" style=\"color: #4CAF50;\">").append(correct).append(" (")
+                    .append(String.format("%.1f", correctPct)).append("%)</td>");
+            html.append("</tr></table>");
+            html.append("<div style=\"background: #e8f5e9; border-radius: 4px; height: 16px; overflow: hidden;\">");
+            html.append("<div style=\"height: 100%; background: #4CAF50; width: ")
+                    .append(String.format("%.1f", correctPct)).append("%;\"></div>");
+            html.append("</div></div>");
+
+            // 错误题目
+            html.append("<div class=\"bar-item\">");
+            html.append("<table class=\"bar-label-table\"><tr>");
+            html.append("<td class=\"bar-label\" style=\"color: #f44336;\">错误</td>");
+            html.append("<td class=\"bar-count\" style=\"color: #f44336;\">").append(wrong).append(" (")
+                    .append(String.format("%.1f", wrongPct)).append("%)</td>");
+            html.append("</tr></table>");
+            html.append("<div style=\"background: #ffebee; border-radius: 4px; height: 16px; overflow: hidden;\">");
+            html.append("<div style=\"height: 100%; background: #f44336; width: ")
+                    .append(String.format("%.1f", wrongPct)).append("%;\"></div>");
+            html.append("</div></div>");
+
+            // 薄弱题目
+            html.append("<div class=\"bar-item\">");
+            html.append("<table class=\"bar-label-table\"><tr>");
+            html.append("<td class=\"bar-label\" style=\"color: #FF9800;\">薄弱</td>");
+            html.append("<td class=\"bar-count\" style=\"color: #FF9800;\">").append(weak).append(" (")
+                    .append(String.format("%.1f", weakPct)).append("%)</td>");
+            html.append("</tr></table>");
+            html.append("<div style=\"background: #fff3e0; border-radius: 4px; height: 16px; overflow: hidden;\">");
+            html.append("<div style=\"height: 100%; background: #FF9800; width: ")
+                    .append(String.format("%.1f", weakPct)).append("%;\"></div>");
+            html.append("</div></div>");
+
+            // 未答题目
+            html.append("<div class=\"bar-item\">");
+            html.append("<table class=\"bar-label-table\"><tr>");
+            html.append("<td class=\"bar-label\" style=\"color: #9e9e9e;\">未答</td>");
+            html.append("<td class=\"bar-count\" style=\"color: #9e9e9e;\">").append(unanswered).append(" (")
+                    .append(String.format("%.1f", unansweredPct)).append("%)</td>");
+            html.append("</tr></table>");
+            html.append("<div style=\"background: #f5f5f5; border-radius: 4px; height: 16px; overflow: hidden;\">");
+            html.append("<div style=\"height: 100%; background: #9e9e9e; width: ")
+                    .append(String.format("%.1f", unansweredPct)).append("%;\"></div>");
+            html.append("</div></div>");
+
+            html.append("</div>");
+        }
+
+        // 进度信息和正确率 - 合并为一行,避免跨页
+        html.append("<h3>复习进度与答题正确率</h3>");
+        html.append("<div class=\"white-box\">");
+        html.append("<table width=\"100%\"><tr>");
+        // 左边:复习进度
+        html.append("<td width=\"50%\" style=\"padding-right: 15px; vertical-align: top;\">");
+        html.append("<p style=\"margin-bottom: 8px; font-weight: bold;\">复习进度</p>");
+        html.append("<p style=\"margin-bottom: 5px; font-size: 13px;\">总内容块:<b>")
+                .append(report.getProgress().getTotalBlocks()).append("</b> | ");
+        html.append("已完成:<b style=\"color: #4CAF50;\">").append(report.getProgress().getCompletedBlocks())
+                .append("</b> | ");
+        html.append("学习中:<b style=\"color: #2196F3;\">").append(report.getProgress().getReadingBlocks())
+                .append("</b></p>");
+        html.append("<div class=\"progress-bar-container\"><div class=\"progress-bar\" style=\"width: ")
+                .append(String.format("%.1f", report.getProgress().getProgressPercent())).append("%;\"></div></div>");
+        html.append("<p style=\"text-align: center; font-size: 14px; font-weight: bold; color: #4CAF50;\">完成率:")
+                .append(String.format("%.1f%%", report.getProgress().getProgressPercent())).append("</p>");
+        html.append("</td>");
+        // 右边:答题正确率
+        html.append(
+                "<td width=\"50%\" style=\"padding-left: 15px; vertical-align: top; border-left: 1px solid #eee;\">");
+        html.append("<p style=\"margin-bottom: 8px; font-weight: bold;\">答题正确率</p>");
+        double correctRate = report.getStatistics().getCorrectRate();
+        String rateColor = correctRate >= 80 ? "#4CAF50" : (correctRate >= 60 ? "#FF9800" : "#f44336");
+        html.append("<p style=\"margin-bottom: 5px; font-size: 13px;\">正确 ")
+                .append(report.getStatistics().getCorrectCount()).append(" / 总 ")
+                .append(report.getStatistics().getTotalQuestions()).append(" 题</p>");
+        html.append("<div class=\"progress-bar-container\"><div class=\"progress-bar\" style=\"width: ")
+                .append(String.format("%.1f", correctRate)).append("%; background: ").append(rateColor)
+                .append(";\"></div></div>");
+        html.append("<p style=\"text-align: center; font-size: 14px; font-weight: bold; color: ").append(rateColor)
+                .append(";\">正确率:").append(String.format("%.1f%%", correctRate)).append("</p>");
+        html.append("</td>");
+        html.append("</tr></table>");
+        html.append("</div>");
+
+        // 错误知识点 - 使用柱状图展示
+        if (report.getWrongKnowledgePoints() != null && !report.getWrongKnowledgePoints().isEmpty()) {
+            html.append("<h3>错误知识点分布</h3>");
+            html.append("<div class=\"bar-chart\">");
+
+            // 找出最大次数用于计算柱状图宽度
+            int maxCount = report.getWrongKnowledgePoints().stream().mapToInt(WeakPointVO::getCount).max().orElse(1);
+
+            for (WeakPointVO wp : report.getWrongKnowledgePoints()) {
+                double barWidth = (wp.getCount() * 100.0 / maxCount);
+                html.append("<div class=\"bar-item\">");
+                html.append("<table class=\"bar-label-table\"><tr>");
+                html.append("<td class=\"bar-label\">").append(escapeHtml(wp.getKnowledgePoint())).append("</td>");
+                html.append("<td class=\"bar-count\" style=\"color: #f44336;\">").append(wp.getCount())
+                        .append(" 次</td>");
+                html.append("</tr></table>");
+                html.append("<div class=\"bar-bg\">");
+                html.append("<div class=\"bar-fill\" style=\"width: ").append(String.format("%.1f", barWidth))
+                        .append("%;\"></div>");
+                html.append("</div>");
+                html.append("</div>");
+            }
+            html.append("</div>");
+        }
+
+        // 薄弱知识点 - 使用标签卡片展示(不显示次数)
+        if (report.getWeakKnowledgePoints() != null && !report.getWeakKnowledgePoints().isEmpty()) {
+            html.append("<h3>薄弱知识点</h3>");
+            html.append("<div class=\"tag-container\">");
+            for (WeakPointVO wp : report.getWeakKnowledgePoints()) {
+                html.append("<span class=\"tag\">");
+                html.append(escapeHtml(wp.getKnowledgePoint()));
+                html.append("</span>");
+            }
+            html.append("</div>");
+        }
+
+        // ==================== 第二部分:原笔记再现 ====================
+        html.append("<div class=\"page-break\"></div>");
+        html.append("<h2>第二部分:笔记内容(知识点标注)</h2>");
+
+        // 图例说明 - 使用table布局
+        html.append("<table class=\"legend-table\"><tr>");
+        html.append("<td><span class=\"legend-color legend-color-wrong\"></span>错误知识点</td>");
+        html.append("<td><span class=\"legend-color legend-color-weak\"></span>薄弱知识点</td>");
+        html.append("<td><span class=\"legend-color legend-color-correct\"></span>已掌握知识点</td>");
+        html.append("<td><span class=\"legend-color legend-color-unreviewed\"></span>未复习知识点</td>");
+        html.append("</tr></table>");
+
+        // 按Block分组知识点
+        Map<Long, List<KnowledgePoint>> kpByBlockId = allKnowledgePoints.stream()
+                .collect(Collectors.groupingBy(KnowledgePoint::getBlockId));
+
+        for (Block block : blocks) {
+            String content = block.getContent();
+            if (content == null || content.trim().isEmpty()) {
+                continue;
+            }
+
+            html.append("<div class=\"block-content\">");
+            // 获取该Block的知识点并渲染高亮
+            List<KnowledgePoint> blockKps = kpByBlockId.getOrDefault(block.getId(), Collections.emptyList());
+            String highlightedContent = highlightKnowledgePointsInMarkdown(content, blockKps, wrongKpIds, weakKpIds,
+                    correctKpIds, reviewedKpIds);
+            html.append(highlightedContent);
+            html.append("</div>");
+        }
+
+        // ==================== 第三部分:题目展示 ====================
+        html.append("<div class=\"page-break\"></div>");
+        html.append("<h2>第三部分:题目汇总</h2>");
+        html.append("<p style=\"color: #666; margin-bottom: 20px;\">共 <b>").append(allQuestions.size())
+                .append("</b> 道题目</p>");
+
+        // 按状态分组展示题目
+        Map<QuestionStatus, List<Question>> questionsByStatus = allQuestions.stream()
+                .collect(Collectors.groupingBy(Question::getStatus));
+
+        // 错误题目
+        List<Question> wrongQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WRONG,
+                Collections.emptyList());
+        if (!wrongQuestions.isEmpty()) {
+            html.append("<h3>错误题目 (").append(wrongQuestions.size()).append("题)</h3>");
+            for (Question q : wrongQuestions) {
+                html.append(renderQuestionCard(q, "wrong", "错误"));
+            }
+        }
+
+        // 薄弱题目
+        List<Question> weakQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WEAK,
+                Collections.emptyList());
+        if (!weakQuestions.isEmpty()) {
+            html.append("<h3>薄弱题目 (").append(weakQuestions.size()).append("题)</h3>");
+            for (Question q : weakQuestions) {
+                html.append(renderQuestionCard(q, "weak", "薄弱"));
+            }
+        }
+
+        // 正确题目
+        List<Question> correctQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_RIGHT,
+                Collections.emptyList());
+        if (!correctQuestions.isEmpty()) {
+            html.append("<h3>已掌握题目 (").append(correctQuestions.size()).append("题)</h3>");
+            for (Question q : correctQuestions) {
+                html.append(renderQuestionCard(q, "correct", "正确"));
+            }
+        }
+
+        // 未作答题目
+        List<Question> activeQuestions = questionsByStatus.getOrDefault(QuestionStatus.ACTIVE, Collections.emptyList());
+        if (!activeQuestions.isEmpty()) {
+            html.append("<h3>未作答题目 (").append(activeQuestions.size()).append("题)</h3>");
+            for (Question q : activeQuestions) {
+                html.append(renderQuestionCard(q, "active", "未作答"));
+            }
+        }
+
+        html.append("</body></html>");
+
+        // 后处理HTML:为代码块添加语言标签
+        String finalHtml = html.toString();
+        // 查找 <code class="language-xxx"> 并添加标签
+        finalHtml = finalHtml.replaceAll("<code class=\"language-([^\"]+)\">",
+                "<code class=\"language-$1\"><div class=\"code-lang\">$1</div>");
+
+        return finalHtml;
+    }
+
+    /**
+     * 在Markdown内容中高亮知识点,然后渲染为HTML
+     * 策略:
+     * 1. 根据 sourceStart 和 sourceEnd 在原始 Markdown 中插入 HTML 注释标记
+     * 2. 渲染 Markdown 为 HTML(注释会被保留)
+     * 3. 找到注释位置,向外扩展到合适的标签边界,插入 span
+     * 4. 删除原始注释标记
+     */
+    private String highlightKnowledgePointsInMarkdown(String content, List<KnowledgePoint> knowledgePoints,
+            Set<Long> wrongKpIds, Set<Long> weakKpIds,
+            Set<Long> correctKpIds, Set<Long> reviewedKpIds) {
+        // 如果没有知识点,直接渲染返回
+        if (knowledgePoints == null || knowledgePoints.isEmpty()) {
+            String processedContent = processLatexFormulas(content);
+            Node document = markdownParser.parse(processedContent);
+            return htmlRenderer.render(document);
+        }
+
+        // 1. 预处理:修正知识点索引(数据库中的索引可能与实际内容不一致)
+        for (KnowledgePoint kp : knowledgePoints) {
+            fixKpIndices(content, kp);
+        }
+
+        // 2. 筛选有效的知识点(必须有 sourceStart 和 sourceEnd)
+        List<KnowledgePoint> validKps = knowledgePoints.stream()
+                .filter(kp -> kp.getSourceStart() != null && kp.getSourceEnd() != null
+                        && kp.getSourceStart() >= 0 && kp.getSourceEnd() > kp.getSourceStart()
+                        && kp.getSourceEnd() <= content.length())
+                .collect(Collectors.toList());
+
+        // 如果没有有效位置信息的知识点,回退到简单匹配
+        if (validKps.isEmpty()) {
+            log.debug("没有有效位置信息的知识点,回退到简单匹配");
+            return highlightKnowledgePointsByTextOnly(content, knowledgePoints, wrongKpIds, weakKpIds, correctKpIds,
+                    reviewedKpIds);
+        }
+
+        // 3. 按 sourceStart 降序排序,从后往前插入标记避免位置偏移
+        validKps.sort((a, b) -> Integer.compare(b.getSourceStart(), a.getSourceStart()));
+
+        // 4. 在原始 Markdown 中插入 HTML 注释标记
+        StringBuilder markedContent = new StringBuilder(content);
+        for (KnowledgePoint kp : validKps) {
+            int start = kp.getSourceStart();
+            int end = kp.getSourceEnd();
+
+            // 确定CSS类
+            String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds);
+
+            // 提取sourceText(用于查找第一个和最后一个正常文字)
+            String sourceText = content.substring(start, end);
+
+            // 检查是否为代码块(以```开头和结尾)
+            if (sourceText.trim().startsWith("```") && sourceText.trim().endsWith("```")) {
+                // 是代码块,使用div包裹,调整背景色
+                String blockClass = cssClass.replace("kp-", "kp-block-");
+                // 插入结束标签(注意添加换行符以确保Markdown解析器正确识别代码块)
+                markedContent.insert(end, "\n\n</div>");
+                // 插入开始标签(注意添加换行符)
+                markedContent.insert(start, "<div class=\"" + blockClass + "\">\n\n");
+                continue;
+            }
+
+            // 找到第一个正常文字的相对位置(跳过Markdown格式标记)
+            int firstNormalCharOffset = findFirstNormalCharOffset(sourceText);
+            // 找到最后一个正常文字之后的相对位置
+            int lastNormalCharOffset = findLastNormalCharOffset(sourceText);
+
+            // 计算实际插入位置
+            int actualStart = start + firstNormalCharOffset;
+            int actualEnd = start + lastNormalCharOffset;
+
+            // 插入结束标记(先插入结束,因为从后往前)
+            String endMarker = "<!--KP_END_" + kp.getId() + "_" + cssClass + "-->";
+            markedContent.insert(actualEnd, endMarker);
+
+            // 插入开始标记
+            String startMarker = "<!--KP_START_" + kp.getId() + "_" + cssClass + "-->";
+            markedContent.insert(actualStart, startMarker);
+
+            log.debug("插入标记: kpId={}, sourceStart={}, sourceEnd={}, actualStart={}, actualEnd={}, cssClass={}",
+                    kp.getId(), start, end, actualStart, actualEnd, cssClass);
+        }
+
+        // 5. 处理数学公式
+        String processedContent = processLatexFormulas(markedContent.toString());
+
+        // 6. 渲染 Markdown 为 HTML
+        Node document = markdownParser.parse(processedContent);
+        String html = htmlRenderer.render(document);
+
+        log.debug("渲染后HTML长度: {}", html.length());
+
+        // 7. 在 HTML 中找到注释标记,向外扩展并插入 span
+        for (KnowledgePoint kp : validKps) {
+            String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds);
+            String startMarker = "<!--KP_START_" + kp.getId() + "_" + cssClass + "-->";
+            String endMarker = "<!--KP_END_" + kp.getId() + "_" + cssClass + "-->";
+
+            int startMarkerPos = html.indexOf(startMarker);
+            int endMarkerPos = html.indexOf(endMarker);
+
+            if (startMarkerPos == -1 || endMarkerPos == -1) {
+                log.warn("未找到标记: kpId={}, startFound={}, endFound={}", kp.getId(), startMarkerPos != -1,
+                        endMarkerPos != -1);
+                // 移除可能存在的单个标记
+                html = html.replace(startMarker, "").replace(endMarker, "");
+                continue;
+            }
+
+            log.debug("找到标记位置: kpId={}, startMarkerPos={}, endMarkerPos={}", kp.getId(), startMarkerPos, endMarkerPos);
+
+            // 计算实际内容的位置(去除标记后)
+            int contentStart = startMarkerPos + startMarker.length();
+            int contentEnd = endMarkerPos;
+
+            // 向外扩展到合适的标签边界
+            // 从开始标记之前开始向前扩展(包括可能在标记前的内联标签)
+            int expandedStart = expandToTagBoundaryStart(html, startMarkerPos);
+            // 从结束标记之后开始向后扩展(包括可能在标记后的内联标签)
+            int expandedEnd = expandToTagBoundaryEnd(html, endMarkerPos + endMarker.length());
+
+            log.debug("扩展位置: expandedStart={}, expandedEnd={}", expandedStart, expandedEnd);
+
+            // 检查是否已在 kp-span 内
+            if (isInsideKpSpan(html.substring(0, startMarkerPos))) {
+                log.debug("已在 kp-span 内,跳过: kpId={}", kp.getId());
+                html = html.replace(startMarker, "").replace(endMarker, "");
+                continue;
+            }
+
+            // 构建新的 HTML
+            // expandedStart 应该在 startMarkerPos 之前(向前扩展到内联标签开始)
+            // expandedEnd 应该在 endMarkerPos + endMarker.length() 之后(向后扩展到内联标签结束)
+            StringBuilder result = new StringBuilder();
+
+            result.append(html.substring(0, expandedStart));
+
+            // 提取需要高亮的内容片段
+            String contentToHighlight = html.substring(expandedStart, startMarkerPos) +
+                    html.substring(contentStart, endMarkerPos) +
+                    html.substring(endMarkerPos + endMarker.length(), expandedEnd);
+
+            // 智能应用高亮(处理块级标签)
+            result.append(applyHighlightToHtml(contentToHighlight, cssClass));
+
+            result.append(html.substring(expandedEnd));
+
+            html = result.toString();
+        }
+
+        return html;
+    }
+
+    /**
+     * 获取知识点对应的CSS类
+     */
+    private String getCssClassForKp(Long kpId, Set<Long> wrongKpIds, Set<Long> weakKpIds, Set<Long> correctKpIds) {
+        if (wrongKpIds.contains(kpId)) {
+            return "kp-wrong";
+        } else if (weakKpIds.contains(kpId)) {
+            return "kp-weak";
+        } else if (correctKpIds.contains(kpId)) {
+            return "kp-correct";
+        } else {
+            return "kp-unreviewed";
+        }
+    }
+
+    /**
+     * 纯文本匹配方式高亮知识点(后备方案)
+     */
+    private String highlightKnowledgePointsByTextOnly(String content, List<KnowledgePoint> knowledgePoints,
+            Set<Long> wrongKpIds, Set<Long> weakKpIds,
+            Set<Long> correctKpIds, Set<Long> reviewedKpIds) {
+        // 处理数学公式
+        String processedContent = processLatexFormulas(content);
+        // 渲染Markdown为HTML
+        Node document = markdownParser.parse(processedContent);
+        String html = htmlRenderer.render(document);
+
+        // 筛选有效的知识点(必须有sourceText)
+        List<KnowledgePoint> validKps = knowledgePoints.stream()
+                .filter(kp -> kp.getSourceText() != null && !kp.getSourceText().isEmpty())
+                .collect(Collectors.toList());
+
+        if (validKps.isEmpty()) {
+            return html;
+        }
+
+        // 按sourceText长度降序排序
+        validKps.sort((a, b) -> Integer.compare(b.getSourceText().length(), a.getSourceText().length()));
+
+        // 对每个知识点进行高亮
+        for (KnowledgePoint kp : validKps) {
+            String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds);
+            html = simpleHighlightInHtml(html, kp.getSourceText(), cssClass);
+        }
+
+        return html;
+    }
+
+    /**
+     * 修正知识点在内容中的索引位置
+     * 尝试解决数据库索引与实际内容不匹配的问题
+     */
+    private void fixKpIndices(String content, KnowledgePoint kp) {
+        if (kp.getSourceText() == null || kp.getSourceText().isEmpty()) {
+            return;
+        }
+
+        Integer start = kp.getSourceStart();
+        Integer end = kp.getSourceEnd();
+        String sourceText = kp.getSourceText();
+
+        // 1. 检查当前索引是否有效且匹配
+        if (start != null && end != null && start >= 0 && end <= content.length() && start < end) {
+            String extracted = content.substring(start, end);
+            if (extracted.equals(sourceText)) {
+                return; // 索引正确,无需修正
+            }
+        }
+
+        // 2. 尝试精确查找
+        int index = content.indexOf(sourceText);
+        if (index != -1) {
+            kp.setSourceStart(index);
+            kp.setSourceEnd(index + sourceText.length());
+            log.debug("修正KP索引(精确匹配): id={}, old={}-{}, new={}-{}", kp.getId(), start, end, index,
+                    index + sourceText.length());
+            return;
+        }
+
+        // 3. 尝试模糊查找(忽略空白字符)
+        int[] fuzzyIndices = findFuzzyMatch(content, sourceText);
+        if (fuzzyIndices != null) {
+            kp.setSourceStart(fuzzyIndices[0]);
+            kp.setSourceEnd(fuzzyIndices[1]);
+            log.debug("修正KP索引(模糊匹配): id={}, old={}-{}, new={}-{}", kp.getId(), start, end, fuzzyIndices[0],
+                    fuzzyIndices[1]);
+        } else {
+            log.warn("无法修正KP索引: id={}, sourceText='{}'", kp.getId(), sourceText);
+        }
+    }
+
+    /**
+     * 在内容中模糊查找文本(忽略空白字符)
+     *
+     * @return [start, end] 或 null
+     */
+    private int[] findFuzzyMatch(String content, String sourceText) {
+        // 移除sourceText中的所有空白字符
+        String cleanSource = sourceText.replaceAll("\\s+", "");
+        if (cleanSource.isEmpty())
+            return null;
+
+        // 构建content的非空白字符映射
+        // contentChars: 存储非空白字符
+        // contentIndices: 存储这些字符在原content中的索引
+        List<Character> contentChars = new ArrayList<>();
+        List<Integer> contentIndices = new ArrayList<>();
+
+        for (int i = 0; i < content.length(); i++) {
+            char c = content.charAt(i);
+            if (!Character.isWhitespace(c)) {
+                contentChars.add(c);
+                contentIndices.add(i);
+            }
+        }
+
+        // 将content非空白字符转为字符串进行查找
+        StringBuilder sb = new StringBuilder(contentChars.size());
+        for (Character c : contentChars)
+            sb.append(c);
+        String cleanContent = sb.toString();
+
+        int matchIndex = cleanContent.indexOf(cleanSource);
+        if (matchIndex != -1) {
+            // 映射回原始索引
+            int startOriginalIndex = contentIndices.get(matchIndex);
+            // 结束索引是匹配的最后一个字符的索引 + 1
+            int endMatchIndex = matchIndex + cleanSource.length() - 1;
+            int endOriginalIndex = contentIndices.get(endMatchIndex) + 1;
+
+            return new int[] { startOriginalIndex, endOriginalIndex };
+        }
+
+        return null;
+    }
+
+    /**
+     * 找到sourceText中第一个正常文字的偏移量(跳过Markdown格式标记)
+     * 特别处理:如果内容以公式开头,在整个公式之前插入标记(避免破坏公式渲染)
+     */
+    private int findFirstNormalCharOffset(String sourceText) {
+        if (sourceText == null || sourceText.isEmpty()) {
+            return 0;
+        }
+
+        int i = 0;
+        int len = sourceText.length();
+
+        while (i < len) {
+            char c = sourceText.charAt(i);
+
+            // 跳过Markdown格式标记
+            if (c == '*' || c == '_' || c == '`' || c == '~' || c == '#') {
+                i++;
+                continue;
+            }
+
+            // 跳过列表标记(- 、+ 、* 后跟空格)
+            if ((c == '-' || c == '+') && i + 1 < len && sourceText.charAt(i + 1) == ' ') {
+                i += 2;
+                continue;
+            }
+
+            // 跳过数字列表标记(1. 、2. 等)
+            if (Character.isDigit(c)) {
+                int j = i;
+                while (j < len && Character.isDigit(sourceText.charAt(j))) {
+                    j++;
+                }
+                if (j < len && sourceText.charAt(j) == '.' && j + 1 < len && sourceText.charAt(j + 1) == ' ') {
+                    i = j + 2;
+                    continue;
+                }
+            }
+
+            // 跳过空白字符
+            if (Character.isWhitespace(c)) {
+                i++;
+                continue;
+            }
+
+            // 注意:不再跳过方括号和小括号,因为它们可能是公式的一部分
+            // 如果是Markdown链接 [text](url),标记加在 [ 前面也是可以的
+
+            // 特别处理:如果遇到公式开始标记,检查是否是完整公式
+            if (c == '$') {
+                // 检查是否是双美元符号(块级公式)
+                if (i + 1 < len && sourceText.charAt(i + 1) == '$') {
+                    // 块级公式 $$...$$,在公式前插入标记
+                    return i;
+                } else {
+                    // 行内公式 $...$,也在公式前插入标记
+                    return i;
+                }
+            }
+
+            // 找到第一个正常字符
+            break;
+        }
+
+        return i;
+    }
+
+    /**
+     * 找到sourceText中最后一个正常文字之后的偏移量(跳过Markdown格式标记)
+     * 特别处理:如果内容以公式结尾,在整个公式之后插入标记(避免破坏公式渲染)
+     */
+    private int findLastNormalCharOffset(String sourceText) {
+        if (sourceText == null || sourceText.isEmpty()) {
+            return 0;
+        }
+
+        int i = sourceText.length() - 1;
+
+        // 先跳过尾部的空白字符和格式标记
+        while (i >= 0) {
+            char c = sourceText.charAt(i);
+
+            // 跳过Markdown格式标记
+            if (c == '*' || c == '_' || c == '`' || c == '~') {
+                i--;
+                continue;
+            }
+
+            // 跳过空白字符
+            if (Character.isWhitespace(c)) {
+                i--;
+                continue;
+            }
+
+            // 注意:不再跳过方括号和小括号,因为它们可能是公式的一部分
+
+            // 特别处理:如果遇到公式结束标记
+            if (c == '$') {
+                // 检查是否是双美元符号(块级公式)
+                if (i > 0 && sourceText.charAt(i - 1) == '$') {
+                    // 块级公式 $$...$$,在公式后插入标记(已经在 $ 之后了)
+                    return i + 1;
+                } else {
+                    // 行内公式 $...$,在公式后插入标记
+                    return i + 1;
+                }
+            }
+
+            // 找到最后一个正常字符,返回其后的位置
+            return i + 1;
+        }
+
+        return sourceText.length();
+    }
+
+    /**
+     * 检查是否是内联标签(可以被span包裹的标签)
+     */
+    private boolean isInlineTag(String tag) {
+        // 提取标签名(去除 < > 和属性)
+        String tagLower = tag.toLowerCase();
+        // 移除开头的 </ 或 <
+        String tagName;
+        if (tagLower.startsWith("</")) {
+            tagName = tagLower.substring(2);
+        } else {
+            tagName = tagLower.substring(1);
+        }
+        // 移除 > 和属性
+        int spaceIdx = tagName.indexOf(' ');
+        int gtIdx = tagName.indexOf('>');
+        int endIdx = Math.min(
+                spaceIdx == -1 ? tagName.length() : spaceIdx,
+                gtIdx == -1 ? tagName.length() : gtIdx);
+        tagName = tagName.substring(0, endIdx);
+
+        // 内联标签列表
+        return tagName.equals("strong") || tagName.equals("b") ||
+                tagName.equals("em") || tagName.equals("i") ||
+                tagName.equals("code") || tagName.equals("mark") ||
+                tagName.equals("small") || tagName.equals("sub") ||
+                tagName.equals("sup") || tagName.equals("a") ||
+                tagName.equals("span") || tagName.equals("img") ||
+                tagName.equals("del") || tagName.equals("ins") ||
+                tagName.equals("u") || tagName.equals("s");
+    }
+
+    /**
+     * 向前扩展到合适的标签边界
+     * 目标:找到一个位置,使得 span 标签可以正确包裹内部的内联标签
+     * 只扩展内联标签(如 strong, em),不扩展块级标签(如 li, p, div)
+     */
+    private int expandToTagBoundaryStart(String html, int pos) {
+        int i = pos;
+
+        // 向前查找,跳过所有紧邻的内联开始标签和HTML注释
+        while (i > 0) {
+            // 跳过HTML注释
+            if (i >= 4 && html.substring(Math.max(0, i - 4), i).equals("-->")) {
+                // 前面可能有HTML注释结束,查找注释开始
+                int commentStart = html.lastIndexOf("<!--", i - 4);
+                if (commentStart != -1) {
+                    i = commentStart;
+                    log.debug("expandStart: 跳过HTML注释,继续向前到 {}", i);
+                    continue;
+                }
+            }
+
+            // 检查前面是否有标签
+            int tagEnd = i - 1;
+            if (tagEnd >= 0 && html.charAt(tagEnd) == '>') {
+                // 前面有标签结束符,查找这个标签的开始
+                int tagStart = html.lastIndexOf('<', tagEnd);
+                if (tagStart != -1) {
+                    String tag = html.substring(tagStart, tagEnd + 1);
+
+                    // 跳过HTML注释
+                    if (tag.startsWith("<!--")) {
+                        i = tagStart;
+                        log.debug("expandStart: 跳过HTML注释标签,继续向前到 {}", i);
+                        continue;
+                    }
+
+                    log.debug("expandStart: 发现标签 '{}' at {}-{}", tag, tagStart, tagEnd);
+
+                    // 检查是否是开始标签(不是结束标签,也不是自闭合标签)
+                    if (!tag.startsWith("</") && !tag.endsWith("/>")) {
+                        // 检查是否是内联标签
+                        if (isInlineTag(tag)) {
+                            // 这是一个内联开始标签,我们应该在它之前插入 span
+                            i = tagStart;
+                            log.debug("expandStart: 是内联开始标签,继续向前到 {}", i);
+                            continue;
+                        } else {
+                            // 这是块级开始标签(如 li, p),不应该跨越它
+                            log.debug("expandStart: 是块级开始标签,停在 {}", i);
+                            break;
+                        }
+                    } else {
+                        // 这是结束标签或自闭合标签,停在这里(标签之后)
+                        log.debug("expandStart: 是结束/自闭合标签,停在 {}", i);
+                        break;
+                    }
+                }
+            }
+            // 前面不是标签,停在当前位置
+            break;
+        }
+
+        log.debug("expandToTagBoundaryStart: {} -> {}", pos, i);
+        return i;
+    }
+
+    /**
+     * 向后扩展到合适的标签边界
+     * 目标:找到一个位置,使得 span 标签可以正确包裹内部的内联标签
+     * 只扩展内联标签(如 /strong, /em),不扩展块级标签(如 /li, /p)
+     */
+    private int expandToTagBoundaryEnd(String html, int pos) {
+        int i = pos;
+
+        // 向后查找,跳过所有紧邻的内联结束标签和HTML注释
+        while (i < html.length()) {
+            // 跳过HTML注释
+            if (i + 4 <= html.length() && html.substring(i, Math.min(i + 4, html.length())).equals("<!--")) {
+                // 后面有HTML注释开始,查找注释结束
+                int commentEnd = html.indexOf("-->", i);
+                if (commentEnd != -1) {
+                    i = commentEnd + 3; // 跳过 -->
+                    log.debug("expandEnd: 跳过HTML注释,继续向后到 {}", i);
+                    continue;
+                }
+            }
+
+            // 检查后面是否有标签
+            if (i < html.length() && html.charAt(i) == '<') {
+                // 后面有标签开始符,查找这个标签的结束
+                int tagEnd = html.indexOf('>', i);
+                if (tagEnd != -1) {
+                    String tag = html.substring(i, tagEnd + 1);
+
+                    // 跳过HTML注释
+                    if (tag.startsWith("<!--")) {
+                        i = tagEnd + 1;
+                        log.debug("expandEnd: 跳过HTML注释标签,继续向后到 {}", i);
+                        continue;
+                    }
+
+                    log.debug("expandEnd: 发现标签 '{}' at {}-{}", tag, i, tagEnd);
+
+                    // 检查是否是结束标签
+                    if (tag.startsWith("</")) {
+                        // 检查是否是内联标签
+                        if (isInlineTag(tag)) {
+                            // 这是一个内联结束标签,我们应该在它之后插入 span 结束
+                            i = tagEnd + 1;
+                            log.debug("expandEnd: 是内联结束标签,继续向后到 {}", i);
+                            continue;
+                        } else {
+                            // 这是块级结束标签(如 /li, /p),不应该跨越它
+                            log.debug("expandEnd: 是块级结束标签,停在 {}", i);
+                            break;
+                        }
+                    } else {
+                        // 这是开始标签或自闭合标签,停在这里(标签之前)
+                        log.debug("expandEnd: 是开始/自闭合标签,停在 {}", i);
+                        break;
+                    }
+                }
+            }
+            // 后面不是标签,停在当前位置
+            break;
+        }
+
+        log.debug("expandToTagBoundaryEnd: {} -> {}", pos, i);
+        return i;
+    }
+
+    /**
+     * 检查当前位置是否已经在kp-span内
+     */
+    private boolean isInsideKpSpan(String htmlBefore) {
+        int lastKpStart = htmlBefore.lastIndexOf("<span class=\"kp-");
+        int lastSpanEnd = htmlBefore.lastIndexOf("</span>");
+        return lastKpStart > lastSpanEnd && lastKpStart != -1;
+    }
+
+    /**
+     * 简单的HTML高亮方式(回退方案)
+     */
+    private String simpleHighlightInHtml(String html, String sourceText, String cssClass) {
+        // 提取sourceText中的关键文本片段进行高亮
+        List<String> segments = extractTextSegments(sourceText);
+
+        String result = html;
+        for (String segment : segments) {
+            if (segment.length() >= 2) {
+                result = smartHighlightInHtml(result, segment, cssClass);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 从知识点文本中提取多个文本片段(按标点和格式分割)
+     * 注意:sourceText应该已经是不含公式的纯文本,这里做二次清理以确保安全
+     */
+    private List<String> extractTextSegments(String kpText) {
+        List<String> segments = new ArrayList<>();
+
+        // 先清理Markdown格式标记
+        String cleaned = kpText;
+
+        // 二次清理:移除任何残留的公式标记(理论上sourceText应该已经不包含公式)
+        cleaned = cleaned.replaceAll("\\$\\$[^$]*\\$\\$", " ");
+        cleaned = cleaned.replaceAll("\\$[^$]*\\$", " ");
+        cleaned = cleaned.replaceAll("\\\\\\[[^\\]]*\\\\\\]", " ");
+        cleaned = cleaned.replaceAll("\\\\\\([^)]*\\\\\\)", " ");
+
+        // 提取粗体内容作为单独片段(不包含公式)
+        java.util.regex.Pattern boldPattern = java.util.regex.Pattern.compile("\\*\\*([^*]+)\\*\\*");
+        java.util.regex.Matcher boldMatcher = boldPattern.matcher(cleaned);
+        while (boldMatcher.find()) {
+            String boldText = boldMatcher.group(1).trim();
+            // 再次检查,确保不包含公式标记
+            if (!boldText.isEmpty() && !containsFormulaMarkers(boldText)) {
+                segments.add(boldText);
+            }
+        }
+
+        // 移除Markdown格式标记
+        cleaned = cleaned.replaceAll("\\*\\*([^*]+)\\*\\*", "$1");
+        cleaned = cleaned.replaceAll("\\*([^*]+)\\*", "$1");
+        cleaned = cleaned.replaceAll("__([^_]+)__", "$1");
+        cleaned = cleaned.replaceAll("_([^_]+)_", "$1");
+        cleaned = cleaned.replaceAll("`([^`]+)`", "$1");
+
+        // 移除列表标记
+        cleaned = cleaned.replaceAll("^[*\\-+]\\s+", "");
+        cleaned = cleaned.replaceAll("\\n[*\\-+]\\s+", "\n");
+
+        // 按标点符号分割成片段
+        String[] parts = cleaned.split("[,。、:;!?\\n\\r]+");
+        for (String part : parts) {
+            String trimmed = part.trim();
+            // 只添加有意义的片段(至少2个字符,不重复,不包含公式)
+            if (trimmed.length() >= 2 && !segments.contains(trimmed) && !containsFormulaMarkers(trimmed)) {
+                segments.add(trimmed);
+            }
+        }
+
+        return segments;
+    }
+
+    /**
+     * 智能地在HTML中高亮文本,保持HTML标签不被破坏
+     * 支持跨越HTML标签的文本匹配(如 <strong>机器学习</strong>:让模型...)
+     */
+    private String smartHighlightInHtml(String html, String searchText, String cssClass) {
+        if (searchText == null || searchText.isEmpty()) {
+            return html;
+        }
+
+        // 检查是否已经被精确高亮过(完整的span标签包裹searchText)
+        if (html.contains("<span class=\"" + cssClass + "\">" + searchText + "</span>")) {
+            return html;
+        }
+
+        // 尝试直接匹配(文本不跨越标签的情况)
+        int idx = findTextOutsideTags(html, searchText);
+        if (idx >= 0) {
+            // 检查这个位置是否已经在kp-span内
+            String beforeText = html.substring(0, idx);
+            int lastKpStart = beforeText.lastIndexOf("<span class=\"kp-");
+            int lastSpanEnd = beforeText.lastIndexOf("</span>");
+            if (lastKpStart > lastSpanEnd && lastKpStart != -1) {
+                // 已经在kp-span内,跳过
+                return html;
+            }
+
+            String before = html.substring(0, idx);
+            String after = html.substring(idx + searchText.length());
+            return before + "<span class=\"" + cssClass + "\">" + searchText + "</span>" + after;
+        }
+
+        // 如果直接匹配失败,尝试在HTML标签内部查找(如<strong>文本</strong>中的"文本")
+        // 使用正则查找被标签包裹的文本
+        String escapedSearch = Pattern.quote(searchText);
+
+        // 匹配 >searchText< 模式(文本在标签之间)
+        Pattern pattern = Pattern.compile("(>)" + escapedSearch + "(<)");
+        java.util.regex.Matcher matcher = pattern.matcher(html);
+        if (matcher.find()) {
+            // 检查是否已在kp-span内
+            String beforeMatch = html.substring(0, matcher.start());
+            int lastKpStart = beforeMatch.lastIndexOf("<span class=\"kp-");
+            int lastSpanEnd = beforeMatch.lastIndexOf("</span>");
+            if (lastKpStart <= lastSpanEnd || lastKpStart == -1) {
+                return html.substring(0, matcher.start()) +
+                        ">" + "<span class=\"" + cssClass + "\">" + searchText + "</span>" + "<" +
+                        html.substring(matcher.end());
+            }
+        }
+
+        return html;
+    }
+
+    /**
+     * 在HTML中查找文本,确保不在标签属性内
+     */
+    private int findTextOutsideTags(String html, String searchText) {
+        int searchLen = searchText.length();
+        int htmlLen = html.length();
+
+        boolean inTag = false;
+        int i = 0;
+        while (i <= htmlLen - searchLen) {
+            char c = html.charAt(i);
+
+            if (c == '<') {
+                inTag = true;
+                i++;
+                continue;
+            }
+            if (c == '>') {
+                inTag = false;
+                i++;
+                continue;
+            }
+
+            if (!inTag) {
+                // 检查从这个位置开始是否匹配
+                if (html.regionMatches(i, searchText, 0, searchLen)) {
+                    return i;
+                }
+            }
+            i++;
+        }
+        return -1;
+    }
+
+    /**
+     * 检测文本是否包含公式标记
+     */
+    private boolean containsFormulaMarkers(String text) {
+        if (text == null || text.isEmpty()) {
+            return false;
+        }
+        // 检查各种公式标记
+        return text.contains("$$") ||
+                text.contains("\\[") ||
+                text.contains("\\]") ||
+                text.contains("\\(") ||
+                text.contains("\\)") ||
+                (text.contains("$") && text.indexOf("$") != text.lastIndexOf("$"));
+    }
+
+    /**
+     * 渲染题目卡片 - 美观设计版本(兼容iText PDF)
+     */
+    private String renderQuestionCard(Question question, String statusClass, String statusLabel) {
+        StringBuilder card = new StringBuilder();
+        card.append("<div class=\"question-card ").append(statusClass).append("\">");
+
+        // 题目头部:题目内容 + 状态标签 - 使用table布局
+        card.append("<div class=\"question-header\">");
+        card.append("<table class=\"question-header-table\"><tr>");
+        card.append("<td class=\"question-text\">");
+        // 渲染题目文本的Markdown(包含公式解析)
+        String questionHtml = renderMarkdownInline(question.getQuestionText());
+        card.append(questionHtml);
+        card.append("</td>");
+        card.append("<td style=\"width: 80px; text-align: right; vertical-align: top;\">");
+        card.append("<span class=\"question-badge badge-").append(statusClass).append("\">").append(statusLabel)
+                .append("</span>");
+        card.append("</td>");
+        card.append("</tr></table>");
+        card.append("</div>");
+
+        // 题目类型
+        String typeLabel;
+        switch (question.getType()) {
+            case SINGLE:
+                typeLabel = "单选题";
+                break;
+            case MULTIPLE:
+                typeLabel = "多选题";
+                break;
+            case ESSAY:
+                typeLabel = "问答题";
+                break;
+            default:
+                typeLabel = "未知";
+        }
+        card.append("<span class=\"question-type\">").append(typeLabel).append("</span>");
+
+        // 题目来源
+        String sourceLabel;
+        if (question.getQuestionSource() == com.smartreview.enums.QuestionSource.REVIEW) {
+            sourceLabel = "复习题";
+        } else if (question.getQuestionSource() == com.smartreview.enums.QuestionSource.SELECTION_GENERATED) {
+            sourceLabel = "划词提问";
+        } else {
+            sourceLabel = "知识点";
+        }
+        card.append("<span class=\"question-type\" style=\"margin-left: 10px; background: #e3f2fd; color: #1565c0;\">")
+                .append(sourceLabel).append("</span>");
+
+        // 选项(如果有)- 也要解析公式
+        if (question.getOptions() != null && !question.getOptions().isEmpty()) {
+            card.append("<div class=\"options\">");
+            char optionLabel = 'A';
+            for (String option : question.getOptions()) {
+                String trimmedOption = option.trim();
+                // 检查选项是否已经以A. B. C. D.等开头
+                boolean hasPrefix = trimmedOption.length() >= 2 &&
+                        Character.isLetter(trimmedOption.charAt(0)) &&
+                        (trimmedOption.charAt(1) == '.' || trimmedOption.charAt(1) == '、' ||
+                                trimmedOption.charAt(1) == ')' || trimmedOption.charAt(1) == ')');
+
+                // 处理选项中的公式和Markdown格式
+                String processedOption = renderMarkdownInline(option);
+                // 移除Markdown生成的外层p标签(因为我们自己添加了p标签)
+                processedOption = processedOption.replaceAll("^<p>", "").replaceAll("</p>\\s*$", "");
+
+                if (hasPrefix) {
+                    // 已有前缀,直接显示
+                    card.append("<p>").append(processedOption).append("</p>");
+                } else {
+                    // 没有前缀,添加标签
+                    card.append("<p>").append(optionLabel).append(". ").append(processedOption).append("</p>");
+                }
+                optionLabel++;
+            }
+            card.append("</div>");
+        }
+
+        // 答案区域
+        card.append("<div class=\"answer-section\">");
+
+        // 用户答案(如果是错误或薄弱题目)
+        if (statusClass.equals("wrong") || statusClass.equals("weak")) {
+            userAnswerRepository.findTopByQuestionIdOrderByAnsweredAtDesc(question.getId())
+                    .ifPresent(answer -> {
+                        String processedUserAnswer = processLatexFormulas(answer.getUserAnswer());
+                        card.append("<p><span class=\"user-answer\">你的答案:</span>")
+                                .append(processedUserAnswer).append("</p>");
+                    });
+        }
+
+        // 正确答案(也要处理公式)
+        String processedCorrectAnswer = processLatexFormulas(question.getCorrectAnswer());
+        card.append("<p><span class=\"correct-answer\">正确答案:</span>").append(processedCorrectAnswer).append("</p>");
+        card.append("</div>");
+
+        // 解析
+        if (question.getExplanation() != null && !question.getExplanation().isEmpty()) {
+            card.append("<div class=\"explanation\">");
+            card.append("<div class=\"explanation-title\">解析</div>");
+            // 渲染解析中的Markdown(包含公式解析)
+            String explanationHtml = renderMarkdownInline(question.getExplanation());
+            card.append(explanationHtml);
+            card.append("</div>");
+        }
+
+        card.append("</div>");
+        return card.toString();
+    }
+
+    private static final Set<String> STRICT_TAGS = new HashSet<>(Arrays.asList(
+            "ul", "ol", "dl", "table", "thead", "tbody", "tfoot", "tr", "colgroup"));
+
+    private static final Set<String> ITEM_TAGS = new HashSet<>(Arrays.asList(
+            "li", "dt", "dd", "td", "th", "caption"));
+
+    private static final Set<String> FLOW_TAGS = new HashSet<>(Arrays.asList(
+            "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote",
+            "form", "section", "header", "footer", "article", "aside", "main",
+            "nav", "figure", "figcaption", "hr"));
+
+    /**
+     * 智能应用高亮到HTML内容,处理块级标签
+     * 避免将块级标签(如ul, div)包裹在span中,而是将span推入到内容层
+     */
+    private String applyHighlightToHtml(String html, String cssClass) {
+        if (html == null || html.isEmpty()) {
+            return "";
+        }
+
+        StringBuilder sb = new StringBuilder();
+        // 初始状态:开启高亮
+        boolean inSpan = true;
+        sb.append("<span class=\"").append(cssClass).append("\">");
+
+        // 正则匹配HTML标签:</?tagName...>
+        // 捕获组1:是否为结束标签 /
+        // 捕获组2:标签名
+        Pattern tagPattern = Pattern.compile("<(/)?([a-zA-Z0-9]+)[^>]*>", Pattern.CASE_INSENSITIVE);
+        java.util.regex.Matcher matcher = tagPattern.matcher(html);
+
+        int lastEnd = 0;
+        while (matcher.find()) {
+            // 添加标签前的文本
+            String text = html.substring(lastEnd, matcher.start());
+            sb.append(text);
+
+            String isEndTag = matcher.group(1); // "/" or null
+            String tagName = matcher.group(2).toLowerCase();
+            String fullTag = matcher.group(0);
+
+            boolean isStartTag = (isEndTag == null);
+
+            if (STRICT_TAGS.contains(tagName)) {
+                // 严格容器(ul, table等):不能包含文本,必须关闭span
+                if (isStartTag) {
+                    // 开始标签:关闭当前span
+                    if (inSpan) {
+                        sb.append("</span>");
+                        inSpan = false;
+                    }
+                    sb.append(fullTag);
+                } else {
+                    // 结束标签:重新开启span(如果需要)
+                    sb.append(fullTag);
+                    if (!inSpan) {
+                        sb.append("<span class=\"").append(cssClass).append("\">");
+                        inSpan = true;
+                    }
+                }
+            } else if (ITEM_TAGS.contains(tagName)) {
+                // 项目容器(li, td等):内容需要高亮
+                if (isStartTag) {
+                    // 开始标签:先关闭(以防万一),添加标签,然后开启
+                    if (inSpan) {
+                        sb.append("</span>");
+                    }
+                    sb.append(fullTag);
+                    sb.append("<span class=\"").append(cssClass).append("\">");
+                    inSpan = true;
+                } else {
+                    // 结束标签:关闭当前span
+                    if (inSpan) {
+                        sb.append("</span>");
+                        inSpan = false;
+                    }
+                    sb.append(fullTag);
+                    // 结束后通常紧跟空白或下一个项目开始,暂时不开启,由下一个显式内容或父级结束来处理
+                    // 但为了保持连续性(如li之间的空白),我们可以选择开启
+                    // 这里选择开启,以便捕获标签间的空白(虽然通常不可见)
+                    // 或者更安全地:不开启,等待下一个显式内容?
+                    // 根据之前的分析:</li>后通常是<li>或</ul>。
+                    // 如果是<li>,它会处理开启。如果是</ul>,它会处理开启。
+                    // 所以这里不开启是安全的,除了丢失空白的高亮(无所谓)。
+                    // 但是!如果</li>后面有文本节点(不规范HTML),则不会高亮。
+                    // 为了稳健性,我们还是开启吧,反正空白高亮看不见。
+                    sb.append("<span class=\"").append(cssClass).append("\">");
+                    inSpan = true;
+                }
+            } else if (FLOW_TAGS.contains(tagName)) {
+                // 流式容器(p, div等):内容需要高亮
+                if (isStartTag) {
+                    // 开始标签:先关闭,添加标签,然后开启
+                    if (inSpan) {
+                        sb.append("</span>");
+                    }
+                    sb.append(fullTag);
+                    // 对于自闭合标签如 <hr>,不需要开启
+                    if (!fullTag.endsWith("/>") && !tagName.equals("hr")) {
+                        sb.append("<span class=\"").append(cssClass).append("\">");
+                        inSpan = true;
+                    } else {
+                        // 自闭合标签后,恢复开启状态(因为我们假设整个段落都要高亮)
+                        sb.append("<span class=\"").append(cssClass).append("\">");
+                        inSpan = true;
+                    }
+                } else {
+                    // 结束标签:关闭当前span,添加标签,然后重新开启
+                    if (inSpan) {
+                        sb.append("</span>");
+                    }
+                    sb.append(fullTag);
+                    sb.append("<span class=\"").append(cssClass).append("\">");
+                    inSpan = true;
+                }
+            } else {
+                // 内联标签或其他:直接添加,保持span状态
+                sb.append(fullTag);
+            }
+
+            lastEnd = matcher.end();
+        }
+
+        // 添加剩余文本
+        sb.append(html.substring(lastEnd));
+
+        // 结束时关闭span
+        if (inSpan) {
+            sb.append("</span>");
+        }
+
+        // 清理空span
+        String result = sb.toString();
+        String emptySpan = "<span class=\"" + cssClass + "\"></span>";
+        // 循环替换直到没有空span(处理嵌套产生的空span)
+        while (result.contains(emptySpan)) {
+            result = result.replace(emptySpan, "");
+        }
+
+        return result;
+    }
+
+    /**
+     * 渲染内联Markdown(用于题目和解析),同时处理数学公式
+     */
+    private String renderMarkdownInline(String text) {
+        if (text == null || text.isEmpty()) {
+            return "";
+        }
+        // 先处理数学公式,将其转换为HTML样式显示
+        String processedText = processLatexFormulas(text);
+        Node document = markdownParser.parse(processedText);
+        return htmlRenderer.render(document);
+    }
+
+    /**
+     * 处理LaTeX数学公式,将公式渲染为图片(Base64)嵌入HTML
+     * 支持多种格式:$$...$$ 块级公式,$...$ 行内公式,\[...\] 块级,\(...\) 行内
+     */
+    private String processLatexFormulas(String text) {
+        if (text == null) {
+            return "";
+        }
+
+        String result = text;
+
+        // 1. 处理块级公式 \[...\]
+        result = processFormulaPatternToImage(result, "\\[", "\\]", true);
+
+        // 2. 处理块级公式 $$...$$
+        result = processFormulaPatternToImage(result, "$$", "$$", true);
+
+        // 3. 处理行内公式 \(...\)
+        result = processFormulaPatternToImage(result, "\\(", "\\)", false);
+
+        // 4. 处理行内公式 $...$ (需要特别处理避免误匹配$$)
+        result = processDollarFormulasToImage(result);
+
+        return result;
+    }
+
+    /**
+     * 通用公式模式处理 - 渲染为图片
+     */
+    private String processFormulaPatternToImage(String text, String startDelim, String endDelim, boolean isBlock) {
+        StringBuilder result = new StringBuilder();
+        int i = 0;
+        while (i < text.length()) {
+            int start = text.indexOf(startDelim, i);
+            if (start == -1) {
+                result.append(text.substring(i));
+                break;
+            }
+
+            result.append(text, i, start);
+
+            int end = text.indexOf(endDelim, start + startDelim.length());
+            if (end == -1) {
+                result.append(text.substring(start));
+                break;
+            }
+
+            String formula = text.substring(start + startDelim.length(), end);
+            String renderedFormula = renderLatexToImage(formula.trim(), isBlock);
+            result.append(renderedFormula);
+
+            i = end + endDelim.length();
+        }
+        return result.toString();
+    }
+
+    /**
+     * 专门处理 $...$ 行内公式(避免误匹配 $$)- 渲染为图片
+     * 更严格的匹配规则:
+     * 1. $ 前后不能紧邻字母数字(避免误匹配如 $10 价格)
+     * 2. 公式内容必须包含LaTeX特征字符(如 \、^、_、{、})或是纯数学表达式
+     */
+    private String processDollarFormulasToImage(String text) {
+        StringBuilder result = new StringBuilder();
+        int i = 0;
+        while (i < text.length()) {
+            int inlineStart = text.indexOf("$", i);
+            if (inlineStart == -1) {
+                result.append(text.substring(i));
+                break;
+            }
+
+            // 检查是否是 $$ 开头(跳过,因为已处理过或不应处理)
+            if (inlineStart + 1 < text.length() && text.charAt(inlineStart + 1) == '$') {
+                result.append(text, i, inlineStart + 2);
+                i = inlineStart + 2;
+                continue;
+            }
+
+            // 检查 $ 前面是否紧邻字母数字(如果是,可能不是公式)
+            if (inlineStart > 0) {
+                char prevChar = text.charAt(inlineStart - 1);
+                if (Character.isLetterOrDigit(prevChar)) {
+                    result.append(text, i, inlineStart + 1);
+                    i = inlineStart + 1;
+                    continue;
+                }
+            }
+
+            result.append(text, i, inlineStart);
+
+            // 查找配对的 $
+            int inlineEnd = text.indexOf("$", inlineStart + 1);
+            if (inlineEnd == -1) {
+                result.append(text.substring(inlineStart));
+                break;
+            }
+
+            // 检查结束符是否是 $$ 的一部分
+            if (inlineEnd + 1 < text.length() && text.charAt(inlineEnd + 1) == '$') {
+                result.append(text, inlineStart, inlineEnd + 2);
+                i = inlineEnd + 2;
+                continue;
+            }
+
+            String formula = text.substring(inlineStart + 1, inlineEnd);
+            // 公式内容不为空,且看起来像LaTeX公式才处理
+            if (!formula.trim().isEmpty() && looksLikeLatexFormula(formula)) {
+                String renderedFormula = renderLatexToImage(formula.trim(), false);
+                result.append(renderedFormula);
+            } else {
+                // 不像公式,保留原文
+                result.append("$").append(formula).append("$");
+            }
+
+            i = inlineEnd + 1;
+        }
+        return result.toString();
+    }
+
+    /**
+     * 判断内容是否看起来像LaTeX公式
+     * 包含LaTeX特征字符或是数学表达式
+     */
+    private boolean looksLikeLatexFormula(String content) {
+        if (content == null || content.trim().isEmpty()) {
+            return false;
+        }
+        // 包含LaTeX命令(以\开头)
+        if (content.contains("\\")) {
+            return true;
+        }
+        // 包含上下标
+        if (content.contains("^") || content.contains("_")) {
+            return true;
+        }
+        // 包含花括号(LaTeX分组)
+        if (content.contains("{") || content.contains("}")) {
+            return true;
+        }
+        // 包含数学运算符
+        if (content.contains("+") || content.contains("-") || content.contains("=") ||
+                content.contains("*") || content.contains("/") || content.contains("×") ||
+                content.contains("÷") || content.contains("≠") || content.contains("≤") ||
+                content.contains("≥") || content.contains("±")) {
+            return true;
+        }
+        // 包含希腊字母名称
+        if (content.matches(".*\\b(alpha|beta|gamma|delta|theta|pi|sigma|omega|lambda|mu)\\b.*")) {
+            return true;
+        }
+        // 纯数字和简单变量(如 x, y, a, b 等)
+        if (content.matches("^[a-zA-Z0-9\\s.,]+$") && content.length() <= 3) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 将LaTeX公式渲染为Base64图片
+     *
+     * @param latex   LaTeX公式文本
+     * @param isBlock 是否为块级公式(块级字体更大,居中显示)
+     * @return HTML img标签,包含Base64编码的图片
+     */
+    private String renderLatexToImage(String latex, boolean isBlock) {
+        if (latex == null || latex.trim().isEmpty()) {
+            return "";
+        }
+
+        try {
+            // 创建 TeXFormula
+            TeXFormula formula = new TeXFormula(latex);
+
+            // 设置字体大小:块级公式稍大,行内公式与正文匹配
+            float fontSize = isBlock ? 22f : 15f;
+
+            // 创建 TeXIcon
+            TeXIcon icon = formula.createTeXIcon(TeXConstants.STYLE_DISPLAY, fontSize);
+            icon.setInsets(new Insets(2, 2, 2, 2));
+
+            // 创建图片
+            BufferedImage image = new BufferedImage(
+                    icon.getIconWidth(),
+                    icon.getIconHeight(),
+                    BufferedImage.TYPE_INT_ARGB);
+
+            Graphics2D g2 = image.createGraphics();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+            g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+            // 设置白色背景
+            g2.setColor(Color.WHITE);
+            g2.fillRect(0, 0, icon.getIconWidth(), icon.getIconHeight());
+
+            // 绘制公式(黑色)
+            JLabel label = new JLabel();
+            label.setForeground(new Color(51, 51, 51)); // 深灰色 #333
+            icon.paintIcon(label, g2, 0, 0);
+            g2.dispose();
+
+            // 转换为 Base64
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(image, "PNG", baos);
+            String base64 = Base64.getEncoder().encodeToString(baos.toByteArray());
+
+            // 返回HTML img标签
+            if (isBlock) {
+                return "<div style=\"text-align: center; margin: 15px 0;\"><img src=\"data:image/png;base64," + base64
+                        + "\" alt=\"formula\" style=\"max-width: 100%;\"/></div>";
+            } else {
+                // 行内公式:垂直居中,高度自适应,不限制最大高度
+                return "<img src=\"data:image/png;base64," + base64
+                        + "\" alt=\"formula\" style=\"vertical-align: middle; height: auto;\"/>";
+            }
+        } catch (Exception e) {
+            log.warn("LaTeX公式渲染失败: {}, 错误: {}", latex, e.getMessage());
+            // 渲染失败时,返回带样式的源代码显示
+            String escapedLatex = escapeHtmlForFormula(latex);
+            if (isBlock) {
+                return "<div class=\"math\">" + escapedLatex + "</div>";
+            } else {
+                return "<span class=\"inline-math\">" + escapedLatex + "</span>";
+            }
+        }
+    }
+
+    /**
+     * 对公式内容进行HTML转义(保留部分符号的可读性)
+     */
+    private String escapeHtmlForFormula(String formula) {
+        if (formula == null) {
+            return "";
+        }
+        return formula.trim()
+                .replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;");
+    }
+
+    /**
+     * HTML转义
+     */
+    private String escapeHtml(String text) {
+        if (text == null) {
+            return "";
+        }
+        return text.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;")
+                .replace("'", "&#39;")
+                .replace("\n", "<br/>");
+    }
+
+}

+ 459 - 0
src/main/java/com/smartreview/service/impl/ReviewChatServiceImpl.java

@@ -0,0 +1,459 @@
+package com.smartreview.service.impl;
+
+import com.smartreview.enums.ConversationType;
+import com.smartreview.enums.QuestionSource;
+import com.smartreview.enums.QuestionStatus;
+import com.smartreview.enums.QuestionType;
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.*;
+import com.smartreview.repository.*;
+import com.smartreview.service.AIService;
+import com.smartreview.service.ReviewChatService;
+import com.smartreview.vo.chat.*;
+import com.smartreview.vo.question.QuestionVO;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+
+/**
+ * 复习对话服务实现(支持多轮对话)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ReviewChatServiceImpl implements ReviewChatService {
+
+    private final ConversationRepository conversationRepository;
+    private final ReviewChatMessageRepository chatMessageRepository;
+    private final BlockRepository blockRepository;
+    private final DocumentRepository documentRepository;
+    private final KnowledgePointRepository knowledgePointRepository;
+    private final QuestionRepository questionRepository;
+    private final AIService aiService;
+    private final Gson gson = new Gson();
+
+    @Override
+    @Transactional
+    public ConversationVO createConversation(CreateConversationRequestVO request, Long userId) {
+        // 验证文档权限
+        Document document = documentRepository.findById(request.getDocumentId())
+                .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
+        if (!document.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此文档");
+        }
+
+        // 创建对话
+        Conversation conversation = new Conversation();
+        conversation.setUserId(userId);
+        conversation.setDocumentId(request.getDocumentId());
+        conversation.setBlockId(request.getBlockId());
+        conversation.setType(request.getType() != null ? request.getType() : ConversationType.FREE_CHAT);
+        conversation.setSelectedText(request.getSelectedText());
+        conversation.setIsClosed(false);
+
+        // 根据对话类型设置标题
+        if (conversation.getType() == ConversationType.FREE_CHAT) {
+            // 自由对话:默认标题
+            conversation.setTitle("自由对话");
+        } else if (conversation.getType() == ConversationType.KNOWLEDGE_QA) {
+            // 划词提问:使用划词内容作为标题
+            if (request.getSelectedText() != null && !request.getSelectedText().isBlank()) {
+                String title = request.getSelectedText();
+                if (title.length() > 20) {
+                    title = title.substring(0, 20) + "...";
+                }
+                conversation.setTitle("关于「" + title + "」的问答");
+            } else {
+                conversation.setTitle("知识点问答");
+            }
+        } else {
+            conversation.setTitle("新对话");
+        }
+
+        // 如果用户自定义了标题,使用用户指定的
+        if (request.getTitle() != null && !request.getTitle().isBlank()) {
+            conversation.setTitle(request.getTitle());
+        }
+
+        conversationRepository.save(conversation);
+
+        // 如果是划词提问,处理知识点逻辑并保存知识点ID
+        if (conversation.getType() == ConversationType.KNOWLEDGE_QA
+                && request.getSelectedText() != null && !request.getSelectedText().isBlank()
+                && request.getBlockId() != null
+                && request.getStartIndex() != null && request.getEndIndex() != null) {
+
+            // 查找当前Block下的所有知识点
+            List<KnowledgePoint> existingKps = knowledgePointRepository.findByBlockId(request.getBlockId());
+
+            // 检查startIndex和endIndex是否在已有知识点范围内
+            KnowledgePoint targetKp = null;
+            for (KnowledgePoint kp : existingKps) {
+                if (kp.getSourceStart() != null && kp.getSourceEnd() != null) {
+                    // 检查两个index是否都在该知识点范围内
+                    if ((request.getStartIndex() >= kp.getSourceStart() && request.getStartIndex() <= kp.getSourceEnd())
+                            ||
+                            (request.getEndIndex() >= kp.getSourceStart()
+                                    && request.getEndIndex() <= kp.getSourceEnd())) {
+                        targetKp = kp;
+                        break;
+                    }
+                }
+            }
+
+            // 如果没找到,创建新知识点
+            if (targetKp == null) {
+                targetKp = new KnowledgePoint();
+                targetKp.setBlockId(request.getBlockId());
+                targetKp.setContent(request.getSelectedText());
+                targetKp.setSourceText(request.getSelectedText());
+                targetKp.setSourceStart(request.getStartIndex());
+                targetKp.setSourceEnd(request.getEndIndex());
+                targetKp.setIsFromChat(true);
+            }
+
+            // 设置复习次数为2(最多2次)
+            targetKp.setReviewTimesNeeded(
+                    Math.min(2, (targetKp.getReviewTimesNeeded() != null ? targetKp.getReviewTimesNeeded() : 0) + 2));
+            // 设置上次复习时间为当前时间
+            targetKp.setLastReviewedAt(java.time.LocalDateTime.now());
+
+            knowledgePointRepository.save(targetKp);
+
+            // 保存知识点ID到会话中
+            conversation.setKnowledgePointId(targetKp.getId());
+            conversationRepository.save(conversation);
+
+            log.info("划词提问知识点处理完成: kpId={}, reviewTimesNeeded={}",
+                    targetKp.getId(), targetKp.getReviewTimesNeeded());
+        }
+
+        return convertToVO(conversation);
+    }
+
+    @Override
+    public List<ConversationVO> getConversations(String documentId, ConversationType type, Long userId) {
+        List<Conversation> conversations;
+        if (type != null) {
+            conversations = conversationRepository
+                    .findByUserIdAndDocumentIdAndTypeOrderByUpdatedAtDesc(userId, documentId, type);
+        } else {
+            conversations = conversationRepository
+                    .findByUserIdAndDocumentIdOrderByUpdatedAtDesc(userId, documentId);
+        }
+
+        return conversations.stream()
+                .map(this::convertToVO)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public ConversationDetailVO getConversationDetail(Long conversationId, Long userId) {
+        Conversation conversation = conversationRepository.findById(conversationId)
+                .orElseThrow(() -> SmartReviewException.notFound("对话不存在"));
+
+        if (!conversation.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此对话");
+        }
+
+        // 获取消息列表
+        List<ReviewChatMessage> messages = chatMessageRepository
+                .findByConversationIdOrderBySequenceNo(conversationId);
+
+        ConversationDetailVO detail = new ConversationDetailVO();
+        detail.setId(conversation.getId());
+        detail.setDocumentId(conversation.getDocumentId());
+        detail.setBlockId(conversation.getBlockId());
+        detail.setType(conversation.getType());
+        detail.setTitle(conversation.getTitle());
+        detail.setSelectedText(conversation.getSelectedText());
+        detail.setIsClosed(conversation.getIsClosed());
+        detail.setMessageCount(messages.size());
+        detail.setCreatedAt(conversation.getCreatedAt());
+        detail.setUpdatedAt(conversation.getUpdatedAt());
+
+        detail.setMessages(messages.stream().map(this::convertMessageToVO).collect(Collectors.toList()));
+
+        return detail;
+    }
+
+    @Override
+    @Transactional
+    public Flux<ChatStreamResponseVO> sendMessage(SendMessageRequestVO request, Long userId) {
+        // 获取对话
+        Conversation conversation = conversationRepository.findById(request.getConversationId())
+                .orElseThrow(() -> SmartReviewException.notFound("对话不存在"));
+
+        if (!conversation.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此对话");
+        }
+
+        if (Boolean.TRUE.equals(conversation.getIsClosed())) {
+            throw SmartReviewException.badRequest("对话已关闭,无法发送消息");
+        }
+
+        // 获取Block内容(用于上下文)
+        String blockContent = "";
+        if (conversation.getBlockId() != null) {
+            Block block = blockRepository.findById(conversation.getBlockId()).orElse(null);
+            if (block != null) {
+                blockContent = block.getContent();
+            }
+        }
+
+        // 获取当前消息序号
+        long messageCount = chatMessageRepository.countByConversationId(conversation.getId());
+        int nextSequence = (int) messageCount + 1;
+
+        // 判断是否是第一轮对话(用于后续生成标题)
+        List<ReviewChatMessage> historyMessages = chatMessageRepository
+                .findByConversationIdOrderBySequenceNo(conversation.getId());
+        final boolean isFirstRound = historyMessages.isEmpty();
+        // 保存用户消息
+        ReviewChatMessage userMessage = new ReviewChatMessage();
+        userMessage.setConversationId(conversation.getId());
+        userMessage.setUserId(userId);
+        userMessage.setRole("user");
+        userMessage.setContent(request.getMessage());
+        userMessage.setSequenceNo(nextSequence);
+        chatMessageRepository.save(userMessage);
+
+        final String userMessageContent = request.getMessage();
+
+        // 调用AI获取流式响应
+        final String finalBlockContent = blockContent;
+        final Long conversationId = conversation.getId();
+        final int aiSequence = nextSequence + 1;
+        StringBuilder fullContent = new StringBuilder();
+
+        return Flux.just(ChatStreamResponseVO.start(conversationId))
+                .concatWith(
+                        aiService.chat(
+                                conversation.getType(),
+                                finalBlockContent,
+                                conversation.getSelectedText(),
+                                request.getMessage(),
+                                historyMessages)
+                                .map(delta -> {
+                                    fullContent.append(delta);
+                                    return ChatStreamResponseVO.delta(conversationId, delta);
+                                }))
+                .concatWith(Flux.defer(() -> {
+                    // 保存AI回复
+                    ReviewChatMessage aiMessage = new ReviewChatMessage();
+                    aiMessage.setConversationId(conversationId);
+                    aiMessage.setUserId(userId);
+                    aiMessage.setRole("assistant");
+                    aiMessage.setContent(fullContent.toString());
+                    aiMessage.setSequenceNo(aiSequence);
+                    ReviewChatMessage savedMessage = chatMessageRepository.save(aiMessage);
+
+                    // 如果是第一轮对话,异步生成对话标题
+                    if (isFirstRound) {
+                        generateConversationTitleAsync(conversationId, userMessageContent, fullContent.toString());
+                    }
+
+                    return Flux.just(
+                            ChatStreamResponseVO.end(conversationId, savedMessage.getId(), fullContent.toString()));
+                }))
+                .onErrorResume(e -> {
+                    log.error("AI对话出错", e);
+                    return Flux.just(ChatStreamResponseVO.error(conversationId, "对话出错:" + e.getMessage()));
+                });
+    }
+
+    /**
+     * 异步生成对话标题(后台任务,不影响正常业务)
+     */
+    private void generateConversationTitleAsync(Long conversationId, String userMessage, String aiResponse) {
+        CompletableFuture.runAsync(() -> {
+            try {
+                log.debug("开始异步生成对话标题, conversationId={}", conversationId);
+                String title = aiService.generateConversationTitle(userMessage, aiResponse);
+
+                // 更新对话标题
+                conversationRepository.findById(conversationId).ifPresent(conv -> {
+                    conv.setTitle(title);
+                    conversationRepository.save(conv);
+                    log.info("对话标题生成成功, conversationId={}, title={}", conversationId, title);
+                });
+            } catch (Exception e) {
+                log.error("异步生成对话标题失败, conversationId={}", conversationId, e);
+                // 失败不影响正常业务,保持默认标题
+            }
+        });
+    }
+
+    @Override
+    @Transactional
+    public void closeConversation(Long conversationId, Long userId) {
+        Conversation conversation = conversationRepository.findById(conversationId)
+                .orElseThrow(() -> SmartReviewException.notFound("对话不存在"));
+
+        if (!conversation.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此对话");
+        }
+
+        conversation.setIsClosed(true);
+        conversationRepository.save(conversation);
+    }
+
+    @Override
+    @Transactional
+    public void deleteConversation(Long conversationId, Long userId) {
+        Conversation conversation = conversationRepository.findById(conversationId)
+                .orElseThrow(() -> SmartReviewException.notFound("对话不存在"));
+
+        if (!conversation.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此对话");
+        }
+
+        // 删除对话的所有消息
+        chatMessageRepository.deleteByConversationId(conversationId);
+        // 删除对话
+        conversationRepository.delete(conversation);
+    }
+
+    /**
+     * 转换为对话VO
+     */
+    private ConversationVO convertToVO(Conversation conversation) {
+        ConversationVO vo = new ConversationVO();
+        vo.setId(conversation.getId());
+        vo.setDocumentId(conversation.getDocumentId());
+        vo.setBlockId(conversation.getBlockId());
+        vo.setKnowledgePointId(conversation.getKnowledgePointId());
+        vo.setType(conversation.getType());
+        vo.setTitle(conversation.getTitle());
+        vo.setSelectedText(conversation.getSelectedText());
+        vo.setIsClosed(conversation.getIsClosed());
+        vo.setCreatedAt(conversation.getCreatedAt());
+        vo.setUpdatedAt(conversation.getUpdatedAt());
+
+        // 获取消息数量和最后一条消息预览
+        List<ReviewChatMessage> messages = chatMessageRepository
+                .findByConversationIdOrderBySequenceNo(conversation.getId());
+        vo.setMessageCount(messages.size());
+
+        if (!messages.isEmpty()) {
+            ReviewChatMessage lastMessage = messages.get(messages.size() - 1);
+            String preview = lastMessage.getContent();
+            if (preview != null && preview.length() > 50) {
+                preview = preview.substring(0, 50) + "...";
+            }
+            vo.setLastMessagePreview(preview);
+        }
+
+        return vo;
+    }
+
+    /**
+     * 转换为消息VO
+     */
+    private ChatMessageVO convertMessageToVO(ReviewChatMessage message) {
+        ChatMessageVO vo = new ChatMessageVO();
+        vo.setId(message.getId());
+        vo.setConversationId(message.getConversationId());
+        vo.setRole(message.getRole());
+        vo.setContent(message.getContent());
+        vo.setSequenceNo(message.getSequenceNo());
+        vo.setCreatedAt(message.getCreatedAt());
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public QuestionVO generateQuestionFromConversation(Long conversationId, Long userId, QuestionType type) {
+        Conversation conversation = conversationRepository.findById(conversationId)
+                .orElseThrow(() -> SmartReviewException.notFound("对话不存在"));
+
+        if (!conversation.getUserId().equals(userId)) {
+            throw SmartReviewException.forbidden("无权访问此对话");
+        }
+
+        if (conversation.getType() != ConversationType.KNOWLEDGE_QA) {
+            throw SmartReviewException.badRequest("只有划词提问类型的对话才能生成题目");
+        }
+
+        if (conversation.getBlockId() == null) {
+            throw SmartReviewException.badRequest("该对话未关联内容块,无法生成题目");
+        }
+
+        // 直接使用保存的知识点ID
+        if (conversation.getKnowledgePointId() == null) {
+            throw SmartReviewException.badRequest("该对话未关联知识点,无法生成题目");
+        }
+
+        KnowledgePoint targetKp = knowledgePointRepository.findById(conversation.getKnowledgePointId())
+                .orElseThrow(() -> SmartReviewException.notFound("知识点不存在"));
+
+        // 获取Block内容用于生成题目
+        Block block = blockRepository.findById(conversation.getBlockId())
+                .orElseThrow(() -> SmartReviewException.notFound("内容块不存在"));
+
+        // 调用AI生成题目
+        try {
+            String questionJson = aiService.generateQuestionForKnowledgePoint(block.getContent(), targetKp, type);
+            Question question = parseQuestionFromJson(questionJson, conversation.getBlockId(),
+                    targetKp.getId(), QuestionSource.SELECTION_GENERATED, conversation.getBlockId());
+            question = questionRepository.save(question);
+            return convertToQuestionVO(question);
+        } catch (Exception e) {
+            log.error("生成题目失败", e);
+            throw SmartReviewException.error("生成题目失败: " + e.getMessage());
+        }
+    }
+
+    private Question parseQuestionFromJson(String json, Long blockId, Long knowledgePointId,
+            QuestionSource source, Long generatedBlockId) {
+        try {
+            JsonObject obj = gson.fromJson(json, JsonObject.class);
+
+            Question question = new Question();
+            question.setBlockId(blockId);
+            question.setGeneratedBlockId(generatedBlockId);
+            question.setKnowledgePointId(knowledgePointId);
+            question.setQuestionSource(source);
+            question.setQuestionText(obj.get("questionText").getAsString());
+            question.setCorrectAnswer(obj.get("correctAnswer").getAsString());
+            question.setExplanation(obj.has("explanation") ? obj.get("explanation").getAsString() : null);
+            question.setSourceEvidence(obj.has("sourceEvidence") ? obj.get("sourceEvidence").getAsString() : null);
+            question.setStatus(QuestionStatus.ACTIVE);
+
+            String typeStr = obj.get("type").getAsString().toUpperCase();
+            question.setType(QuestionType.valueOf(typeStr));
+
+            if (obj.has("options") && !obj.get("options").isJsonNull()) {
+                List<String> options = java.util.Arrays.asList(gson.fromJson(obj.get("options"), String[].class));
+                question.setOptions(options);
+            }
+
+            return question;
+        } catch (Exception e) {
+            log.error("解析题目JSON失败: {}", json, e);
+            throw new SmartReviewException(500, "解析题目失败");
+        }
+    }
+
+    private QuestionVO convertToQuestionVO(Question question) {
+        QuestionVO vo = new QuestionVO();
+        vo.setId(question.getId());
+        vo.setBlockId(question.getBlockId());
+        vo.setKnowledgePointId(question.getKnowledgePointId());
+        vo.setQuestionSource(question.getQuestionSource());
+        vo.setType(question.getType());
+        vo.setQuestionText(question.getQuestionText());
+        vo.setOptions(question.getOptions());
+        vo.setCorrectAnswer(question.getCorrectAnswer());
+        return vo;
+    }
+}

+ 72 - 0
src/main/java/com/smartreview/service/impl/UserServiceImpl.java

@@ -0,0 +1,72 @@
+package com.smartreview.service.impl;
+
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.User;
+import com.smartreview.repository.UserRepository;
+import com.smartreview.service.UserService;
+import com.smartreview.utils.TokenUtil;
+import com.smartreview.vo.user.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+/**
+ * 用户服务实现
+ */
+@Service
+@RequiredArgsConstructor
+public class UserServiceImpl implements UserService {
+
+    private final UserRepository userRepository;
+    private final PasswordEncoder passwordEncoder;
+    private final TokenUtil tokenUtil;
+
+    @Override
+    public LoginResponseVO login(LoginRequestVO request) {
+        User user = userRepository.findByUsername(request.getUsername())
+                .orElseThrow(() -> SmartReviewException.badRequest("用户名或密码错误"));
+
+        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
+            throw SmartReviewException.badRequest("用户名或密码错误");
+        }
+
+        LoginResponseVO response = new LoginResponseVO();
+        response.setUserId(user.getId());
+        response.setUsername(user.getUsername());
+        response.setToken(tokenUtil.getToken(user));
+        return response;
+    }
+
+    @Override
+    public LoginResponseVO register(RegisterRequestVO request) {
+        if (userRepository.existsByUsername(request.getUsername())) {
+            throw SmartReviewException.badRequest("用户名已存在");
+        }
+
+        User user = new User();
+        user.setUsername(request.getUsername());
+        user.setPassword(passwordEncoder.encode(request.getPassword()));
+        userRepository.save(user);
+
+        LoginResponseVO response = new LoginResponseVO();
+        response.setUserId(user.getId());
+        response.setUsername(user.getUsername());
+        response.setToken(tokenUtil.getToken(user));
+        return response;
+    }
+
+    @Override
+    public UserInfoVO getUserInfo(Long userId) {
+        User user = getUserById(userId);
+        UserInfoVO vo = new UserInfoVO();
+        vo.setId(user.getId());
+        vo.setUsername(user.getUsername());
+        return vo;
+    }
+
+    @Override
+    public User getUserById(Long userId) {
+        return userRepository.findById(userId)
+                .orElseThrow(() -> SmartReviewException.notFound("用户不存在"));
+    }
+}

+ 387 - 0
src/main/java/com/smartreview/utils/MarkdownParser.java

@@ -0,0 +1,387 @@
+package com.smartreview.utils;
+
+import com.smartreview.enums.BlockType;
+import lombok.Data;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Markdown解析工具类
+ * 使用正则表达式解析Markdown,按标题切分章节,按段落切分Block
+ */
+@Component
+public class MarkdownParser {
+
+    private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,6})\\s+(.+)$", Pattern.MULTILINE);
+
+    /**
+     * 解析Markdown文档
+     */
+    public ParseResult parse(String content) {
+        ParseResult result = new ParseResult();
+        result.setChapters(new ArrayList<>());
+        result.setBlocks(new ArrayList<>());
+
+        if (content == null || content.isBlank()) {
+            return result;
+        }
+
+        // 按行分割
+        String[] lines = content.split("\n");
+
+        // 第一遍:识别所有标题
+        List<HeadingInfo> headings = new ArrayList<>();
+        for (int i = 0; i < lines.length; i++) {
+            Matcher matcher = HEADING_PATTERN.matcher(lines[i].trim());
+            if (matcher.matches()) {
+                HeadingInfo heading = new HeadingInfo();
+                heading.setLineIndex(i);
+                heading.setLevel(matcher.group(1).length());
+                heading.setTitle(matcher.group(2).trim());
+                headings.add(heading);
+            }
+        }
+
+        // 构建章节层级
+        int chapterOrder = 0;
+        List<Integer> parentStack = new ArrayList<>(); // 存储各层级的章节索引
+
+        for (HeadingInfo heading : headings) {
+            ChapterInfo chapter = new ChapterInfo();
+            chapter.setTitle(heading.getTitle());
+            chapter.setLevel(heading.getLevel());
+            chapter.setSortOrder(chapterOrder);
+            chapter.setStartLine(heading.getLineIndex());
+
+            // 找到父章节
+            while (!parentStack.isEmpty()) {
+                int lastIdx = parentStack.get(parentStack.size() - 1);
+                ChapterInfo lastChapter = result.getChapters().get(lastIdx);
+                if (lastChapter.getLevel() < heading.getLevel()) {
+                    chapter.setParentIndex(lastIdx);
+                    break;
+                }
+                parentStack.remove(parentStack.size() - 1);
+            }
+
+            result.getChapters().add(chapter);
+            parentStack.add(chapterOrder);
+            chapterOrder++;
+        }
+
+        // 设置每个章节的结束行
+        for (int i = 0; i < result.getChapters().size(); i++) {
+            ChapterInfo chapter = result.getChapters().get(i);
+            if (i + 1 < result.getChapters().size()) {
+                chapter.setEndLine(result.getChapters().get(i + 1).getStartLine() - 1);
+            } else {
+                chapter.setEndLine(lines.length - 1);
+            }
+        }
+
+        // 第二遍:切分Block(按段落或按章节)
+        int blockOrder = 0;
+
+        if (headings.isEmpty()) {
+            // 没有标题,按段落切分,并为每个段落创建平级的chapter
+            List<BlockInfo> blocks = splitByParagraph(content, null);
+            for (int i = 0; i < blocks.size(); i++) {
+                BlockInfo block = blocks.get(i);
+
+                // 为每个block创建一个平级的chapter
+                ChapterInfo chapter = new ChapterInfo();
+                // 如果block有标题(来自标号行),使用标号行文字;否则使用默认标题
+                if (block.getTitle() != null && !block.getTitle().isEmpty()) {
+                    chapter.setTitle(block.getTitle());
+                } else {
+                    chapter.setTitle("段落 " + (i + 1));
+                }
+                chapter.setLevel(1); // 都是平级的一级章节
+                chapter.setSortOrder(i);
+                chapter.setParentIndex(null);
+                result.getChapters().add(chapter);
+
+                // 设置block关联到对应的chapter
+                block.setChapterIndex(i);
+                block.setSortOrder(blockOrder++);
+                block.setType(determineBlockType(block.getContent()));
+                result.getBlocks().add(block);
+            }
+        } else {
+            // 有标题,按章节切分
+            for (ChapterInfo chapter : result.getChapters()) {
+                StringBuilder chapterContent = new StringBuilder();
+                for (int i = chapter.getStartLine(); i <= chapter.getEndLine() && i < lines.length; i++) {
+                    chapterContent.append(lines[i]).append("\n");
+                }
+
+                String contentStr = chapterContent.toString().trim();
+                if (!contentStr.isEmpty()) {
+                    // 判断该block是否只包含标题、空行或无实质内容
+                    BlockType blockType = determineBlockType(contentStr);
+
+                    BlockInfo block = new BlockInfo();
+                    block.setContent(contentStr);
+                    block.setChapterIndex(chapter.getSortOrder());
+                    block.setSortOrder(blockOrder++);
+                    block.setType(blockType);
+                    result.getBlocks().add(block);
+                }
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * 判断Block类型
+     * 如果内容只有标题、空行、分隔符、特殊标记说明等无实质学习内容,则为READ_ONLY
+     */
+    private BlockType determineBlockType(String content) {
+        if (content == null || content.isBlank()) {
+            return BlockType.READ_ONLY;
+        }
+
+        // 移除所有标题行
+        String withoutHeadings = content.replaceAll("(?m)^#{1,6}\\s+.*$", "").trim();
+
+        // 移除分隔符(---、***、___)
+        String withoutSeparators = withoutHeadings.replaceAll("(?m)^[-*_]{3,}$", "").trim();
+
+        // 移除空行
+        String cleaned = withoutSeparators.replaceAll("(?m)^\\s*$", "").trim();
+
+        // 如果清理后为空,则为仅阅读
+        if (cleaned.isEmpty()) {
+            return BlockType.READ_ONLY;
+        }
+
+        // 如果剩余内容长度很短(只有特殊标记说明等),也视为仅阅读
+        // 例如:"**特殊标记**:⚡表示关键点" 这种单行说明
+        // 但如果有实际的段落内容,则为需要学习
+        String[] lines = cleaned.split("\n");
+        int meaningfulLines = 0;
+        for (String line : lines) {
+            String trimmedLine = line.trim();
+            if (!trimmedLine.isEmpty() && trimmedLine.length() > 10) {
+                meaningfulLines++;
+            }
+        }
+
+        // 如果有实质性内容行,则为需要学习
+        if (meaningfulLines > 0 || cleaned.length() > 50) {
+            return BlockType.LEARNABLE;
+        }
+
+        return BlockType.READ_ONLY;
+    }
+
+    // 标号行正则:匹配 1. 2. 或 一、二、 或 1.1 1.1. 等格式
+    // 允许开头有 Markdown 格式标记(**、*、__、_)
+    private static final Pattern NUMBERED_LINE_PATTERN = Pattern.compile(
+            "^\\s*(?:\\*{1,2}|_{1,2})?(" + // 允许可选的 Markdown 加粗/斜体标记
+                    "\\d+\\.\\d*\\.?\\d*\\.?|" + // 1. 或 1.1 或 1.1. 或 1.1.1 等
+                    "[一二三四五六七八九十]+[、.]|" + // 一、 二、 或 一. 二. 等
+                    "[((]\\s*\\d+\\s*[))]|" + // (1) 或 (1) 等
+                    "[((]\\s*[一二三四五六七八九十]+\\s*[))]" + // (一) 或 (一) 等
+                    ")\\s*.+");
+
+    /**
+     * 按段落切分内容(无标题时使用)
+     * 优先按标号行切分,其次按空行切分,超长时按行切分
+     */
+    private List<BlockInfo> splitByParagraph(String content, Integer chapterIndex) {
+        List<BlockInfo> blocks = new ArrayList<>();
+        String[] lines = content.split("\n");
+
+        // 第一步:识别所有标号行的位置
+        List<Integer> numberedLineIndices = new ArrayList<>();
+        for (int i = 0; i < lines.length; i++) {
+            if (NUMBERED_LINE_PATTERN.matcher(lines[i]).matches()) {
+                numberedLineIndices.add(i);
+            }
+        }
+
+        if (!numberedLineIndices.isEmpty()) {
+            // 有标号行,按标号行切分
+            blocks = splitByNumberedLines(lines, numberedLineIndices, chapterIndex);
+        } else {
+            // 没有标号行,按空行(\n\n\n\n,即两个空行)切分
+            blocks = splitByEmptyLines(lines, chapterIndex);
+        }
+
+        // 对超长的block进行二次切分
+        List<BlockInfo> finalBlocks = new ArrayList<>();
+        for (BlockInfo block : blocks) {
+            if (block.getContent().length() > 1000) {
+                finalBlocks.addAll(splitLongBlock(block, chapterIndex));
+            } else {
+                finalBlocks.add(block);
+            }
+        }
+
+        return finalBlocks;
+    }
+
+    /**
+     * 按标号行切分
+     */
+    private List<BlockInfo> splitByNumberedLines(String[] lines, List<Integer> numberedLineIndices,
+            Integer chapterIndex) {
+        List<BlockInfo> blocks = new ArrayList<>();
+
+        // 如果第一个标号行不是第0行,先把前面的内容作为一个block
+        if (numberedLineIndices.get(0) > 0) {
+            StringBuilder preContent = new StringBuilder();
+            for (int i = 0; i < numberedLineIndices.get(0); i++) {
+                preContent.append(lines[i]).append("\n");
+            }
+            String content = preContent.toString().trim();
+            if (!content.isEmpty()) {
+                BlockInfo block = new BlockInfo();
+                block.setContent(content);
+                block.setChapterIndex(chapterIndex);
+                blocks.add(block);
+            }
+        }
+
+        // 按标号行切分
+        for (int i = 0; i < numberedLineIndices.size(); i++) {
+            int startLine = numberedLineIndices.get(i);
+            int endLine = (i + 1 < numberedLineIndices.size()) ? numberedLineIndices.get(i + 1) - 1 : lines.length - 1;
+
+            // 提取标号行作为标题
+            String titleLine = lines[startLine].trim();
+
+            StringBuilder blockContent = new StringBuilder();
+            for (int j = startLine; j <= endLine; j++) {
+                blockContent.append(lines[j]).append("\n");
+            }
+
+            String content = blockContent.toString().trim();
+            if (!content.isEmpty()) {
+                BlockInfo block = new BlockInfo();
+                block.setContent(content);
+                block.setTitle(titleLine); // 设置标号行文字为标题
+                block.setChapterIndex(chapterIndex);
+                blocks.add(block);
+            }
+        }
+
+        return blocks;
+    }
+
+    /**
+     * 按空行切分(连续两个空行,即\n\n\n\n)
+     */
+    private List<BlockInfo> splitByEmptyLines(String[] lines, Integer chapterIndex) {
+        List<BlockInfo> blocks = new ArrayList<>();
+        StringBuilder currentBlock = new StringBuilder();
+        int consecutiveEmptyLines = 0;
+
+        for (String line : lines) {
+            if (line.trim().isEmpty()) {
+                consecutiveEmptyLines++;
+                // 遇到连续两个空行(对应原文中的一个空行,即\n\n\n\n的情况)
+                if (consecutiveEmptyLines >= 2 && currentBlock.length() > 0) {
+                    BlockInfo block = new BlockInfo();
+                    block.setContent(currentBlock.toString().trim());
+                    block.setChapterIndex(chapterIndex);
+                    blocks.add(block);
+                    currentBlock = new StringBuilder();
+                    consecutiveEmptyLines = 0;
+                    continue;
+                }
+            } else {
+                consecutiveEmptyLines = 0;
+            }
+            currentBlock.append(line).append("\n");
+        }
+
+        // 保存最后一个block
+        if (currentBlock.length() > 0) {
+            String content = currentBlock.toString().trim();
+            if (!content.isEmpty()) {
+                BlockInfo block = new BlockInfo();
+                block.setContent(content);
+                block.setChapterIndex(chapterIndex);
+                blocks.add(block);
+            }
+        }
+
+        return blocks;
+    }
+
+    /**
+     * 切分超长block(超过1000字时,在接近1000字的行处切分)
+     */
+    private List<BlockInfo> splitLongBlock(BlockInfo originalBlock, Integer chapterIndex) {
+        List<BlockInfo> blocks = new ArrayList<>();
+        String[] lines = originalBlock.getContent().split("\n");
+
+        StringBuilder currentBlock = new StringBuilder();
+        int maxSize = 1000;
+
+        for (String line : lines) {
+            // 如果加上这一行会超过1000字,且当前已有内容,则先保存当前block
+            if (currentBlock.length() + line.length() + 1 > maxSize && currentBlock.length() > 0) {
+                BlockInfo block = new BlockInfo();
+                block.setContent(currentBlock.toString().trim());
+                block.setChapterIndex(chapterIndex);
+                blocks.add(block);
+                currentBlock = new StringBuilder();
+            }
+
+            if (currentBlock.length() > 0) {
+                currentBlock.append("\n");
+            }
+            currentBlock.append(line);
+        }
+
+        // 保存最后一个block
+        if (currentBlock.length() > 0) {
+            BlockInfo block = new BlockInfo();
+            block.setContent(currentBlock.toString().trim());
+            block.setChapterIndex(chapterIndex);
+            blocks.add(block);
+        }
+
+        return blocks;
+    }
+
+    @Data
+    public static class ParseResult {
+        private List<ChapterInfo> chapters;
+        private List<BlockInfo> blocks;
+    }
+
+    @Data
+    public static class ChapterInfo {
+        private String title;
+        private Integer level;
+        private Integer sortOrder;
+        private Integer parentIndex;
+        private int startLine;
+        private int endLine;
+    }
+
+    @Data
+    public static class BlockInfo {
+        private String content;
+        private String title; // 标号行的标题文字
+        private Integer chapterIndex;
+        private Integer sortOrder;
+        private BlockType type = BlockType.LEARNABLE;
+    }
+
+    @Data
+    private static class HeadingInfo {
+        private int lineIndex;
+        private int level;
+        private String title;
+    }
+}

+ 70 - 0
src/main/java/com/smartreview/utils/OssUtil.java

@@ -0,0 +1,70 @@
+package com.smartreview.utils;
+
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.model.PutObjectRequest;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Date;
+
+/**
+ * 阿里云OSS工具类
+ */
+@Data
+@Component
+@ConfigurationProperties("aliyun.oss")
+public class OssUtil {
+
+    private String endpoint;
+    private String accessKeyId;
+    private String accessKeySecret;
+    private String bucketName;
+
+    /**
+     * 上传文件并返回objectName(用于存储到数据库)
+     * 
+     * @param objectName  OSS中的文件路径
+     * @param inputStream 文件输入流
+     * @return objectName 返回传入的objectName,用于存储到数据库
+     */
+    public String upload(String objectName, InputStream inputStream) {
+        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
+        try {
+            ossClient.putObject(new PutObjectRequest(bucketName, objectName, inputStream));
+            // 返回objectName而不是URL,因为OSS是私有的
+            return objectName;
+        } finally {
+            ossClient.shutdown();
+        }
+    }
+
+    /**
+     * 生成签名URL(用于访问已上传的文件)
+     */
+    public String generatePresignedUrl(String objectName, long expireSeconds) {
+        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
+        try {
+            Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000);
+            URL url = ossClient.generatePresignedUrl(bucketName, objectName, expiration);
+            return url.toString();
+        } finally {
+            ossClient.shutdown();
+        }
+    }
+
+    /**
+     * 删除文件
+     */
+    public void delete(String objectName) {
+        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
+        try {
+            ossClient.deleteObject(bucketName, objectName);
+        } finally {
+            ossClient.shutdown();
+        }
+    }
+}

+ 86 - 0
src/main/java/com/smartreview/utils/SecurityUtil.java

@@ -0,0 +1,86 @@
+package com.smartreview.utils;
+
+import com.smartreview.exception.SmartReviewException;
+import com.smartreview.po.User;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+/**
+ * 安全工具类
+ */
+@Component
+@RequiredArgsConstructor
+public class SecurityUtil {
+
+    private final TokenUtil tokenUtil;
+
+    /**
+     * 获取当前登录用户ID
+     */
+    public Long getCurrentUserId() {
+        HttpServletRequest request = getCurrentRequest();
+        if (request == null) {
+            throw SmartReviewException.notLogin();
+        }
+
+        // 优先从session获取
+        User user = (User) request.getSession().getAttribute("currentUser");
+        if (user != null) {
+            return user.getId();
+        }
+
+        // 从token获取
+        String token = request.getHeader("token");
+        if (token == null || token.isEmpty()) {
+            token = request.getHeader("Authorization");
+            if (token != null && token.startsWith("Bearer ")) {
+                token = token.substring(7);
+            }
+        }
+
+        Long userId = tokenUtil.getUserId(token);
+        if (userId == null) {
+            throw SmartReviewException.notLogin();
+        }
+
+        return userId;
+    }
+
+    /**
+     * 获取当前登录用户
+     */
+    public User getCurrentUser() {
+        HttpServletRequest request = getCurrentRequest();
+        if (request == null) {
+            throw SmartReviewException.notLogin();
+        }
+
+        User user = (User) request.getSession().getAttribute("currentUser");
+        if (user != null) {
+            return user;
+        }
+
+        String token = request.getHeader("token");
+        if (token == null || token.isEmpty()) {
+            token = request.getHeader("Authorization");
+            if (token != null && token.startsWith("Bearer ")) {
+                token = token.substring(7);
+            }
+        }
+
+        user = tokenUtil.getUser(token);
+        if (user == null) {
+            throw SmartReviewException.notLogin();
+        }
+
+        return user;
+    }
+
+    private HttpServletRequest getCurrentRequest() {
+        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        return attributes != null ? attributes.getRequest() : null;
+    }
+}

+ 35 - 0
src/main/java/com/smartreview/utils/StringListJsonConverter.java

@@ -0,0 +1,35 @@
+package com.smartreview.utils;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 字符串列表JSON转换器(用于JPA)
+ */
+@Converter
+public class StringListJsonConverter implements AttributeConverter<List<String>, String> {
+
+    private static final Gson gson = new Gson();
+
+    @Override
+    public String convertToDatabaseColumn(List<String> attribute) {
+        if (attribute == null || attribute.isEmpty()) {
+            return null;
+        }
+        return gson.toJson(attribute);
+    }
+
+    @Override
+    public List<String> convertToEntityAttribute(String dbData) {
+        if (dbData == null || dbData.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return gson.fromJson(dbData, new TypeToken<List<String>>() {
+        }.getType());
+    }
+}

+ 73 - 0
src/main/java/com/smartreview/utils/TokenUtil.java

@@ -0,0 +1,73 @@
+package com.smartreview.utils;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.exceptions.JWTVerificationException;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.smartreview.po.User;
+import com.smartreview.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+/**
+ * JWT Token工具类
+ */
+@Component
+@RequiredArgsConstructor
+public class TokenUtil {
+
+    private static final long EXPIRE_TIME = 30L * 24 * 60 * 60 * 1000; // 30天
+    private static final String SECRET = "smart_review_secret_key";
+
+    private final UserRepository userRepository;
+
+    /**
+     * 生成Token
+     */
+    public String getToken(User user) {
+        return JWT.create()
+                .withAudience(String.valueOf(user.getId()))
+                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME))
+                .sign(Algorithm.HMAC256(SECRET));
+    }
+
+    /**
+     * 验证Token
+     */
+    public boolean verifyToken(String token) {
+        try {
+            if (token == null || token.isEmpty()) {
+                return false;
+            }
+            JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
+            return true;
+        } catch (JWTVerificationException e) {
+            return false;
+        }
+    }
+
+    /**
+     * 从Token获取用户ID
+     */
+    public Long getUserId(String token) {
+        try {
+            DecodedJWT jwt = JWT.decode(token);
+            return Long.parseLong(jwt.getAudience().get(0));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 从Token获取用户
+     */
+    public User getUser(String token) {
+        Long userId = getUserId(token);
+        if (userId == null) {
+            return null;
+        }
+        return userRepository.findById(userId).orElse(null);
+    }
+}

+ 50 - 0
src/main/java/com/smartreview/vo/Response.java

@@ -0,0 +1,50 @@
+package com.smartreview.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 统一响应封装类
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Response<T> {
+
+    private int code;
+    private String message;
+    private T data;
+
+    public static <T> Response<T> success(T data) {
+        return new Response<>(200, "success", data);
+    }
+
+    public static <T> Response<T> success() {
+        return new Response<>(200, "success", null);
+    }
+
+    public static <T> Response<T> success(String message, T data) {
+        return new Response<>(200, message, data);
+    }
+
+    public static <T> Response<T> error(int code, String message) {
+        return new Response<>(code, message, null);
+    }
+
+    public static <T> Response<T> error(String message) {
+        return new Response<>(500, message, null);
+    }
+
+    public static <T> Response<T> notLogin() {
+        return new Response<>(401, "未登录或登录已过期", null);
+    }
+
+    public static <T> Response<T> notFound(String message) {
+        return new Response<>(404, message, null);
+    }
+
+    public static <T> Response<T> badRequest(String message) {
+        return new Response<>(400, message, null);
+    }
+}

+ 35 - 0
src/main/java/com/smartreview/vo/block/BlockDetailVO.java

@@ -0,0 +1,35 @@
+package com.smartreview.vo.block;
+
+import com.smartreview.enums.BlockStatus;
+import lombok.Data;
+
+/**
+ * Block内容详情
+ */
+@Data
+public class BlockDetailVO {
+    private Long id;
+    private String documentId;
+    private Long chapterId;
+    private String content;
+    private Integer sortOrder;
+    private BlockStatus status;
+    /**
+     * 总Block数(用于显示进度)
+     */
+    private Integer totalBlocks;
+    /**
+     * 当前是第几个Block
+     */
+    private Integer currentIndex;
+
+    /**
+     * 错题数量
+     */
+    private Integer wrongAnswerCount;
+
+    /**
+     * 薄弱点数量
+     */
+    private Integer weakPointCount;
+}

+ 14 - 0
src/main/java/com/smartreview/vo/block/UpdateBlockContentRequestVO.java

@@ -0,0 +1,14 @@
+package com.smartreview.vo.block;
+
+import lombok.Data;
+
+/**
+ * 更新Block内容请求
+ */
+@Data
+public class UpdateBlockContentRequestVO {
+    /**
+     * 新的文本内容
+     */
+    private String content;
+}

+ 12 - 0
src/main/java/com/smartreview/vo/block/UpdateBlockProgressRequestVO.java

@@ -0,0 +1,12 @@
+package com.smartreview.vo.block;
+
+import com.smartreview.enums.BlockStatus;
+import lombok.Data;
+
+/**
+ * 更新Block进度请求
+ */
+@Data
+public class UpdateBlockProgressRequestVO {
+    private BlockStatus status;
+}

+ 42 - 0
src/main/java/com/smartreview/vo/chat/ChatMessageVO.java

@@ -0,0 +1,42 @@
+package com.smartreview.vo.chat;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 对话消息VO
+ */
+@Data
+public class ChatMessageVO {
+
+    /**
+     * 消息ID
+     */
+    private Long id;
+
+    /**
+     * 对话ID
+     */
+    private Long conversationId;
+
+    /**
+     * 消息角色:user / assistant / system
+     */
+    private String role;
+
+    /**
+     * 消息内容
+     */
+    private String content;
+
+    /**
+     * 消息序号
+     */
+    private Integer sequenceNo;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createdAt;
+}

+ 76 - 0
src/main/java/com/smartreview/vo/chat/ChatStreamResponseVO.java

@@ -0,0 +1,76 @@
+package com.smartreview.vo.chat;
+
+import lombok.Data;
+
+/**
+ * 聊天流式响应VO
+ */
+@Data
+public class ChatStreamResponseVO {
+
+    /**
+     * 响应类型:
+     * - start: 开始响应
+     * - delta: 增量内容
+     * - end: 响应结束
+     * - error: 错误
+     */
+    private String type;
+
+    /**
+     * 对话ID
+     */
+    private Long conversationId;
+
+    /**
+     * 消息ID(AI回复的消息ID)
+     */
+    private Long messageId;
+
+    /**
+     * 增量内容
+     */
+    private String content;
+
+    /**
+     * 完整内容(仅在end类型时返回)
+     */
+    private String fullContent;
+
+    /**
+     * 错误信息(仅在error类型时返回)
+     */
+    private String error;
+
+    public static ChatStreamResponseVO start(Long conversationId) {
+        ChatStreamResponseVO vo = new ChatStreamResponseVO();
+        vo.setType("start");
+        vo.setConversationId(conversationId);
+        return vo;
+    }
+
+    public static ChatStreamResponseVO delta(Long conversationId, String content) {
+        ChatStreamResponseVO vo = new ChatStreamResponseVO();
+        vo.setType("delta");
+        vo.setConversationId(conversationId);
+        vo.setContent(content);
+        return vo;
+    }
+
+    public static ChatStreamResponseVO end(Long conversationId, Long messageId, String fullContent) {
+        ChatStreamResponseVO vo = new ChatStreamResponseVO();
+        vo.setType("end");
+        vo.setConversationId(conversationId);
+        vo.setMessageId(messageId);
+        vo.setFullContent(fullContent);
+        return vo;
+    }
+
+    public static ChatStreamResponseVO error(Long conversationId, String error) {
+        ChatStreamResponseVO vo = new ChatStreamResponseVO();
+        vo.setType("error");
+        vo.setConversationId(conversationId);
+        vo.setError(error);
+        return vo;
+    }
+}

+ 19 - 0
src/main/java/com/smartreview/vo/chat/ConversationDetailVO.java

@@ -0,0 +1,19 @@
+package com.smartreview.vo.chat;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * 对话详情VO(包含消息历史)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ConversationDetailVO extends ConversationVO {
+
+    /**
+     * 消息列表
+     */
+    private List<ChatMessageVO> messages;
+}

+ 73 - 0
src/main/java/com/smartreview/vo/chat/ConversationVO.java

@@ -0,0 +1,73 @@
+package com.smartreview.vo.chat;
+
+import com.smartreview.enums.ConversationType;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 对话信息VO
+ */
+@Data
+public class ConversationVO {
+
+    /**
+     * 对话ID
+     */
+    private Long id;
+
+    /**
+     * 文档ID
+     */
+    private String documentId;
+
+    /**
+     * Block ID
+     */
+    private Long blockId;
+
+    /**
+     * 知识点ID(划词提问时关联的知识点)
+     */
+    private Long knowledgePointId;
+
+    /**
+     * 对话类型
+     */
+    private ConversationType type;
+
+    /**
+     * 对话标题
+     */
+    private String title;
+
+    /**
+     * 划词选中的文本
+     */
+    private String selectedText;
+
+    /**
+     * 是否已关闭
+     */
+    private Boolean isClosed;
+
+    /**
+     * 消息数量
+     */
+    private Integer messageCount;
+
+    /**
+     * 最后一条消息预览
+     */
+    private String lastMessagePreview;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createdAt;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updatedAt;
+}

+ 46 - 0
src/main/java/com/smartreview/vo/chat/CreateConversationRequestVO.java

@@ -0,0 +1,46 @@
+package com.smartreview.vo.chat;
+
+import com.smartreview.enums.ConversationType;
+import lombok.Data;
+
+/**
+ * 创建对话请求VO
+ */
+@Data
+public class CreateConversationRequestVO {
+
+    /**
+     * 文档ID
+     */
+    private String documentId;
+
+    /**
+     * Block ID(划词提问时必传)
+     */
+    private Long blockId;
+
+    /**
+     * 对话类型:FREE_CHAT / KNOWLEDGE_QA
+     */
+    private ConversationType type;
+
+    /**
+     * 对话标题(可选)
+     */
+    private String title;
+
+    /**
+     * 划词选中的文本(知识点问答时使用)
+     */
+    private String selectedText;
+
+    /**
+     * 划词在Block中的起始索引(划词提问时使用)
+     */
+    private Integer startIndex;
+
+    /**
+     * 划词在Block中的结束索引(划词提问时使用)
+     */
+    private Integer endIndex;
+}

+ 30 - 0
src/main/java/com/smartreview/vo/chat/ReviewChatRequestVO.java

@@ -0,0 +1,30 @@
+package com.smartreview.vo.chat;
+
+import lombok.Data;
+
+/**
+ * 复习对话请求
+ */
+@Data
+public class ReviewChatRequestVO {
+    /**
+     * 文档ID
+     */
+    private String documentId;
+    /**
+     * 当前BlockID
+     */
+    private Long blockId;
+    /**
+     * 用户消息
+     */
+    private String message;
+    /**
+     * 划词选中的文本(可选)
+     */
+    private String selectedText;
+    /**
+     * 预设按钮类型:explain/example/compare/quiz(可选)
+     */
+    private String actionType;
+}

+ 26 - 0
src/main/java/com/smartreview/vo/chat/ReviewChatResponseVO.java

@@ -0,0 +1,26 @@
+package com.smartreview.vo.chat;
+
+import lombok.Data;
+
+/**
+ * 复习对话响应(流式)
+ */
+@Data
+public class ReviewChatResponseVO {
+    /**
+     * 响应类型:delta(增量内容)/ done(完成)/ error
+     */
+    private String type;
+    /**
+     * 增量内容
+     */
+    private String content;
+    /**
+     * 完整内容(done时返回)
+     */
+    private String fullContent;
+    /**
+     * 是否已记录为知识点
+     */
+    private Boolean knowledgePointCreated;
+}

+ 20 - 0
src/main/java/com/smartreview/vo/chat/SendMessageRequestVO.java

@@ -0,0 +1,20 @@
+package com.smartreview.vo.chat;
+
+import lombok.Data;
+
+/**
+ * 发送消息请求VO
+ */
+@Data
+public class SendMessageRequestVO {
+
+    /**
+     * 对话ID
+     */
+    private Long conversationId;
+
+    /**
+     * 消息内容
+     */
+    private String message;
+}

+ 20 - 0
src/main/java/com/smartreview/vo/document/BlockBriefVO.java

@@ -0,0 +1,20 @@
+package com.smartreview.vo.document;
+
+import com.smartreview.enums.BlockStatus;
+import com.smartreview.enums.BlockType;
+import lombok.Data;
+
+/**
+ * Block简要信息(用于目录展示)
+ */
+@Data
+public class BlockBriefVO {
+    private Long id;
+    private Integer sortOrder;
+    private BlockStatus status;
+    private BlockType type;
+    /**
+     * 完整内容
+     */
+    private String content;
+}

+ 17 - 0
src/main/java/com/smartreview/vo/document/ChapterTreeVO.java

@@ -0,0 +1,17 @@
+package com.smartreview.vo.document;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 章节目录树结构
+ */
+@Data
+public class ChapterTreeVO {
+    private Long id;
+    private String title;
+    private Integer level;
+    private List<ChapterTreeVO> children;
+    private List<BlockBriefVO> blocks;
+}

+ 19 - 0
src/main/java/com/smartreview/vo/document/DocumentDetailVO.java

@@ -0,0 +1,19 @@
+package com.smartreview.vo.document;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 文档详情响应(含章节目录树)
+ */
+@Data
+public class DocumentDetailVO {
+    private String id;
+    private String title;
+    private String fileUrl;
+    private Integer totalBlocks;
+    private Integer completedBlocks;
+    private String status;
+    private List<ChapterTreeVO> chapters;
+}

+ 21 - 0
src/main/java/com/smartreview/vo/document/DocumentListVO.java

@@ -0,0 +1,21 @@
+package com.smartreview.vo.document;
+
+import com.smartreview.enums.DocumentStatus;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 文档列表项
+ */
+@Data
+public class DocumentListVO {
+    private String id;
+    private String title;
+    private Integer totalBlocks;
+    private Integer completedBlocks;
+    private Double progressPercent;
+    private DocumentStatus status;
+    private LocalDateTime lastReviewedAt;
+    private LocalDateTime createdAt;
+}

+ 114 - 0
src/main/java/com/smartreview/vo/document/DocumentParseStatusVO.java

@@ -0,0 +1,114 @@
+package com.smartreview.vo.document;
+
+import com.smartreview.enums.DocumentStatus;
+import lombok.Data;
+
+/**
+ * 文档解析状态VO(用于轮询查询)
+ */
+@Data
+public class DocumentParseStatusVO {
+    /**
+     * 文档ID
+     */
+    private String documentId;
+
+    /**
+     * 当前状态
+     */
+    private DocumentStatus status;
+
+    /**
+     * 状态描述信息
+     */
+    private String statusMessage;
+
+    /**
+     * 解析进度百分比 0-100
+     */
+    private Integer progress;
+
+    /**
+     * Mineru解析进度信息(JSON格式)
+     */
+    private MineruProgressInfo mineruProgress;
+
+    /**
+     * 是否完成(包括成功和失败)
+     */
+    private Boolean finished;
+
+    /**
+     * 是否成功
+     */
+    private Boolean success;
+
+    /**
+     * 错误信息(失败时返回)
+     */
+    private String errorMessage;
+
+    @Data
+    public static class MineruProgressInfo {
+        /**
+         * 已解析页数
+         */
+        private Integer extractedPages;
+
+        /**
+         * 总页数
+         */
+        private Integer totalPages;
+
+        /**
+         * 解析开始时间
+         */
+        private String startTime;
+    }
+
+    // ========== 静态工厂方法 ==========
+
+    /**
+     * 创建正在解析中的响应
+     */
+    public static DocumentParseStatusVO parsing(String documentId, DocumentStatus status, String message,
+            Integer progress) {
+        DocumentParseStatusVO vo = new DocumentParseStatusVO();
+        vo.setDocumentId(documentId);
+        vo.setStatus(status);
+        vo.setStatusMessage(message);
+        vo.setProgress(progress);
+        vo.setFinished(false);
+        vo.setSuccess(false);
+        return vo;
+    }
+
+    /**
+     * 创建解析完成的响应
+     */
+    public static DocumentParseStatusVO completed(String documentId) {
+        DocumentParseStatusVO vo = new DocumentParseStatusVO();
+        vo.setDocumentId(documentId);
+        vo.setStatus(DocumentStatus.READY);
+        vo.setStatusMessage("文档解析完成");
+        vo.setProgress(100);
+        vo.setFinished(true);
+        vo.setSuccess(true);
+        return vo;
+    }
+
+    /**
+     * 创建解析失败的响应
+     */
+    public static DocumentParseStatusVO failed(String documentId, String errorMessage) {
+        DocumentParseStatusVO vo = new DocumentParseStatusVO();
+        vo.setDocumentId(documentId);
+        vo.setStatus(DocumentStatus.FAILED);
+        vo.setStatusMessage("解析失败");
+        vo.setProgress(0);
+        vo.setFinished(true);
+        vo.setSuccess(false);
+        vo.setErrorMessage(errorMessage);
+        return vo;
+    }
+}

+ 22 - 0
src/main/java/com/smartreview/vo/document/ParseDocumentRequestVO.java

@@ -0,0 +1,22 @@
+package com.smartreview.vo.document;
+
+import lombok.Data;
+
+/**
+ * 文档解析请求
+ */
+@Data
+public class ParseDocumentRequestVO {
+    /**
+     * 文档标题(可选,如果传内容的话传入标题将作为笔记的标题,传id的话会用之前的文件名作为标题)
+     */
+    private String title;
+    /**
+     * 文档ID(如果解析已上传的文档,则必传)
+     */
+    private String documentId;
+    /**
+     * Markdown原始内容(如果直接传内容)
+     */
+    private String content;
+}

+ 51 - 0
src/main/java/com/smartreview/vo/document/ParseDocumentV2RequestVO.java

@@ -0,0 +1,51 @@
+package com.smartreview.vo.document;
+
+import lombok.Data;
+
+/**
+ * 文档解析请求V2(支持多种格式)
+ */
+@Data
+public class ParseDocumentV2RequestVO {
+    /**
+     * 文档ID(必传,通过上传接口获取)
+     */
+    private String documentId;
+
+    /**
+     * 文档标题(可选,不传则使用上传时的文件名)
+     */
+    private String title;
+
+    /**
+     * Markdown原始内容(可选,如果直接传内容则忽略documentId对应的文件)
+     */
+    private String content;
+
+    /**
+     * 是否强制重新解析(默认false)
+     * - false:如果文档正在解析或已解析完成,直接返回当前状态
+     * - true:清理所有旧数据并重新解析
+     */
+    private Boolean forceParse = false;
+
+    /**
+     * 是否启用OCR功能(仅对非Markdown文件有效)
+     */
+    private Boolean enableOcr = true;
+
+    /**
+     * 是否开启公式识别(仅对非Markdown文件有效)
+     */
+    private Boolean enableFormula = true;
+
+    /**
+     * 是否开启表格识别(仅对非Markdown文件有效)
+     */
+    private Boolean enableTable = true;
+
+    /**
+     * 指定页码范围(仅对非Markdown文件有效,如"1-10")
+     */
+    private String pageRanges;
+}

+ 26 - 0
src/main/java/com/smartreview/vo/document/ParseProgressVO.java

@@ -0,0 +1,26 @@
+package com.smartreview.vo.document;
+
+import lombok.Data;
+
+/**
+ * 解析进度事件(SSE)
+ */
+@Data
+public class ParseProgressVO {
+    /**
+     * 阶段:PARSING / CHUNKING / EXTRACTING / DONE / ERROR
+     */
+    private String stage;
+    /**
+     * 进度百分比 0-100
+     */
+    private Integer progress;
+    /**
+     * 当前处理的内容描述
+     */
+    private String message;
+    /**
+     * 文档ID(完成时返回)
+     */
+    private String documentId;
+}

+ 82 - 0
src/main/java/com/smartreview/vo/mineru/MineruBatchUploadRequest.java

@@ -0,0 +1,82 @@
+package com.smartreview.vo.mineru;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * Mineru批量文件上传请求
+ */
+@Data
+public class MineruBatchUploadRequest {
+    /**
+     * 文件列表
+     */
+    private List<FileInfo> files;
+
+    /**
+     * 是否开启公式识别,默认true
+     */
+    @JsonProperty("enable_formula")
+    private Boolean enableFormula = true;
+
+    /**
+     * 是否开启表格识别,默认true
+     */
+    @JsonProperty("enable_table")
+    private Boolean enableTable = true;
+
+    /**
+     * 指定文档语言,默认ch
+     */
+    private String language = "ch";
+
+    /**
+     * 回调URL
+     */
+    private String callback;
+
+    /**
+     * 回调签名种子
+     */
+    private String seed;
+
+    /**
+     * 额外导出格式
+     */
+    @JsonProperty("extra_formats")
+    private List<String> extraFormats;
+
+    /**
+     * 模型版本:pipeline或vlm
+     */
+    @JsonProperty("model_version")
+    private String modelVersion = "vlm";
+
+    @Data
+    public static class FileInfo {
+        /**
+         * 文件名
+         */
+        private String name;
+
+        /**
+         * 业务数据ID
+         */
+        @JsonProperty("data_id")
+        private String dataId;
+
+        /**
+         * 是否启动OCR功能
+         */
+        @JsonProperty("is_ocr")
+        private Boolean isOcr;
+
+        /**
+         * 指定页码范围
+         */
+        @JsonProperty("page_ranges")
+        private String pageRanges;
+    }
+}

+ 52 - 0
src/main/java/com/smartreview/vo/mineru/MineruBatchUploadResponse.java

@@ -0,0 +1,52 @@
+package com.smartreview.vo.mineru;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * Mineru批量文件上传响应
+ */
+@Data
+public class MineruBatchUploadResponse {
+    /**
+     * 状态码,0表示成功
+     */
+    private Integer code;
+
+    /**
+     * 处理信息
+     */
+    private String msg;
+
+    /**
+     * 请求ID
+     */
+    @JsonProperty("trace_id")
+    private String traceId;
+
+    /**
+     * 响应数据
+     */
+    private BatchUploadData data;
+
+    @Data
+    public static class BatchUploadData {
+        /**
+         * 批次ID
+         */
+        @JsonProperty("batch_id")
+        private String batchId;
+
+        /**
+         * 文件上传链接列表
+         */
+        @JsonProperty("file_urls")
+        private List<String> fileUrls;
+    }
+
+    public boolean isSuccess() {
+        return code != null && code == 0;
+    }
+}

+ 19 - 0
src/main/java/com/smartreview/vo/mineru/MineruCallbackRequest.java

@@ -0,0 +1,19 @@
+package com.smartreview.vo.mineru;
+
+import lombok.Data;
+
+/**
+ * Mineru回调请求
+ */
+@Data
+public class MineruCallbackRequest {
+    /**
+     * 签名校验字符串(uid + seed + content 的SHA256)
+     */
+    private String checksum;
+
+    /**
+     * JSON字符串格式的任务结果,需要解析为MineruTaskResultResponse.TaskResultData
+     */
+    private String content;
+}

+ 76 - 0
src/main/java/com/smartreview/vo/mineru/MineruTaskRequest.java

@@ -0,0 +1,76 @@
+package com.smartreview.vo.mineru;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * Mineru创建任务请求
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class MineruTaskRequest {
+    /**
+     * 文件URL
+     */
+    private String url;
+
+    /**
+     * 是否启动OCR功能,默认false
+     */
+    @JsonProperty("is_ocr")
+    private Boolean isOcr;
+
+    /**
+     * 是否开启公式识别,默认true
+     */
+    @JsonProperty("enable_formula")
+    private Boolean enableFormula = true;
+
+    /**
+     * 是否开启表格识别,默认true
+     */
+    @JsonProperty("enable_table")
+    private Boolean enableTable = true;
+
+    /**
+     * 指定文档语言,默认ch
+     */
+    private String language = "ch";
+
+    /**
+     * 业务数据ID
+     */
+    @JsonProperty("data_id")
+    private String dataId;
+
+    /**
+     * 回调URL
+     */
+    private String callback;
+
+    /**
+     * 回调签名种子
+     */
+    private String seed;
+
+    /**
+     * 额外导出格式
+     */
+    @JsonProperty("extra_formats")
+    private List<String> extraFormats;
+
+    /**
+     * 指定页码范围
+     */
+    @JsonProperty("page_ranges")
+    private String pageRanges;
+
+    /**
+     * 模型版本:pipeline或vlm
+     */
+    @JsonProperty("model_version")
+    private String modelVersion = "vlm";
+}

+ 44 - 0
src/main/java/com/smartreview/vo/mineru/MineruTaskResponse.java

@@ -0,0 +1,44 @@
+package com.smartreview.vo.mineru;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * Mineru创建任务响应
+ */
+@Data
+public class MineruTaskResponse {
+    /**
+     * 状态码,0表示成功
+     */
+    private Integer code;
+
+    /**
+     * 处理信息
+     */
+    private String msg;
+
+    /**
+     * 请求ID
+     */
+    @JsonProperty("trace_id")
+    private String traceId;
+
+    /**
+     * 响应数据
+     */
+    private TaskData data;
+
+    @Data
+    public static class TaskData {
+        /**
+         * 任务ID
+         */
+        @JsonProperty("task_id")
+        private String taskId;
+    }
+
+    public boolean isSuccess() {
+        return code != null && code == 0;
+    }
+}

+ 127 - 0
src/main/java/com/smartreview/vo/mineru/MineruTaskResultResponse.java

@@ -0,0 +1,127 @@
+package com.smartreview.vo.mineru;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * Mineru任务结果查询响应
+ */
+@Data
+@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
+public class MineruTaskResultResponse {
+    /**
+     * 状态码,0表示成功
+     */
+    private Integer code;
+
+    /**
+     * 处理信息
+     */
+    private String msg;
+
+    /**
+     * 请求ID
+     */
+    @JsonProperty("trace_id")
+    private String traceId;
+
+    /**
+     * 响应数据
+     */
+    private TaskResultData data;
+
+    @Data
+    @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
+    public static class TaskResultData {
+        /**
+         * 任务ID
+         */
+        @JsonProperty("task_id")
+        private String taskId;
+
+        /**
+         * 业务数据ID
+         */
+        @JsonProperty("data_id")
+        private String dataId;
+
+        /**
+         * 任务状态:done-完成,pending-排队中,running-正在解析,failed-解析失败,converting-格式转换中
+         */
+        private String state;
+
+        /**
+         * 模型版本
+         */
+        @JsonProperty("model_version")
+        private String modelVersion;
+
+        /**
+         * 解析结果压缩包URL
+         */
+        @JsonProperty("full_zip_url")
+        private String fullZipUrl;
+
+        /**
+         * 错误信息(state=failed时有效)
+         */
+        @JsonProperty("err_msg")
+        private String errMsg;
+
+        /**
+         * 解析进度信息
+         */
+        @JsonProperty("extract_progress")
+        private ExtractProgress extractProgress;
+    }
+
+    @Data
+    @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
+    public static class ExtractProgress {
+        /**
+         * 已解析页数
+         */
+        @JsonProperty("extracted_pages")
+        private Integer extractedPages;
+
+        /**
+         * 总页数
+         */
+        @JsonProperty("total_pages")
+        private Integer totalPages;
+
+        /**
+         * 解析开始时间
+         */
+        @JsonProperty("start_time")
+        private String startTime;
+    }
+
+    public boolean isSuccess() {
+        return code != null && code == 0;
+    }
+
+    /**
+     * 判断任务是否完成
+     */
+    public boolean isDone() {
+        return data != null && "done".equals(data.getState());
+    }
+
+    /**
+     * 判断任务是否失败
+     */
+    public boolean isFailed() {
+        return data != null && "failed".equals(data.getState());
+    }
+
+    /**
+     * 判断任务是否正在处理中
+     */
+    public boolean isProcessing() {
+        if (data == null)
+            return false;
+        String state = data.getState();
+        return "pending".equals(state) || "running".equals(state) || "converting".equals(state);
+    }
+}

+ 64 - 0
src/main/java/com/smartreview/vo/question/QuestionGenerationResponseVO.java

@@ -0,0 +1,64 @@
+package com.smartreview.vo.question;
+
+import com.smartreview.enums.QuestionGenerationStatus;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 题目生成响应VO
+ */
+@Data
+public class QuestionGenerationResponseVO {
+
+    /**
+     * 生成状态:GENERATING / COMPLETED / FAILED
+     */
+    private QuestionGenerationStatus status;
+
+    /**
+     * 任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 消息提示
+     */
+    private String message;
+
+    /**
+     * 题目列表(状态为COMPLETED时返回)
+     */
+    private List<QuestionVO> questions;
+
+    /**
+     * 错误信息(状态为FAILED时返回)
+     */
+    private String error;
+
+    public static QuestionGenerationResponseVO generating(Long taskId) {
+        QuestionGenerationResponseVO vo = new QuestionGenerationResponseVO();
+        vo.setStatus(QuestionGenerationStatus.GENERATING);
+        vo.setTaskId(taskId);
+        vo.setMessage("正在生成题目,请稍候...");
+        return vo;
+    }
+
+    public static QuestionGenerationResponseVO completed(Long taskId, List<QuestionVO> questions) {
+        QuestionGenerationResponseVO vo = new QuestionGenerationResponseVO();
+        vo.setStatus(QuestionGenerationStatus.COMPLETED);
+        vo.setTaskId(taskId);
+        vo.setMessage("题目生成完成");
+        vo.setQuestions(questions);
+        return vo;
+    }
+
+    public static QuestionGenerationResponseVO failed(Long taskId, String error) {
+        QuestionGenerationResponseVO vo = new QuestionGenerationResponseVO();
+        vo.setStatus(QuestionGenerationStatus.FAILED);
+        vo.setTaskId(taskId);
+        vo.setMessage("题目生成失败");
+        vo.setError(error);
+        return vo;
+    }
+}

+ 25 - 0
src/main/java/com/smartreview/vo/question/QuestionVO.java

@@ -0,0 +1,25 @@
+package com.smartreview.vo.question;
+
+import com.smartreview.enums.QuestionSource;
+import com.smartreview.enums.QuestionType;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 题目响应
+ */
+@Data
+public class QuestionVO {
+    private Long id;
+    private Long blockId;
+    private Long knowledgePointId;
+    private QuestionSource questionSource;
+    private QuestionType type;
+    private String questionText;
+    private List<String> options;
+    private String correctAnswer;
+    /**
+     * 注意:现在返回正确答案,让前端判断客观题
+     */
+}

+ 16 - 0
src/main/java/com/smartreview/vo/question/SubmitAnswerRequestVO.java

@@ -0,0 +1,16 @@
+package com.smartreview.vo.question;
+
+import lombok.Data;
+
+/**
+ * 提交答案请求
+ */
+@Data
+public class SubmitAnswerRequestVO {
+    private Long questionId;
+    private String answer;
+    /**
+     * 是否标记为薄弱点
+     */
+    private Boolean isWeak;
+}

+ 23 - 0
src/main/java/com/smartreview/vo/question/SubmitAnswerResponseVO.java

@@ -0,0 +1,23 @@
+package com.smartreview.vo.question;
+
+import lombok.Data;
+
+/**
+ * 提交答案响应
+ */
+@Data
+public class SubmitAnswerResponseVO {
+    private Long questionId;
+    private Boolean isCorrect;
+    private String correctAnswer;
+    private String explanation;
+    private String sourceEvidence;
+    /**
+     * AI反馈(问答题)
+     */
+    private String aiFeedback;
+    /**
+     * 回看建议
+     */
+    private String reviewSuggestion;
+}

Some files were not shown because too many files changed in this diff