6.12号版本
This commit is contained in:
448
后端问题诊断报告_题目保存HTTP500.md
Normal file
448
后端问题诊断报告_题目保存HTTP500.md
Normal 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引用对象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) |
|
||||
Reference in New Issue
Block a user