From 247669f648003f362af3aa8f4712e582f09bc526 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 12 Oct 2026 11:41:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20AI=E5=8A=A9=E8=AF=84=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=8D=E5=86=8D=E6=8A=9B=E9=94=99=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=EF=BC=8CPROCESSING=E8=B6=85=E6=97=B6=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E8=AF=95=EF=BC=8C=E6=8A=A5=E9=94=99=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=B2=BE=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GlobalExceptionHandler.java | 19 +++ .../impl/AiTrainingEvaluationServiceImpl.java | 69 ++++++++-- .../mappers/AiTrainingEvaluationMapper.xml | 6 +- .../handler/GlobalExceptionHandlerTest.java | 59 +++++++++ .../AiTrainingEvaluationServiceImplTest.java | 118 ++++++++++++++++++ 5 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandlerTest.java diff --git a/src/main/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandler.java index ea7f466..aeb6076 100644 --- a/src/main/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandler.java @@ -4,6 +4,8 @@ import com.sztzjy.linkCommerce.config.exception.UnAuthorizedException; import com.sztzjy.linkCommerce.util.ResultEntity; import com.sztzjy.linkCommerce.util.ThrowableUtil; import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.exceptions.PersistenceException; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.BadCredentialsException; @@ -13,6 +15,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.sql.SQLException; + /** * 全局异常处理 */ @@ -20,6 +24,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { + private static final String DATABASE_ERROR_MESSAGE = "系统繁忙,请稍后重试"; + @ExceptionHandler(IllegalArgumentException.class) public ResultEntity illegalArgumentException(IllegalArgumentException e) { log.error(ThrowableUtil.getStackTrace(e)); @@ -61,9 +67,22 @@ public class GlobalExceptionHandler { @ExceptionHandler(Throwable.class) public ResultEntity nullPointerException(Throwable e) { log.error(ThrowableUtil.getStackTrace(e)); + if (isDatabaseException(e)) { + // 数据库异常原文包含完整 SQL 和表结构,只记日志,不返回给前端 + return new ResultEntity<>(HttpStatus.BAD_REQUEST, DATABASE_ERROR_MESSAGE); + } return new ResultEntity<>(HttpStatus.BAD_REQUEST, e.getMessage()); } + private static boolean isDatabaseException(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause() == t ? null : t.getCause()) { + if (t instanceof DataAccessException || t instanceof PersistenceException || t instanceof SQLException) { + return true; + } + } + return false; + } + /** * BadCredentialsException */ diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java index 889593f..382665e 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java @@ -21,6 +21,7 @@ import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper; import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService; import com.sztzjy.linkCommerce.service.TrainingKnowledgeService; import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -34,11 +35,16 @@ import java.util.Date; import java.util.List; import java.util.UUID; +@Slf4j @Service public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationService { private static final String NOT_STARTED = "NOT_STARTED"; private static final String PROCESSING = "PROCESSING"; private static final String SUCCEEDED = "SUCCEEDED"; + private static final String FAILURE_MESSAGE = "AI服务暂时不可用,请稍后重试"; + // 与 AiTrainingEvaluationMapper.xml 中 claimHelp/claimAssessment 的 INTERVAL 10 MINUTE 保持一致 + private static final long PROCESSING_TIMEOUT_MILLIS = 10 * 60 * 1000L; + private static final int LOGGED_RAW_RESPONSE_LENGTH = 500; private static final int MAX_ERROR_LENGTH = 1000; private static final int MAX_HELP_QUESTION_LENGTH = 500; private static final int MAX_STEP_CONTEXT_LENGTH = 2000; @@ -82,7 +88,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ String normalizedStepContext = normalizeStepContext(stepContext); EvaluationContext context = resolveContext(taskKey, user, true); AiTrainingEvaluation evaluation = getOrCreate(context, user); - if (PROCESSING.equals(evaluation.getHelpStatus())) { + if (PROCESSING.equals(evaluation.getHelpStatus()) && !isProcessingTimedOut(evaluation)) { return toView(evaluation); } if (aiTrainingEvaluationMapper.claimHelp(evaluation.getId(), new Date()) != 1) { @@ -110,7 +116,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ completed.setAssessmentStatus(evaluation.getAssessmentStatus()); return toView(completed); } catch (RuntimeException e) { - aiTrainingEvaluationMapper.failHelp(evaluation.getId(), cappedMessage(e), new Date()); + recordHelpFailure(evaluation.getId(), e); throw asServiceException(e); } } @@ -119,7 +125,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ public AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user) { EvaluationContext context = resolveContext(taskKey, user, true); AiTrainingEvaluation evaluation = getOrCreate(context, user); - if (PROCESSING.equals(evaluation.getAssessmentStatus())) { + if (PROCESSING.equals(evaluation.getAssessmentStatus()) && !isProcessingTimedOut(evaluation)) { return toView(evaluation); } String answerSnapshot = answerSnapshot(context.answer); @@ -159,7 +165,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ completed.setAssessmentStatus(SUCCEEDED); return toView(completed); } catch (RuntimeException e) { - aiTrainingEvaluationMapper.failAssessment(evaluation.getId(), cappedMessage(e), rawResponse, new Date()); + recordAssessmentFailure(evaluation.getId(), e, rawResponse); throw asServiceException(e); } } @@ -548,16 +554,57 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ return view; } - private String cappedMessage(RuntimeException exception) { - String message = StringUtils.defaultIfBlank(exception.getMessage(), "AI evaluation failed"); - return StringUtils.left(message, MAX_ERROR_LENGTH); + /** + * 落库的失败原因:只保存我们自己抛出的 ServiceException 文案, + * 其余异常(数据库、网络等)的原文只写日志,避免把 SQL 等长文本存进库或带给前端。 + */ + private String storedFailureMessage(RuntimeException exception) { + if (exception instanceof ServiceException && StringUtils.isNotBlank(exception.getMessage())) { + return StringUtils.left(exception.getMessage(), MAX_ERROR_LENGTH); + } + return FAILURE_MESSAGE; } - private ServiceException asServiceException(RuntimeException exception) { - if (exception instanceof ServiceException) { - return (ServiceException) exception; + /** + * 记录失败状态本身出错时不能再往外抛:否则会盖掉真正的失败原因, + * 并让记录一直停在 PROCESSING,学生无法重试。 + */ + private void recordHelpFailure(String evaluationId, RuntimeException cause) { + log.warn("AI help failed, evaluationId={}", evaluationId, cause); + try { + aiTrainingEvaluationMapper.failHelp(evaluationId, storedFailureMessage(cause), new Date()); + } catch (RuntimeException recordError) { + log.error("Could not record AI help failure, evaluationId={}", evaluationId, recordError); + } + } + + private void recordAssessmentFailure(String evaluationId, RuntimeException cause, String rawResponse) { + log.warn("AI assessment failed, evaluationId={}", evaluationId, cause); + String message = storedFailureMessage(cause); + try { + aiTrainingEvaluationMapper.failAssessment(evaluationId, message, rawResponse, new Date()); + return; + } catch (RuntimeException recordError) { + log.warn("Could not record AI assessment failure with the raw response, retrying without it, evaluationId={}, rawResponse={}", + evaluationId, StringUtils.left(rawResponse, LOGGED_RAW_RESPONSE_LENGTH), recordError); } - return new ServiceException(HttpStatus.BAD_GATEWAY, "AI evaluation failed"); + try { + aiTrainingEvaluationMapper.failAssessment(evaluationId, message, null, new Date()); + } catch (RuntimeException recordError) { + log.error("Could not record AI assessment failure, evaluationId={}", evaluationId, recordError); + } + } + + private boolean isProcessingTimedOut(AiTrainingEvaluation evaluation) { + Date updatedAt = evaluation.getUpdateTime(); + return updatedAt != null && System.currentTimeMillis() - updatedAt.getTime() > PROCESSING_TIMEOUT_MILLIS; + } + + private ServiceException asServiceException(RuntimeException exception) { + HttpStatus status = exception instanceof ServiceException && ((ServiceException) exception).getCode() != null + ? ((ServiceException) exception).getCode() + : HttpStatus.BAD_GATEWAY; + return new ServiceException(status, FAILURE_MESSAGE); } private static class AssessmentCriterionPlan { diff --git a/src/main/resources/mappers/AiTrainingEvaluationMapper.xml b/src/main/resources/mappers/AiTrainingEvaluationMapper.xml index a1f0d10..82fff32 100644 --- a/src/main/resources/mappers/AiTrainingEvaluationMapper.xml +++ b/src/main/resources/mappers/AiTrainingEvaluationMapper.xml @@ -61,14 +61,16 @@ UPDATE ai_training_evaluation SET help_status = 'PROCESSING', help_error_message = NULL, update_time = #{updateTime,jdbcType=TIMESTAMP} WHERE id = #{id,jdbcType=VARCHAR} - AND help_status IN ('NOT_STARTED', 'FAILED', 'SUCCEEDED') + AND (help_status IN ('NOT_STARTED', 'FAILED', 'SUCCEEDED') + OR (help_status = 'PROCESSING' AND update_time < DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE))) UPDATE ai_training_evaluation SET assessment_status = 'PROCESSING', assessment_error_message = NULL, update_time = #{updateTime,jdbcType=TIMESTAMP} WHERE id = #{id,jdbcType=VARCHAR} - AND assessment_status IN ('NOT_STARTED', 'FAILED') + AND (assessment_status IN ('NOT_STARTED', 'FAILED') + OR (assessment_status = 'PROCESSING' AND update_time < DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE))) diff --git a/src/test/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandlerTest.java b/src/test/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..704ddb6 --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/config/exception/handler/GlobalExceptionHandlerTest.java @@ -0,0 +1,59 @@ +package com.sztzjy.linkCommerce.config.exception.handler; + +import com.sztzjy.linkCommerce.util.ResultEntity; +import org.apache.ibatis.exceptions.PersistenceException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.BadSqlGrammarException; + +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GlobalExceptionHandlerTest { + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void databaseExceptionsReturnShortMessageWithoutSql() { + PersistenceException e = new PersistenceException( + "\n### Error updating database. Cause: java.sql.SQLException: Incorrect string value" + + "\n### SQL: UPDATE ai_training_evaluation SET assessment_raw_response = ?"); + + ResultEntity result = handler.nullPointerException(e); + + assertShortAndClean(result.getBody().getMsg()); + } + + @Test + void springJdbcExceptionsReturnShortMessageWithoutSql() { + BadSqlGrammarException e = new BadSqlGrammarException("PreparedStatementCallback", + "select p.question from platform_question p", new SQLException("syntax error near 'x'")); + + ResultEntity result = handler.nullPointerException(e); + + assertShortAndClean(result.getBody().getMsg()); + } + + @Test + void wrappedSqlExceptionIsAlsoShortened() { + RuntimeException e = new RuntimeException("### Could not execute: select * from ai_training_evaluation", + new SQLException("Incorrect string value: '\\xC9'")); + + ResultEntity result = handler.nullPointerException(e); + + assertShortAndClean(result.getBody().getMsg()); + } + + @Test + void ordinaryBusinessMessagesAreUnchanged() { + ResultEntity result = handler.nullPointerException(new RuntimeException("考试不存在或无权访问")); + + assertEquals("考试不存在或无权访问", result.getBody().getMsg()); + } + + private static void assertShortAndClean(String message) { + assertTrue(message != null && message.length() <= 30, "message: " + message); + assertTrue(!message.contains("###") && !message.contains("SQL") && !message.contains("select "), + "internal detail leaked: " + message); + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java index 95d6f09..3293d17 100644 --- a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java @@ -501,6 +501,124 @@ class AiTrainingEvaluationServiceImplTest { verify(service.qwenChatClient, never()).completeJson(any(), any()); } + @Test + void recentProcessingAssessmentIsNotReclaimed() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "PROCESSING"); + evaluation.setUpdateTime(minutesAgo(1)); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation); + + AiTrainingEvaluationView view = service.generateAssessment("new-product-survey", student()); + + assertEquals("PROCESSING", view.getAssessmentStatus()); + verify(service.aiTrainingEvaluationMapper, never()).claimAssessment(any(), any(Date.class)); + verify(service.qwenChatClient, never()).completeJson(any(), any()); + } + + @Test + void staleProcessingAssessmentIsReclaimedSoStudentIsNotStuckForever() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "PROCESSING"); + evaluation.setUpdateTime(minutesAgo(11)); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation); + when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(assessmentJson()); + when(service.aiTrainingEvaluationMapper.completeAssessment(any(AiTrainingEvaluation.class))).thenReturn(1); + when(service.studentTrainingAnswerMapper.updateAiAssessmentScore(eq("answer-1"), eq(86), any(Date.class))).thenReturn(1); + + AiTrainingEvaluationView view = service.generateAssessment("new-product-survey", student()); + + assertEquals(Integer.valueOf(86), view.getAssessmentScore()); + verify(service.aiTrainingEvaluationMapper).claimAssessment(eq("evaluation-1"), any(Date.class)); + } + + @Test + void failureRecordingErrorFallsBackToShortRecordAndKeepsStudentRetryable() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + String invalidResponse = assessmentJson().replace("\"maxScore\":25", "\"maxScore\":24"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(invalidResponse); + when(service.aiTrainingEvaluationMapper.failAssessment(eq("evaluation-1"), any(), eq(invalidResponse), any(Date.class))) + .thenThrow(new org.apache.ibatis.exceptions.PersistenceException(longDatabaseMessage())); + when(service.aiTrainingEvaluationMapper.failAssessment(eq("evaluation-1"), any(), isNull(), any(Date.class))).thenReturn(1); + + ServiceException thrown = assertThrows(ServiceException.class, + () -> service.generateAssessment("new-product-survey", student())); + + assertShortFriendlyMessage(thrown.getMessage()); + verify(service.aiTrainingEvaluationMapper).failAssessment(eq("evaluation-1"), any(), isNull(), any(Date.class)); + } + + @Test + void failureRecordingBothAttemptsFailingStillReturnsShortError() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenThrow(new IllegalStateException("upstream")); + when(service.aiTrainingEvaluationMapper.failAssessment(any(), any(), any(), any(Date.class))) + .thenThrow(new org.apache.ibatis.exceptions.PersistenceException(longDatabaseMessage())); + + ServiceException thrown = assertThrows(ServiceException.class, + () -> service.generateAssessment("new-product-survey", student())); + + assertShortFriendlyMessage(thrown.getMessage()); + } + + @Test + void helpFailureRecordingErrorStillReturnsShortError() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimHelp(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenThrow(new IllegalStateException("upstream")); + when(service.aiTrainingEvaluationMapper.failHelp(any(), any(), any(Date.class))) + .thenThrow(new org.apache.ibatis.exceptions.PersistenceException(longDatabaseMessage())); + + ServiceException thrown = assertThrows(ServiceException.class, + () -> service.generateHelp("new-product-survey", student(), "如何完善调研证据?")); + + assertShortFriendlyMessage(thrown.getMessage()); + } + + @Test + void storedFailureMessageNeverContainsInternalExceptionText() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenThrow(new IllegalStateException(longDatabaseMessage())); + when(service.aiTrainingEvaluationMapper.failAssessment(any(), any(), any(), any(Date.class))).thenReturn(1); + + assertThrows(ServiceException.class, () -> service.generateAssessment("new-product-survey", student())); + + ArgumentCaptor stored = ArgumentCaptor.forClass(String.class); + verify(service.aiTrainingEvaluationMapper).failAssessment(eq("evaluation-1"), stored.capture(), any(), any(Date.class)); + assertShortFriendlyMessage(stored.getValue()); + } + + private static String longDatabaseMessage() { + return "\n### Error updating database. Cause: java.sql.SQLException: Incorrect string value" + + "\n### SQL: UPDATE ai_training_evaluation SET assessment_raw_response = ? WHERE id = ?"; + } + + private static void assertShortFriendlyMessage(String message) { + assertNotNull(message); + assertTrue(message.length() <= 60, "message too long: " + message); + assertTrue(!message.contains("###") && !message.contains("SQL") && !message.contains("Exception"), + "internal detail leaked: " + message); + } + + private static Date minutesAgo(int minutes) { + return new Date(System.currentTimeMillis() - minutes * 60_000L); + } + @Test void rejectsNonStudentJwtUser() { AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));