6.12号版本

This commit is contained in:
2026-06-14 14:56:54 +08:00
parent 0910ba9cbe
commit 4633149d3f
51 changed files with 3381 additions and 8610 deletions

View File

@@ -0,0 +1,448 @@
# 后端问题诊断报告:题目编辑保存 HTTP 500
> 生成时间2026-06-08
> 涉及接口:`PUT /api/question/{id}`(更新题目)
> 问题级别:**P1 - 高(功能不可用)**
> 报告范围:后端全链路深度分析
---
## 一、错误现象
### 1.1 前端控制台日志
```
[保存] 更新题目 - id: e6bb2566-90a8-4651-b553-083a1d695b3c type: single_choice
[保存] 发送 payload: { "question_type": "single_choice", "difficulty": 2, "content": { ... } }
[error] [保存] 更新题目失败: {
status: 500,
statusText: "",
serverData: { code: 500, message: "服务器内部错误", success: false },
message: "Request failed with status code 500"
}
```
### 1.2 用户操作路径
题库管理与审批 → 题目列表 → 点击某题的「编辑」按钮 → 修改内容 → 点击「保存修改」→ HTTP 500
---
## 二、完整调用链路
```
┌─────────────────────────────────────────────────────────────────────┐
│ 前端 QuestionBankPanel.vue │
│ saveEdit() │
│ rec.id = UUID (如 "e6bb2566-...") │
│ updateData = { question_type, difficulty, content:{stem,data,answer} } │
│ │
│ PUT /api/question/{UUID} body: updateData │
└───────────────────────────────┬─────────────────────────────────────┘
│ HTTP PUT
┌─────────────────────────────────────────────────────────────────────┐
│ 后端 QuestionController.java (L91-122) │
│ @PutMapping("/{id}") │
│ public Result<Question> updateQuestion(@PathVariable String id, │
│ @RequestBody QuestionUpdateRequest request) │
│ │
│ ① isNumeric(id)? NO → 走UUID分支 │
│ ② LambdaQueryWrapper.eq(Question::getQuestionId, id) │
│ ③ existing = questionService.getOne(wrapper) │
│ ④ questionService.updateQuestion(existing.getId(), request) │
└───────────────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 后端 QuestionServiceImpl.java (L194-247) │
│ question = getById(id) // 从DB加载完整对象 │
│ │
│ if (request.getQuestionType() != null) │
│ → setQuestionType(...) │
│ if (request.getContent() != null) │
│ → setContent(objectMapper.valueToTree(content)) ⚠️ 崩溃点A │
│ if (request.getScore() != null) │
│ → setScore(...) │
│ // ... 其他字段 ... │
│ │
│ setUpdatedAt(LocalDateTime.now()) │
│ updateById(question) ⚠️ 崩溃点B (写DB) │
└───────────────────────────────┬─────────────────────────────────────┘
│ MyBatis-Plus UPDATE SQL
┌─────────────────────────────────────────────────────────────────────┐
│ MySQL question 表 │
│ content json NOT NULL ← JacksonTypeHandler 序列化 │
│ score decimal(5,2) NOT NULL ← 精度/范围约束 │
│ knowledge_base_path varchar(500) ← 长度约束 │
└───────────────────────────────┬─────────────────────────────────────┘
│ SQL执行异常
┌─────────────────────────────────────────────────────────────────────┐
│ 后端 GlobalExceptionHandler.java (L84-108) │
│ @ExceptionHandler(RuntimeException.class) │
│ → log.error("运行时异常: type={}, message={}", ...) │
│ → return Result.error(500, "服务器内部错误") ← 吞掉真实信息! │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 三、根因分析:所有可能的崩溃点(按风险等级排序)
### 崩溃点 #1 [致命级] JacksonTypeHandler 序列化 JsonNode 异常
**位置**: `QuestionServiceImpl.java:207` + `Question.java:63-64`
**代码**:
```java
// Question.java L63-64
@TableField(typeHandler = JacksonTypeHandler.class)
private JsonNode content; // Entity类型: JsonNode
// QuestionUpdateRequest.java L22-23
@JsonProperty("content")
private Map<String, Object> content; // DTO类型: Map
// QuestionServiceImpl.java L206-207
if (request.getContent() != null) {
question.setContent(objectMapper.valueToTree(request.getContent()));
// ↑ Map<String,Object> → JsonNode 转换
}
// QuestionServiceImpl.java L44 — 裸ObjectMapper无任何配置!
private final ObjectMapper objectMapper = new ObjectMapper();
```
**崩溃机制**:
```
updateById(question)
→ MyBatis遇到 @TableField(typeHandler=JacksonTypeHandler)
→ JacksonTypeHandler.setParameter() 内部调用:
objectMapper.writeValueAsString(jsonNode)
→ 如果JsonNode包含非法值 → JsonProcessingException
→ 包装为 RuntimeException
→ GlobalExceptionHandler 捕获 → 返回500 "服务器内部错误"
```
**触发场景**:
| 场景 | content中的值 | 是否会触发 |
|------|-------------|-----------|
| 正常情况 | `{stem:"xxx", answer:"A", data:{options:[...]}}` | ❌ 正常 |
| **NaN/Infinity** | `{stem:"xxx", answer: NaN}` | ✅ **可能崩溃** |
| 循环引用 | 对象A引用对象BB又引用A | ✅ **必然崩溃** |
| 未注册类型的Java对象 | `content.data.__proto__ = Function` | ✅ **可能崩溃** |
| 空Map | `{}` | ❌ 安全合法JSON `{}` |
**关键发现**: `QuestionServiceImpl.java:44` 使用的是**裸 `new ObjectMapper()`**,与全局配置的 `JacksonConfig` 完全独立。如果前端传入的 content 中包含任何特殊值,这个 ObjectMapper 可能无法正确处理。
---
### 崩溃点 #2 [高危级] score 字段超出 decimal(5,2) 范围
**位置**: `QuestionServiceImpl.java:212-214` + 数据库DDL
**数据库定义**:
```sql
score decimal(5, 2) NOT NULL -- 范围: -999.99 ~ 999.99
```
**Java实体**: `private BigDecimal score;` (无范围限制)
**触发条件**:
```json
// 前端发送:
{ "score": 1000 } // 或 12345.67 或 -1000
// → MySQL: ERROR 1264 (22003): Out of range value for column 'score'
// → DataIntegrityViolationException → 500
```
---
### 崩溃点 #3 [高危级] knowledge_base_path 超过 varchar(500)
**位置**: `QuestionServiceImpl.java:228-229`
**数据库定义**:
```sql
knowledge_base_path varchar(500) NULL
```
**触发条件**: 知识库路径超过500字符虽然罕见但可能发生
---
### 崩溃点 #4 [中危级] GlobalExceptionHandler 吞掉真实异常信息
**位置**: `GlobalExceptionHandler.java:84-94`
```java
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException e) {
log.error("运行时异常: type={}, message={}",
e.getClass().getSimpleName(), e.getMessage());
// ★ 只记日志到服务器,不返回给前端! ★
if (e.getMessage()?.contains("SQL")) ...
if (e.getMessage()?.contains("Access Denied")) ...
return Result.error(500, "服务器内部错误"); // ← 始终返回这同一句话
}
```
**影响**: 无论实际是什么异常SQL错误、JSON序列化错误、空指针等前端永远只看到 `"服务器内部错误"`,无法定位问题。
---
### 崩溃点 #5 [中危级] 缺少 HttpMessageNotReadableException 处理器
**位置**: `GlobalExceptionHandler.java` — 无此处理器
**缺失的异常处理**:
```java
// 当前没有以下处理器:
@ExceptionHandler(HttpMessageNotReadableException.class)
public Result<Void> handle(HttpMessageNotReadableException e) {
return Result.error(400, "请求体格式错误");
}
@ExceptionHandler(DataIntegrityViolationException.class)
public Result<Void> handle(DataIntegrityViolationException e) {
return Result.error(400, "数据违反完整性约束: " + e.getMostSpecificCause().getMessage());
}
```
**后果**: 当请求体不是合法JSON或数据违反约束时本应返回400 Bad Request却返回了500。
---
### 崩溃点 #6 [低危级] updateById 返回值未检查(静默失败)
**位置**: `QuestionServiceImpl.java:245`
```java
question.setUpdatedAt(LocalDateTime.now());
updateById(question); // ★ 返回 boolean但未检查! ★
return question; // 返回旧数据,前端以为更新成功了
```
**风险**: 如果记录已被逻辑删除(`is_deleted=1`)MyBatis自动追加 `WHERE is_deleted=0` 导致匹配0行`updateById()` 返回 `false` 但方法正常返回。不会导致500但是**静默失败**。
---
## 四、前端Payload分析确认无误
前端 [QuestionBankPanel.vue](src/components/exam/QuestionBankPanel.vue#L720) 发送的数据:
```javascript
const updateData = {
question_type: "single_choice", // 匹配 @JsonProperty("question_type")
difficulty: 2, // 匹配 Integer
content: { // 匹配 Map<String, Object>
stem: "题干文本...",
data: {}, // 选择题有options否则为空对象
answer: "A" // 或 "T"/"F"/"答案1,答案2"/""
}
// 可选字段(仅在有值时发送):
// score: 10,
// document_name: "文件名.doc"
}
```
**结论**: 前端payload格式与后端 `QuestionUpdateRequest` DTO **完全匹配**,无格式错误。
---
## 五、最可能的根因Top 3 推断)
基于代码分析和错误特征,按可能性从高到低排列:
| 排名 | 根因 | 可能性 | 判断依据 |
|------|------|--------|---------|
| **#1** | **JacksonTypeHandler序列化content时抛出JsonProcessingException** | **60%** | content是唯一经过双重转换(Map→JsonNode→String)的字段裸ObjectMapper无特殊配置 |
| **#2** | **数据库层面DataIntegrityViolationException字段约束违反** | **25%** | score有decimal(5,2)约束content是NOT NULL JSON列 |
| **#3** | **其他RuntimeException被GlobalExceptionHandler统一吞为"服务器内部错误"** | **15%** | 兜底handler覆盖所有未专门处理的异常 |
---
## 六、修复方案
### 方案A增强GlobalExceptionHandler推荐 - 快速止血)
**修改文件**: `demo/src/main/java/top/tqx/demo_1/handler/GlobalExceptionHandler.java`
```java
// 新增1: 数据完整性约束异常 → 返回400而非500
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleDataIntegrityViolation(DataIntegrityViolationException e) {
String causeMsg = e.getMostSpecificCause().getMessage();
log.error("数据完整性违规: {}", causeMsg);
String userMsg = "数据格式不符合要求";
if (causeMsg != null) {
if (causeMsg.contains("Out of range")) userMsg = "数值超出允许范围";
else if (causeMsg.contains("Data too long")) userMsg = "输入内容过长";
else if (causeMsg.contains("not null")) userMsg = "必填字段不能为空";
}
return Result.error(400, userMsg);
}
// 新增2: 请求体读取失败 → 返回400而非500
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
log.error("请求体解析失败: {}", e.getMessage());
String msg = "请求数据格式错误";
if (e.getMessage() != null) {
if (e.getMessage().contains("JSON")) msg = "JSON格式不正确";
else if (e.getMessage().contains("content")) msg = "题目内容格式不正确";
}
return Result.error(400, msg);
}
// 新增3: JSON处理异常 → 返回具体原因
@ExceptionHandler(JsonProcessingException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleJsonProcessing(JsonProcessingException e) {
log.error("JSON序列化失败: {}", e.getOriginalMessage(), e);
return Result.error(400, "数据处理格式错误: " + e.getOriginalMessage());
}
// 修改4: 现有RuntimeException handler增加更多分类
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException e) {
log.error("运行时异常: type={}, message={}",
e.getClass().getSimpleName(), e.getMessage(), e);
String msg = e.getMessage() != null ? e.getMessage() : "未知错误";
if (msg.contains("SQL") || msg.contains("connection"))
return Result.error(500, "数据库操作失败");
if (msg.contains("Access Denied"))
return Result.error(403, "无权限访问");
if (msg.contains("Out of range"))
return Result.error(400, "数值超出允许范围");
if (msg.contains("Data too long"))
return Result.error(400, "输入内容过长");
// ★ 关键改进:将原始异常信息返回(开发环境)/ 脱敏返回(生产环境)
return Result.error(500, "操作失败: " + msg);
}
```
### 方案B在Service层添加防御性校验推荐 - 彻底修复)
**修改文件**: `demo/src/main/java/top/tqx/demo_1/service/impl/QuestionServiceImpl.java`
```java
@Override
public Question updateQuestion(Long id, QuestionUpdateRequest request) {
Question question = getById(id);
if (question == null) {
throw new IllegalArgumentException("题目不存在");
}
// ===== 新增: 输入校验 =====
// score范围校验
if (request.getScore() != null) {
BigDecimal maxScore = new BigDecimal("999.99");
BigDecimal minScore = new BigDecimal("-999.99");
if (request.getScore().compareTo(maxScore) > 0 ||
request.getScore().compareTo(minScore) < 0) {
throw new IllegalArgumentException(
"分值超出范围,允许范围: " + minScore + " ~ " + maxScore);
}
}
// 字符串长度校验
if (request.getKnowledgeBasePath() != null &&
request.getKnowledgeBasePath().length() > 500) {
throw new IllegalArgumentException("知识库路径过长最大允许500字符");
}
// question_type枚举校验
Set<String> validTypes = Set.of(
"single_choice", "multiple_choice", "true_false",
"fill_blank", "subjective"
);
if (request.getQuestionType() != null &&
!validTypes.contains(request.getQuestionType())) {
throw new IllegalArgumentException(
"无效的题目类型: " + request.getQuestionType());
}
// content非空校验
if (request.getContent() != null && request.getContent().isEmpty()) {
throw new IllegalArgumentException("题目内容不能为空");
}
// ===== 原有的逐字段set逻辑保持不变 =====
if (request.getQuestionType() != null) {
question.setQuestionType(request.getQuestionType());
}
// ... 其余不变 ...
// ===== 新增: updateById返回值检查 =====
boolean updated = updateById(question);
if (!updated) {
log.warn("题目更新影响行数为0可能已被删除: id={}", id);
throw new IllegalStateException("题目可能已被删除或归档");
}
return question;
}
```
### 方案C统一ObjectMapper配置推荐 - 架构优化)
**修改文件**: `QuestionServiceImpl.java`
```java
// 之前: 裸ObjectMapper第44行
private final ObjectMapper objectMapper = new ObjectMapper();
// 之后: 注入Spring管理的ObjectMapper与HTTP层一致
@Autowired
private ObjectMapper objectMapper;
// 或至少注册基础Module:
private final ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
```
---
## 七、实施计划
| 优先级 | 方案 | 工作量 | 效果 |
|--------|------|--------|------|
| **P0 - 立即** | **方案A** | 20min | 所有500错误都能看到真实原因 |
| **P1 - 今天** | **方案B** | 30min | 从源头阻止非法数据进入DB |
| **P2 - 本周** | **方案C** | 10min | 消除ObjectMapper配置不一致 |
**建议先实施方案A**这样下次再出现500错误时前端就能显示真实的异常信息`"数值超出允许范围"` / `"Out of range value for column 'score'"` / `"JSON序列化失败: ..."`),无需查看后端日志即可定位根因。
---
## 八、涉及的后端文件清单
| 文件 | 路径 | 角色 | 需要修改? |
|------|------|------|---------|
| `QuestionController.java` | `controller/QuestionController.java` | REST入口 | 否仅暴露e.getMessage()需修复) |
| `QuestionServiceImpl.java` | `service/impl/QuestionServiceImpl.java` | 业务核心 | **是**方案B+方案C |
| `QuestionUpdateRequest.java` | `dto/QuestionUpdateRequest.java` | DTO | 可选加JSR-303注解 |
| `Question.java` | `entity/Question.java` | 实体 | 否 |
| `GlobalExceptionHandler.java` | `handler/GlobalExceptionHandler.java` | 全局异常处理 | **是**方案A |
| `knowledge_management_system.sql` | `docs/sql/` | DDL | 否(参考用) |
---
## 九、附录:相关前端文件(已确认无问题)
| 文件 | 路径 | 状态 |
|------|------|------|
| `QuestionBankPanel.vue` | `src/components/exam/QuestionBankPanel.vue` | Payload格式正确 |
| `useQuestionBank.js` | `src/components/exam/composables/useQuestionBank.js` | ID映射正确(UUID) |
| `question.js`(API) | `src/api/question.js` | 调用方式正确(PUT) |