# 后端问题诊断报告:题目编辑保存 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 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 content; // DTO类型: Map // QuestionServiceImpl.java L206-207 if (request.getContent() != null) { question.setContent(objectMapper.valueToTree(request.getContent())); // ↑ Map → 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 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 handle(HttpMessageNotReadableException e) { return Result.error(400, "请求体格式错误"); } @ExceptionHandler(DataIntegrityViolationException.class) public Result 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 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 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 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 handleJsonProcessing(JsonProcessingException e) { log.error("JSON序列化失败: {}", e.getOriginalMessage(), e); return Result.error(400, "数据处理格式错误: " + e.getOriginalMessage()); } // 修改4: 现有RuntimeException handler增加更多分类 @ExceptionHandler(RuntimeException.class) public Result 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 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) |