449 lines
19 KiB
Markdown
449 lines
19 KiB
Markdown
# 后端问题诊断报告:题目编辑保存 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引用对象B,B又引用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) |
|