# 后端问题诊断报告：题目编辑保存 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) |
