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

19 KiB
Raw Blame History

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

代码:

// 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

数据库定义:

score decimal(5, 2) NOT NULL   -- 范围: -999.99 ~ 999.99

Java实体: private BigDecimal score; (无范围限制)

触发条件:

// 前端发送:
{ "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

数据库定义:

knowledge_base_path varchar(500) NULL

触发条件: 知识库路径超过500字符虽然罕见但可能发生


崩溃点 #4 [中危级] GlobalExceptionHandler 吞掉真实异常信息

位置: GlobalExceptionHandler.java:84-94

@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 — 无此处理器

缺失的异常处理:

// 当前没有以下处理器:
@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

question.setUpdatedAt(LocalDateTime.now());
updateById(question);   // ★ 返回 boolean但未检查! ★
return question;        // 返回旧数据,前端以为更新成功了

风险: 如果记录已被逻辑删除(is_deleted=1)MyBatis自动追加 WHERE is_deleted=0 导致匹配0行updateById() 返回 false 但方法正常返回。不会导致500但是静默失败


四、前端Payload分析确认无误

前端 QuestionBankPanel.vue 发送的数据:

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

// 新增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

@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

// 之前: 裸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)