AsyncExceptionConfig.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. package com.web.api.config;
  2. import com.alibaba.fastjson.JSONException;
  3. import com.google.common.collect.ImmutableMap;
  4. import com.web.api.dao.AnswerDao;
  5. import com.web.api.exception.ErrorCode;
  6. import com.web.api.exception.SgException;
  7. import com.web.api.model.dto.AnswerDto;
  8. import com.web.api.model.po.UserInfo;
  9. import com.web.api.service.mail.MailServiceImpl;
  10. import com.web.api.utils.ContextUtils;
  11. import lombok.extern.slf4j.Slf4j;
  12. import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
  13. import org.springframework.beans.factory.annotation.Autowired;
  14. import org.springframework.context.annotation.Configuration;
  15. import org.springframework.dao.DataAccessException;
  16. import org.springframework.scheduling.annotation.AsyncConfigurer;
  17. import org.thymeleaf.context.Context;
  18. import org.thymeleaf.spring5.SpringTemplateEngine;
  19. import javax.annotation.Resource;
  20. import java.io.IOException;
  21. import java.lang.reflect.Method;
  22. import java.text.DecimalFormat;
  23. import java.time.Duration;
  24. import java.time.LocalDateTime;
  25. import java.time.format.DateTimeFormatter;
  26. import java.util.Arrays;
  27. import java.util.HashSet;
  28. import java.util.Set;
  29. /**
  30. * @author 王玉鹏
  31. * @version 1.0
  32. * @className AsyncExceptionConfig
  33. * @description TODO
  34. * @date 2020/7/12 21:42
  35. */
  36. @Configuration
  37. @Slf4j
  38. public class AsyncExceptionConfig implements AsyncConfigurer {
  39. @Resource
  40. private AnswerDao answerDao;
  41. @Autowired
  42. private MailServiceImpl mailService;
  43. @Autowired
  44. private SpringTemplateEngine templateEngine;
  45. private static final Set<Class<? extends Throwable>> BUSINESS_EXCEPTIONS = new HashSet<Class<? extends Throwable>>() {{
  46. add(SgException.class);
  47. add(IOException.class);
  48. add(NullPointerException.class);
  49. add(JSONException.class);
  50. }};
  51. private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分");
  52. private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("######0.00");
  53. private static final String SERVER_IP = "http://42.192.203.166";
  54. private static final String[] DEFAULT_RECIPIENTS = {"251664727@qq.com", "632062365@qq.com"};
  55. @Override
  56. public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
  57. return new SpringAsyncExceptionHandler();
  58. }
  59. class SpringAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
  60. @Override
  61. public void handleUncaughtException(Throwable throwable, Method method, Object... params) {
  62. try {
  63. if (!BUSINESS_EXCEPTIONS.contains(throwable.getClass())) {
  64. log.error("------非业务类异常,此处不做处理--------: ", throwable);
  65. return;
  66. }
  67. validateParams(params);
  68. AnswerDto answerDto = (AnswerDto) params[2];
  69. Double startScore = (Double) params[3];
  70. //组装用户信息
  71. UserInfo userInfo = processUserInfo(answerDto, startScore, throwable);
  72. //更新数据库
  73. int updateCount = handleDatabaseOperation(userInfo);
  74. //发送邮件
  75. if (updateCount > 0) {
  76. sendNotificationEmail(answerDto, userInfo, startScore, throwable, updateCount);
  77. }
  78. cleanUpResources(answerDto, throwable);
  79. } catch (Exception e) {
  80. log.error("异常处理过程出错: ", e);
  81. } finally {
  82. log.error("异步任务异常详情: ", throwable);
  83. }
  84. }
  85. private void validateParams(Object[] params) {
  86. log.info("业务参数:{}", params);
  87. if (params.length < 4) {
  88. throw new IllegalArgumentException("参数数量不足,需要4个参数");
  89. }
  90. if (!(params[2] instanceof AnswerDto)) {
  91. throw new IllegalArgumentException("第三个参数应为AnswerDto类型");
  92. }
  93. if (!(params[3] instanceof Double)) {
  94. throw new IllegalArgumentException("第四个参数应为Double类型");
  95. }
  96. }
  97. private UserInfo processUserInfo(AnswerDto answerDto, Double startScore, Throwable throwable) {
  98. UserInfo userInfo = ContextUtils.getUserInfo(answerDto.getJobNo());
  99. userInfo.setEndTime(LocalDateTime.now().format(DATE_TIME_FORMATTER));
  100. userInfo.setAnswerStatus("已结束");
  101. if (userInfo.getFinalScore() != null) {
  102. double addScore = userInfo.getFinalScore() - startScore;
  103. userInfo.setAddScores(DECIMAL_FORMAT.format(addScore));
  104. } else {
  105. userInfo.setAddScores("本次答题异常终止,无法计算分数");
  106. }
  107. if (throwable instanceof SgException ) {
  108. userInfo.setErrMsg(throwable.getMessage());
  109. }else {
  110. userInfo.setErrMsg("本次答题异常终止");
  111. }
  112. handleBatchOperation(answerDto, userInfo);
  113. return userInfo;
  114. }
  115. /***
  116. * 关闭答题
  117. */
  118. private void handleBatchOperation(AnswerDto answerDto, UserInfo userInfo) {
  119. if (answerDto.getBatchFlag()) {
  120. ContextUtils.setBatchUserList(answerDto.getJobNo(), userInfo);
  121. }
  122. ContextUtils.setOnOff(answerDto.getJobNo(), false);
  123. }
  124. private int handleDatabaseOperation(UserInfo userInfo) {
  125. try {
  126. if (isValidAddScore(userInfo.getAddScores())) {
  127. log.info("更新答题记录:{}", userInfo);
  128. int updateCount = answerDao.updatePassRecord(userInfo);
  129. if (updateCount == 0) {
  130. log.error("数据库更新失败:{}", userInfo);
  131. }
  132. return updateCount;
  133. } else {
  134. answerDao.deletePassRecord(userInfo.getId());
  135. log.warn("无效分数,删除记录:{}", userInfo.getId());
  136. return 0;
  137. }
  138. } catch (DataAccessException e) {
  139. log.error("数据库操作异常:{}", e.getMessage());
  140. return 0;
  141. }
  142. }
  143. private boolean isValidAddScore(String addScore) {
  144. try {
  145. return Double.parseDouble(addScore) > 0.0;
  146. } catch (NumberFormatException e) {
  147. return false;
  148. }
  149. }
  150. private void sendNotificationEmail(AnswerDto answerDto, UserInfo userInfo,
  151. Double startScore, Throwable throwable,
  152. int updateCount) {
  153. try {
  154. String[] recipients = determineRecipients(answerDto); //收件人邮箱
  155. String subject = buildEmailSubject(throwable, userInfo); //邮件主题
  156. String content = buildEmailContent(answerDto, userInfo, startScore, throwable); //邮件内容
  157. if (updateCount > 0 && !userInfo.getAddScores().contains("异常终止")) {
  158. mailService.sendSimpleMail(recipients, subject, content);
  159. }
  160. } catch (Exception e) {
  161. log.error("邮件发送失败: ", e);
  162. }
  163. }
  164. private String[] determineRecipients(AnswerDto answerDto) {
  165. if (answerDto.getBatchFlag() && ContextUtils.getBatchUserList().size() > 10) {
  166. return new String[]{"251664727@qq.com"};
  167. }
  168. return DEFAULT_RECIPIENTS.clone();
  169. }
  170. private String buildEmailSubject(Throwable throwable, UserInfo userInfo) {
  171. return (throwable instanceof SgException) ?
  172. throwable.getMessage() :
  173. userInfo.getUserName() + "【" + userInfo.getJobNo() + "】答题异常结束";
  174. }
  175. public String buildEmailContent(AnswerDto answer, UserInfo user,
  176. Double startScore, Throwable ex) {
  177. Context ctx = new Context();
  178. // 用户信息
  179. ctx.setVariable("user", ImmutableMap.of(
  180. "userName", user.getUserName(),
  181. "jobNo", user.getJobNo(),
  182. "addScores", user.getAddScores(),
  183. "finalScore", user.getFinalScore()
  184. ));
  185. // 答题信息
  186. ctx.setVariable("answer", ImmutableMap.of(
  187. "batchFlag", answer.getBatchFlag(),
  188. "jobTypeName", answer.getJobTypeName()
  189. ));
  190. // 分数卡片数据
  191. ctx.setVariable("scores", Arrays.asList(
  192. ImmutableMap.of("type", "初始分数", "value", startScore, "color", "#2196F3"),
  193. ImmutableMap.of("type", "目标分数", "value", user.getTargetScore(), "color", "#FF9800")
  194. ));
  195. // 时间信息
  196. ctx.setVariable("times", Arrays.asList(
  197. ImmutableMap.of("label", "开始", "value", user.getStartTime()),
  198. ImmutableMap.of("label", "结束", "value", user.getEndTime()),
  199. ImmutableMap.of("label", "用时", "value", calculateElapsedTime(user))
  200. ));
  201. // 异常信息
  202. if (ex != null) {
  203. ctx.setVariable("exception", ImmutableMap.of(
  204. "message", ex.getMessage()
  205. ));
  206. }
  207. // 服务器IP
  208. ctx.setVariable("serverIp", SERVER_IP);
  209. return templateEngine.process("answer-report", ctx);
  210. }
  211. /**
  212. * 计算答题时常
  213. */
  214. private String calculateElapsedTime(UserInfo userInfo) {
  215. try {
  216. LocalDateTime start = LocalDateTime.parse(userInfo.getStartTime(), DATE_TIME_FORMATTER);
  217. LocalDateTime end = LocalDateTime.parse(userInfo.getEndTime(), DATE_TIME_FORMATTER);
  218. Duration duration = Duration.between(start, end);
  219. return String.format("%d天%d时%d分", duration.toDays(), duration.toHours() % 24, duration.toMinutes() % 60);
  220. } catch (Exception e) {
  221. return "时间计算错误";
  222. }
  223. }
  224. /**
  225. * 非手动结束情况下清楚用户缓存信息
  226. */
  227. private void cleanUpResources(AnswerDto answerDto, Throwable throwable) {
  228. if (!isManualStop(throwable)) {
  229. ContextUtils.removeUserCookies(answerDto.getJobNo());
  230. ContextUtils.removeUserInfo(answerDto.getJobNo());
  231. log.info("已清理用户资源:{}", answerDto.getJobNo());
  232. }
  233. }
  234. private boolean isManualStop(Throwable throwable) {
  235. return throwable instanceof SgException &&
  236. ((SgException) throwable).getCode() == ErrorCode.STOP_ANSWER.getCode();
  237. }
  238. /* private String buildEmailContent(AnswerDto answerDto, UserInfo userInfo,
  239. Double startScore, Throwable throwable) {
  240. String elapsedTime = calculateElapsedTime(userInfo);
  241. // 增强的响应式样式
  242. String mediaQuery = "@media only screen and (max-width:480px) { "
  243. + ".mobile-adjust { padding:12px 15px !important; } "
  244. + ".section-title { font-size:18px !important; margin-bottom:8px !important; } "
  245. + ".info-card { padding:12px !important; margin-bottom:16px !important; } "
  246. + ".score-container { gap:10px !important; margin-bottom:16px !important; } "
  247. + ".score-box { padding:12px !important; margin-bottom:12px !important; } "
  248. + ".final-score { padding:16px !important; margin:12px 0 !important; } "
  249. + ".time-record td { padding:4px 0 !important; } "
  250. + ".system-alert { margin:16px 0 !important; padding:12px !important; } "
  251. + ".action-button { padding:12px 24px !important; font-size:14px !important; } "
  252. + "}";
  253. return String.format("<html>"
  254. + "<head>"
  255. + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
  256. + "<style>%s</style>"
  257. + "</head>"
  258. + "<body style=\"margin:0; padding:20px 0; background:#f5f5f5;\">"
  259. + "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"%s\">"
  260. + "<tr><td class=\"mobile-adjust\" style=\"padding:20px; background:white;\">"
  261. // 头部信息
  262. + "<div style=\"border-bottom:2px solid #2196F3; padding-bottom:12px; margin-bottom:16px;\">"
  263. + "<h2 class=\"section-title\" style=\"margin:0; font-size:24px;\">✅ 答题结束通知</h2>"
  264. + "<p style=\"color:#7f8c8d; margin:6px 0; font-size:14px;\">"
  265. + "用户:<strong>%s</strong> | 工号:%s"
  266. + "</p>"
  267. + "</div>"
  268. // 答题概览
  269. + "<div style=\"margin-bottom:20px;\">"
  270. + "<div class=\"section-title\" style=\"color:#2c3e50; font-size:20px; margin-bottom:12px;\">📝 答题概览</div>"
  271. + "<div class=\"info-card\" style=\"background:#f8f9fa; padding:15px; border-radius:8px;\">"
  272. + "<p style=\"margin:4px 0; font-size:14px;\">"
  273. + "<span style=\"color:#7f8c8d;\">模式:</span>%s"
  274. + "</p>"
  275. + "<p style=\"margin:4px 0; font-size:14px;\">"
  276. + "<span style=\"color:#7f8c8d;\">工种:</span>"
  277. + "<span style=\"color:#2196F3; font-weight:600;\">%s</span>"
  278. + "</p>"
  279. + "</div>"
  280. + "</div>"
  281. // 分数区块
  282. + "<div class=\"score-container\" style=\"display:flex; gap:15px; margin-bottom:20px;\">"
  283. + "<div class=\"score-box\" style=\"flex:1; background:#f3f5f7; padding:15px; border-radius:8px; box-sizing:border-box;\">"
  284. + "<p style=\"margin:2px 0; color:#7f8c8d; font-size:14px;\">初始分数</p>"
  285. + "<p style=\"font-size:24px; margin:6px 0; color:#2196F3;\">%.1f</p>"
  286. + "</div>"
  287. + "<div class=\"score-box\" style=\"flex:1; background:#f3f5f7; padding:15px; border-radius:8px; box-sizing:border-box;\">"
  288. + "<p style=\"margin:2px 0; color:#7f8c8d; font-size:14px;\">目标分数</p>"
  289. + "<p style=\"font-size:24px; margin:6px 0; color:#FF9800;\">%s</p>"
  290. + "</div>"
  291. + "</div>"
  292. // 增长分数
  293. + "<div class=\"score-box\" style=\"text-align:center; background:#4CAF50; padding:15px; border-radius:8px;\">"
  294. + "<p style=\"margin:0; color:white; font-size:16px; font-weight:bold;\">"
  295. + "+%s 分数增长"
  296. + "</p>"
  297. + "</div>"
  298. // 最终分数
  299. + "<div class=\"final-score\" style=\"text-align:center; background:white; padding:20px; margin:20px 0; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,0.1);\">"
  300. + "<p style=\"margin:4px 0; color:#7f8c8d; font-size:14px;\">最终得分</p>"
  301. + "<p style=\"font-size:32px; margin:8px 0; color:#4CAF50; font-weight:800;\">%s</p>"
  302. + "</div>"
  303. // 时间记录
  304. + "<div>"
  305. + "<div class=\"section-title\" style=\"color:#2c3e50; font-size:20px; margin:12px 0;\">⏱ 时间记录</div>"
  306. + "<table class=\"time-record\" style=\"width:100%%; font-size:14px;\">"
  307. + "<tr><td style=\"padding:4px 0; color:#2196F3;\">▸</td><td style=\"padding:4px 0;\">开始:%s</td></tr>"
  308. + "<tr><td style=\"padding:4px 0; color:#2196F3;\">▸</td><td style=\"padding:4px 0;\">结束:%s</td></tr>"
  309. + "<tr><td style=\"padding:4px 0; color:#2196F3;\">▸</td><td style=\"padding:4px 0;\">用时:%s</td></tr>"
  310. + "</table>"
  311. + "</div>"
  312. // 系统提示
  313. + "<div class=\"system-alert\" style=\"margin:25px 0; padding:15px; background:#fff3e0; border-radius:8px;\">"
  314. + "<p style=\"margin:4px 0; color:#EF6C00; font-size:14px;\">%s</p>"
  315. + "</div>"
  316. // 操作按钮
  317. + "<div style=\"text-align:center; margin-top:30px;\">"
  318. + "<a href=\"%s/answer\" class=\"action-button\" "
  319. + "style=\"display:inline-block; background:#2196F3; color:white!important; "
  320. + "padding:16px 40px; text-decoration:none; border-radius:30px; font-size:16px; "
  321. + "letter-spacing:1px; min-width:200px;\">🚀 前往答题系统查看</a>"
  322. + "</div>"
  323. + "</td></tr>"
  324. + "</table>"
  325. + "</body></html>",
  326. // 参数列表
  327. mediaQuery,
  328. "width:100% !important; max-width:600px; margin:0 auto; font-family:'Segoe UI',Tahoma,sans-serif; border-collapse:collapse;",
  329. userInfo.getUserName(),
  330. userInfo.getJobNo(),
  331. answerDto.getBatchFlag() ? "批量模式" : "单用户模式",
  332. answerDto.getJobTypeName(),
  333. startScore,
  334. userInfo.getTargetScore(),
  335. userInfo.getAddScores(),
  336. userInfo.getFinalScore(),
  337. userInfo.getStartTime(),
  338. userInfo.getEndTime(),
  339. elapsedTime,
  340. throwable != null ? "终止事件:" + throwable.getMessage() : "异常终止",
  341. SERVER_IP
  342. );
  343. }*/
  344. }
  345. }