package apijson.boot; import apijson.Log; import apijson.demo.DemoParser; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientResponseException; import java.math.BigDecimal; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import static org.springframework.http.HttpHeaders.COOKIE; import static org.springframework.http.HttpHeaders.SET_COOKIE; @Service public class AutoTestRegressionScheduler { private static final String TAG = "AutoTestRegressionScheduler"; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final AutoTestNodeProcessSupervisor nodeProcessSupervisor; private final ObjectProvider mailSenderProvider; private final AtomicBoolean running = new AtomicBoolean(false); @Value("${apijson.auto-test.enabled:true}") private boolean enabled; @Value("${apijson.auto-test.start-url:http://localhost:3000/test/start}") private String startUrl; @Value("${apijson.auto-test.status-url:http://localhost:3000/test/status}") private String statusUrl; @Value("${apijson.auto-test.report-host:http://localhost:8080}") private String reportHost; @Value("${apijson.auto-test.poll-interval-millis:15000}") private long pollIntervalMillis; @Value("${apijson.auto-test.timeout-minutes:180}") private long timeoutMinutes; @Value("${apijson.auto-test.mail.from:}") private String mailFrom; @Value("${apijson.auto-test.mail.to:}") private String mailTo; @Value("${apijson.auto-test.mail.subject-prefix:[APIJSON]}") private String subjectPrefix; @Value("${apijson.auto-test.zone:Asia/Shanghai}") private String zone; public AutoTestRegressionScheduler(AutoTestNodeProcessSupervisor nodeProcessSupervisor, ObjectProvider mailSenderProvider) { this.nodeProcessSupervisor = nodeProcessSupervisor; this.mailSenderProvider = mailSenderProvider; } @Scheduled(cron = "${apijson.auto-test.cron:0 0 10,20 * * *}", zone = "${apijson.auto-test.zone:Asia/Shanghai}") public void runScheduledApiRegression() { if (!enabled) { return; } if (!running.compareAndSet(false, true)) { Log.w(TAG, "已有 API 回归测试任务在执行,跳过本次调度"); return; } TestExecutionResult result; try { nodeProcessSupervisor.ensureRunning(); result = executeRegressionTest(resolveZoneId()); } catch (Throwable e) { e.printStackTrace(); result = TestExecutionResult.failed(resolveZoneId(), extractErrorMessage(e)); } try { sendReportEmail(result); } finally { running.set(false); } } private TestExecutionResult executeRegressionTest(ZoneId zoneId) { TestExecutionResult result = new TestExecutionResult(); result.startedAt = LocalDateTime.now(zoneId); result.key = buildTestKey(); BackgroundHttpSession session = new BackgroundHttpSession("auto-api-test-" + UUID.randomUUID()); DemoParser.KEY_MAP.put(result.key, session); try { doGet(session, appendQueryParam(startUrl, "key", result.key)); long deadline = System.currentTimeMillis() + Math.max(timeoutMinutes, 1L) * 60_000L; while (System.currentTimeMillis() < deadline) { result.statusResponseBody = doGet(session, statusUrl); result.status = parseObject(result.statusResponseBody); if (result.status != null) { result.progress = readDecimal(result.status, "progress"); result.reportId = readLong(result.status, "reportId"); if (result.progress != null && result.progress.compareTo(BigDecimal.ONE) >= 0) { break; } } sleep(Math.max(pollIntervalMillis, 1000L)); } if (result.progress == null || result.progress.compareTo(BigDecimal.ONE) < 0) { result.errorMessage = "轮询 /api/test/status 超时," + timeoutMinutes + " 分钟内 progress 未达到 1"; } } catch (Throwable e) { result.errorMessage = extractErrorMessage(e); Log.e(TAG, "执行定时 API 回归测试失败: " + result.errorMessage); } finally { DemoParser.KEY_MAP.remove(result.key); try { session.invalidate(); } catch (Throwable ignore) { } result.finishedAt = LocalDateTime.now(zoneId); } Log.d(TAG, "定时 API 回归测试结束: " + buildBriefDescription(result)); return result; } private String doGet(HttpSession session, String url) { HttpHeaders headers = new HttpHeaders(); headers.setAccept(List.of(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN)); List cookies = currentCookies(session); if (!cookies.isEmpty()) { headers.put(COOKIE, new ArrayList<>(cookies)); } try { ResponseEntity entity = DemoController.CLIENT.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class); storeCookies(session, entity.getHeaders()); return entity.getBody(); } catch (RestClientResponseException e) { storeCookies(session, e.getResponseHeaders()); String responseBody = e.getResponseBodyAsString(); throw new IllegalStateException(isBlank(responseBody) ? e.getMessage() : responseBody, e); } } private void storeCookies(HttpSession session, HttpHeaders headers) { if (session == null || headers == null) { return; } List setCookies = headers.get(SET_COOKIE); if (setCookies != null && !setCookies.isEmpty()) { session.setAttribute(COOKIE, new ArrayList<>(setCookies)); } } private List currentCookies(HttpSession session) { if (session == null) { return Collections.emptyList(); } Object cookieObj = session.getAttribute(COOKIE); if (!(cookieObj instanceof List rawList)) { return Collections.emptyList(); } List cookies = new ArrayList<>(); for (Object item : rawList) { if (item != null) { cookies.add(String.valueOf(item)); } } return cookies; } private void sendReportEmail(TestExecutionResult result) { String[] recipients = parseRecipients(mailTo); if (recipients.length <= 0) { Log.w(TAG, "未配置 apijson.auto-test.mail.to,跳过发送测试报告邮件"); return; } JavaMailSender mailSender = mailSenderProvider.getIfAvailable(); if (mailSender == null) { Log.e(TAG, "未找到 JavaMailSender,无法发送测试报告邮件"); return; } try { var message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name()); if (!isBlank(mailFrom)) { helper.setFrom(mailFrom.trim()); } helper.setTo(recipients); helper.setSubject(buildMailSubject(result)); helper.setText(buildMailHtml(result), true); mailSender.send(message); } catch (Throwable e) { e.printStackTrace(); Log.e(TAG, "发送 API 回归测试邮件失败: " + extractErrorMessage(e)); } } private String buildMailSubject(TestExecutionResult result) { StringBuilder subject = new StringBuilder(); if (!isBlank(subjectPrefix)) { subject.append(subjectPrefix.trim()).append(' '); } subject.append("API 接口回归测试"); subject.append(result.errorMessage == null ? "完成" : "失败"); if (result.reportId != null) { subject.append(" - reportId=").append(result.reportId); } return subject.toString(); } private String buildMailHtml(TestExecutionResult result) { StringBuilder html = new StringBuilder(); html.append("

").append(escapeHtml(buildBriefDescription(result))).append("

"); html.append("
    "); appendListItem(html, "开始时间", formatTime(result.startedAt)); appendListItem(html, "结束时间", formatTime(result.finishedAt)); appendListItem(html, "耗时", formatDuration(result.duration())); appendListItem(html, "progress", formatProgress(result.progress)); appendListItem(html, "reportId", result.reportId); appendListItem(html, "状态消息", firstLine(result.message())); appendListItem(html, "异常信息", result.errorMessage); html.append("
"); String reportUrl = buildReportUrl(result); if (!isBlank(reportUrl)) { html.append("

") .append(escapeHtml(reportUrl)) .append("

"); } return html.toString(); } private void appendListItem(StringBuilder html, String label, Object value) { if (value == null) { return; } String text = String.valueOf(value); if (isBlank(text)) { return; } html.append("
  • ") .append(escapeHtml(label)) .append(": ") .append(escapeHtml(text)) .append("
  • "); } private String buildBriefDescription(TestExecutionResult result) { StringBuilder description = new StringBuilder(result.errorMessage == null ? "定时 API 接口回归测试已完成" : "定时 API 接口回归测试执行失败"); String msg = firstLine(result.message()); if (!isBlank(msg)) { description.append(",").append(msg); } if (result.progress != null) { description.append(",progress=").append(formatProgress(result.progress)); } if (result.reportId != null) { description.append(",reportId=").append(result.reportId); } return description.toString(); } private String buildReportUrl(TestExecutionResult result) { if (result.reportId != null && result.reportId > 0) { String host = trimTrailingSlash(reportHost); return host + "/api/index.html?reportId=" + URLEncoder.encode(String.valueOf(result.reportId), StandardCharsets.UTF_8); } return result.link(); } private String[] parseRecipients(String rawRecipients) { if (isBlank(rawRecipients)) { return new String[0]; } List recipients = new ArrayList<>(); String[] arr = rawRecipients.split("[,;\\s]+"); for (String item : arr) { if (!isBlank(item)) { recipients.add(item.trim()); } } return recipients.toArray(String[]::new); } private String appendQueryParam(String url, String key, String value) { String delimiter = url.contains("?") ? "&" : "?"; return url + delimiter + key + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8); } private JSONObject parseObject(String body) { if (isBlank(body)) { return null; } try { return JSON.parseObject(body); } catch (Throwable e) { return null; } } private BigDecimal readDecimal(JSONObject object, String key) { if (object == null) { return null; } Object value = object.get(key); if (value == null) { return null; } try { return new BigDecimal(String.valueOf(value)); } catch (Throwable e) { return null; } } private Long readLong(JSONObject object, String key) { if (object == null) { return null; } Object value = object.get(key); if (value == null) { return null; } try { return Long.parseLong(String.valueOf(value)); } catch (Throwable e) { return null; } } private String formatProgress(BigDecimal progress) { if (progress == null) { return null; } return progress.multiply(BigDecimal.valueOf(100)).stripTrailingZeros().toPlainString() + "%"; } private String formatTime(LocalDateTime time) { return time == null ? null : TIME_FORMATTER.format(time); } private String formatDuration(Duration duration) { if (duration == null || duration.isNegative()) { return null; } long seconds = duration.getSeconds(); long hours = seconds / 3600; long minutes = (seconds % 3600) / 60; long remainSeconds = seconds % 60; return String.format("%02d:%02d:%02d", hours, minutes, remainSeconds); } private ZoneId resolveZoneId() { try { return ZoneId.of(zone); } catch (Throwable e) { return ZoneId.systemDefault(); } } private void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("轮询 API 回归测试状态时被中断", e); } } private String firstLine(String text) { if (isBlank(text)) { return null; } int index = text.indexOf('\n'); return (index < 0 ? text : text.substring(0, index)).trim(); } private String extractErrorMessage(Throwable throwable) { if (throwable == null) { return null; } String message = throwable.getMessage(); if (isBlank(message) && throwable.getCause() != null) { message = throwable.getCause().getMessage(); } return isBlank(message) ? throwable.getClass().getSimpleName() : message.trim(); } private String trimTrailingSlash(String value) { if (isBlank(value)) { return ""; } String trimmed = value.trim(); while (trimmed.endsWith("/")) { trimmed = trimmed.substring(0, trimmed.length() - 1); } return trimmed; } private String buildTestKey() { return String.valueOf(100000 + Math.round(899999 * Math.random())); } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private String escapeHtml(String value) { if (value == null) { return ""; } return value .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'"); } private static final class TestExecutionResult { private LocalDateTime startedAt; private LocalDateTime finishedAt; private String key; private String statusResponseBody; private JSONObject status; private BigDecimal progress; private Long reportId; private String errorMessage; static TestExecutionResult failed(ZoneId zoneId, String errorMessage) { TestExecutionResult result = new TestExecutionResult(); result.startedAt = LocalDateTime.now(zoneId); result.finishedAt = result.startedAt; result.errorMessage = errorMessage; return result; } private Duration duration() { if (startedAt == null || finishedAt == null) { return null; } return Duration.between(startedAt, finishedAt); } private String message() { return status == null ? null : status.getString("msg"); } private String link() { return status == null ? null : status.getString("link"); } } private static final class BackgroundHttpSession implements HttpSession { private final String id; private final long creationTime; private final Map attributes = new ConcurrentHashMap<>(); private long lastAccessedTime; private int maxInactiveInterval; private boolean invalidated; private boolean isNew = true; private BackgroundHttpSession(String id) { this.id = id; this.creationTime = System.currentTimeMillis(); this.lastAccessedTime = this.creationTime; } @Override public long getCreationTime() { checkValid(); return creationTime; } @Override public String getId() { return id; } @Override public long getLastAccessedTime() { checkValid(); return lastAccessedTime; } @Override public ServletContext getServletContext() { return null; } @Override public void setMaxInactiveInterval(int interval) { this.maxInactiveInterval = interval; } @Override public int getMaxInactiveInterval() { return maxInactiveInterval; } @Override public Object getAttribute(String name) { touch(); return attributes.get(name); } @Override public Enumeration getAttributeNames() { touch(); return Collections.enumeration(attributes.keySet()); } @Override public void setAttribute(String name, Object value) { touch(); if (value == null) { attributes.remove(name); return; } attributes.put(name, value); } @Override public void removeAttribute(String name) { touch(); attributes.remove(name); } @Override public void invalidate() { invalidated = true; attributes.clear(); } @Override public boolean isNew() { checkValid(); return isNew; } private void touch() { checkValid(); lastAccessedTime = System.currentTimeMillis(); isNew = false; } private void checkValid() { if (invalidated) { throw new IllegalStateException("BackgroundHttpSession 已失效"); } } } }