Files
aue/后端问题诊断报告_题目保存HTTP500.md
2026-06-14 14:56:54 +08:00

449 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 后端问题诊断报告:题目编辑保存 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) |