package vip.mate.channel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 渠道健康监控
*
* 每 1 分钟(RFC-024 Change 2)检查所有活跃渠道适配器:
* - 连接状态为 ERROR 超过 5 分钟 → 触发重启
* - 连接状态为 CONNECTED 但超过 {@link ChannelAdapter#stalenessThreshold()} 无事件 → stale 重启
* - 每渠道每小时最多 10 次重启,cooldown 2 分钟
*
* RFC-024:stale 阈值改为读 adapter 自声明(默认 60min,WeChat 类长轮询 5min),
* 检查频率从 5min 降到 1min,让短阈值能真正生效;
* 配合 {@code AbstractChannelAdapter.touchActivity()} 精准刷新活跃时间。
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelHealthMonitor {
private final ChannelManager channelManager;
/** 错误状态超过此时间触发重启(毫秒) */
private static final long ERROR_THRESHOLD_MS = 5 * 60 * 1000;
/**
* 兜底 stale 阈值(当 adapter 未覆盖 {@link ChannelAdapter#stalenessThreshold()} 时使用)。
* RFC-024 Change 2 之前是硬编码 60min;现在由 adapter 自行声明。
*/
private static final Duration DEFAULT_STALE_THRESHOLD = Duration.ofMinutes(60);
/** 每渠道每小时最大重启次数 */
private static final int MAX_RESTARTS_PER_HOUR = 10;
/** 同一渠道两次重启最小间隔(毫秒) */
private static final long COOLDOWN_MS = 2 * 60 * 1000;
/** 重启历史记录(channelId → 重启时间列表) */
private final ConcurrentHashMap> restartHistory = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 60_000) // RFC-024 Change 2: 每 1 分钟(原 5 分钟)
public void checkHealth() {
Collection adapters = channelManager.getActiveAdapters();
if (adapters.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
int checked = 0;
int restarted = 0;
for (ChannelAdapter adapter : adapters) {
if (!(adapter instanceof AbstractChannelAdapter aca)) {
continue;
}
checked++;
Long channelId = aca.channelEntity.getId();
AbstractChannelAdapter.ConnectionState state = aca.getConnectionState().get();
long lastEvent = aca.getLastEventTimeMs().get();
long sinceLastEvent = now - lastEvent;
String reason = null;
// 检查 1:ERROR 状态超过阈值
if (state == AbstractChannelAdapter.ConnectionState.ERROR && sinceLastEvent > ERROR_THRESHOLD_MS) {
reason = String.format("ERROR state for %ds", sinceLastEvent / 1000);
}
// 检查 2:CONNECTED 但长时间无事件(stale)— RFC-024 读 adapter 自声明阈值
Duration staleThreshold;
try {
Duration d = adapter.stalenessThreshold();
staleThreshold = (d == null || d.isNegative() || d.isZero()) ? DEFAULT_STALE_THRESHOLD : d;
} catch (Exception ex) {
staleThreshold = DEFAULT_STALE_THRESHOLD;
}
if (reason == null && state == AbstractChannelAdapter.ConnectionState.CONNECTED
&& sinceLastEvent > staleThreshold.toMillis()) {
reason = String.format("stale connection, no events for %ds (threshold=%ds)",
sinceLastEvent / 1000, staleThreshold.toSeconds());
}
if (reason != null) {
if (canRestart(channelId, now)) {
log.warn("[ChannelHealth] Restarting channel {} ({}): {}",
channelId, aca.getDisplayName(), reason);
try {
channelManager.restartChannel(channelId);
recordRestart(channelId, now);
restarted++;
} catch (Exception e) {
log.error("[ChannelHealth] Failed to restart channel {}: {}",
channelId, e.getMessage());
}
} else {
log.warn("[ChannelHealth] Channel {} ({}) unhealthy ({}), but restart rate-limited",
channelId, aca.getDisplayName(), reason);
}
}
}
if (restarted > 0) {
log.info("[ChannelHealth] Check completed: {}/{} channels checked, {} restarted",
checked, adapters.size(), restarted);
}
}
/**
* 检查是否允许重启(限流 + cooldown)
*/
private boolean canRestart(Long channelId, long nowMs) {
List history = restartHistory.computeIfAbsent(channelId, k -> new ArrayList<>());
// 清理 1 小时前的记录
Instant oneHourAgo = Instant.ofEpochMilli(nowMs - 3600_000);
history.removeIf(t -> t.isBefore(oneHourAgo));
// 限流检查
if (history.size() >= MAX_RESTARTS_PER_HOUR) {
return false;
}
// cooldown 检查
if (!history.isEmpty()) {
Instant lastRestart = history.get(history.size() - 1);
if (nowMs - lastRestart.toEpochMilli() < COOLDOWN_MS) {
return false;
}
}
return true;
}
private void recordRestart(Long channelId, long nowMs) {
restartHistory.computeIfAbsent(channelId, k -> new ArrayList<>())
.add(Instant.ofEpochMilli(nowMs));
}
}