fix: AI助评失败记录不再抛错卡死,PROCESSING超时可重试,报错信息精简

main
chenyuan 2 days ago
parent 15f1a399df
commit 247669f648

@ -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<String> illegalArgumentException(IllegalArgumentException e) {
log.error(ThrowableUtil.getStackTrace(e));
@ -61,9 +67,22 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Throwable.class)
public ResultEntity<String> 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
*/

@ -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 {

@ -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 &lt; DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE)))
</update>
<update id="claimAssessment">
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 &lt; DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE)))
</update>
<update id="claimReassessment">

@ -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<String> 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<String> 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<String> result = handler.nullPointerException(e);
assertShortAndClean(result.getBody().getMsg());
}
@Test
void ordinaryBusinessMessagesAreUnchanged() {
ResultEntity<String> 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);
}
}

@ -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<String> 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));

Loading…
Cancel
Save