package com.ruoyi.energymeter.service.impl;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.energymeter.config.EnergyMeterProperties;
import com.ruoyi.energymeter.constant.EnergyMeterConstants;
import com.ruoyi.energymeter.dto.MeterReadingPoint;
import com.ruoyi.energymeter.dto.MeterUsagePoint;
import com.ruoyi.energymeter.dto.TaskStatusCount;
import com.ruoyi.energymeter.mapper.EnergyMeterDeviceMapper;
import com.ruoyi.energymeter.mapper.EnergyMeterMeasurementMapper;
import com.ruoyi.energymeter.mapper.EnergyMeterSyncCheckpointMapper;
import com.ruoyi.energymeter.mapper.EnergyMeterSyncRunMapper;
import com.ruoyi.energymeter.mapper.EnergyMeterSyncTaskMapper;
import com.ruoyi.energymeter.pojo.EnergyMeterDevice;
import com.ruoyi.energymeter.pojo.EnergyMeterMeasurement;
import com.ruoyi.energymeter.pojo.EnergyMeterSyncCheckpoint;
import com.ruoyi.energymeter.pojo.EnergyMeterSyncRun;
import com.ruoyi.energymeter.pojo.EnergyMeterSyncTask;
import com.ruoyi.energymeter.provider.MeterProviderClient;
import com.ruoyi.energymeter.provider.MeterProviderException;
import com.ruoyi.energymeter.service.EnergyMeterSyncService;
import com.ruoyi.energymeter.util.EnergyMeterSupport;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
/**
* 同步编排:批次创建、任务执行、幂等落库、进度推进与失败重试。
*
*
外部 HTTP 调用不放在数据库事务中;写入采集表、更新任务和进度在短事务内提交。
*/
@Slf4j
@Service
public class EnergyMeterSyncServiceImpl implements EnergyMeterSyncService {
private static final int UPSERT_CHUNK = 500;
private final EnergyMeterDeviceMapper deviceMapper;
private final EnergyMeterMeasurementMapper measurementMapper;
private final EnergyMeterSyncRunMapper runMapper;
private final EnergyMeterSyncTaskMapper taskMapper;
private final EnergyMeterSyncCheckpointMapper checkpointMapper;
private final MeterProviderClient provider;
private final EnergyMeterProperties properties;
private final TransactionTemplate transactionTemplate;
private final ThreadPoolTaskExecutor taskExecutor;
private final ThreadPoolTaskExecutor runExecutor;
public EnergyMeterSyncServiceImpl(EnergyMeterDeviceMapper deviceMapper,
EnergyMeterMeasurementMapper measurementMapper,
EnergyMeterSyncRunMapper runMapper,
EnergyMeterSyncTaskMapper taskMapper,
EnergyMeterSyncCheckpointMapper checkpointMapper,
MeterProviderClient provider,
EnergyMeterProperties properties,
PlatformTransactionManager transactionManager,
@Qualifier("energyMeterSyncExecutor") ThreadPoolTaskExecutor taskExecutor,
@Qualifier("energyMeterRunExecutor") ThreadPoolTaskExecutor runExecutor) {
this.deviceMapper = deviceMapper;
this.measurementMapper = measurementMapper;
this.runMapper = runMapper;
this.taskMapper = taskMapper;
this.checkpointMapper = checkpointMapper;
this.provider = provider;
this.properties = properties;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.taskExecutor = taskExecutor;
this.runExecutor = runExecutor;
}
/* ------------------------------------------------------------------ */
/* 批次创建 */
/* ------------------------------------------------------------------ */
@Override
public Long triggerScheduledSync() {
if (!provider.mockMode() && !provider.hasApiKey()) {
log.warn("未配置第三方授权码(NH_ENERGY_API_KEY),跳过每日自动同步");
return null;
}
LocalDate today = EnergyMeterSupport.today();
LocalDate target = today.minusDays(1);
String runKey = EnergyMeterConstants.AUTO_RUN_KEY_PREFIX
+ today.format(DateTimeFormatter.BASIC_ISO_DATE);
EnergyMeterSyncRun existing = runMapper.selectByRunKey(runKey);
if (existing != null) {
log.info("当天自动同步批次已存在,跳过重复触发 runKey={}", runKey);
return existing.getId();
}
EnergyMeterSyncRun run = newRun(EnergyMeterConstants.RUN_SCHEDULED, runKey, today, target, target);
try {
runMapper.insert(run);
} catch (DuplicateKeyException e) {
EnergyMeterSyncRun duplicated = runMapper.selectByRunKey(runKey);
log.info("并发触发自动同步,复用已有批次 runKey={}", runKey);
return duplicated == null ? null : duplicated.getId();
}
List devices = deviceMapper.selectScannableDevices();
LocalDate minStart = target;
int taskCount = 0;
for (EnergyMeterDevice device : devices) {
LocalDate start = resolveAutoStart(device, target);
if (start == null) {
continue;
}
if (start.isBefore(minStart)) {
minStart = start;
}
taskCount += createDeviceTasks(run.getId(), device, start, target, properties.isRawReadingEnabled());
}
applyRunRange(run, minStart, target, taskCount);
log.info("自动同步批次创建完成 runKey={} 设备数={} 任务数={}", runKey, devices.size(), taskCount);
if (taskCount == 0) {
EnergyMeterSyncRun finished = new EnergyMeterSyncRun();
finished.setId(run.getId());
finished.setStatus(EnergyMeterConstants.RUN_SUCCESS);
finished.setFinishedAt(EnergyMeterSupport.now());
runMapper.updateById(finished);
return run.getId();
}
executeRunAfterCommit(run.getId());
return run.getId();
}
@Override
public void triggerBackfill(EnergyMeterDevice device, LocalDate start, LocalDate end) {
if (device == null || start == null || end == null || start.isAfter(end)) {
return;
}
if (EnergyMeterConstants.isManual(device.getSourceType())) {
log.info("手工抄表设备不创建补采批次 meterId={}", device.getId());
return;
}
if (!provider.mockMode() && !provider.hasApiKey()) {
log.warn("未配置第三方授权码,跳过补采 meterId={}", device.getId());
return;
}
EnergyMeterSyncRun run = newRun(EnergyMeterConstants.RUN_BACKFILL,
"BACKFILL:" + UUID.randomUUID(), null, start, end);
runMapper.insert(run);
int taskCount = createDeviceTasks(run.getId(), device, start, end, properties.isRawReadingEnabled());
applyRunRange(run, start, end, taskCount);
log.info("历史补采批次创建完成 meterId={} range={}~{} tasks={}", device.getId(), start, end, taskCount);
if (taskCount > 0) {
executeRunAfterCommit(run.getId());
}
}
@Override
public Long triggerBackfillGap(EnergyMeterDevice device) {
if (device == null) {
return null;
}
if (EnergyMeterConstants.isManual(device.getSourceType())) {
log.info("手工抄表设备不参与缺口补采 meterId={}", device.getId());
return null;
}
if (!provider.mockMode() && !provider.hasApiKey()) {
log.warn("未配置第三方授权码,跳过缺口补采 meterId={}", device.getId());
return null;
}
LocalDate target = EnergyMeterSupport.today().minusDays(1);
LocalDate base = device.getSyncStartDate() != null
? device.getSyncStartDate()
: LocalDate.of(target.getYear() - 1, 1, 1);
EnergyMeterSyncCheckpoint checkpoint = checkpointMapper.selectUnit(device.getId(),
EnergyMeterConstants.usageSource(device.getMeterType()), EnergyMeterConstants.GRANULARITY_DAY);
LocalDate start = base;
if (checkpoint != null && checkpoint.getLastCompleteDate() != null) {
start = checkpoint.getLastCompleteDate().plusDays(1);
}
if (start.isBefore(base)) {
start = base;
}
if (start.isAfter(target)) {
log.info("设备 {} 无缺口需要补采", device.getId());
return null;
}
EnergyMeterSyncRun run = newRun(EnergyMeterConstants.RUN_BACKFILL,
"BACKFILL:" + UUID.randomUUID(), null, start, target);
runMapper.insert(run);
int taskCount = createDeviceTasks(run.getId(), device, start, target, properties.isRawReadingEnabled());
applyRunRange(run, start, target, taskCount);
if (taskCount > 0) {
executeRunAfterCommit(run.getId());
}
return run.getId();
}
@Override
public Long triggerManual(List meterIds, LocalDate start, LocalDate end, Boolean includeReadings) {
if (!provider.mockMode() && !provider.hasApiKey()) {
throw new ServiceException("未配置第三方授权码(环境变量 NH_ENERGY_API_KEY),无法查表");
}
if (meterIds == null || meterIds.isEmpty()) {
throw new ServiceException("请选择至少一台设备");
}
// 手动查表允许查到今天(当天用量为截至此刻的累计值),只挡未来日期
LocalDate today = EnergyMeterSupport.today();
LocalDate rangeEnd = end != null ? end : today;
if (rangeEnd.isAfter(today)) {
rangeEnd = today;
}
LocalDate rangeStart = start != null ? start : rangeEnd.minusDays(2);
if (rangeStart.isAfter(rangeEnd)) {
throw new ServiceException("开始日期不能晚于结束日期");
}
if (ChronoUnit.DAYS.between(rangeStart, rangeEnd) > EnergyMeterConstants.MAX_RANGE_DAYS) {
throw new ServiceException("查询跨度超出上限,请缩小日期范围");
}
List devices = new ArrayList<>();
for (Long meterId : new LinkedHashSet<>(meterIds)) {
EnergyMeterDevice device = deviceMapper.selectById(meterId);
if (device == null) {
throw new ServiceException("设备不存在:" + meterId);
}
Long tenantId = EnergyMeterSupport.currentTenantId();
if (tenantId != null && device.getTenantId() != null && !tenantId.equals(device.getTenantId())) {
throw new ServiceException("无权操作设备:" + meterId);
}
if (!EnergyMeterConstants.BIND_VERIFIED.equals(device.getBindStatus())) {
throw new ServiceException("设备未通过第三方档案验证,不能查表:" + meterId);
}
if (EnergyMeterConstants.isManual(device.getSourceType())) {
throw new ServiceException("手工抄表设备无需查表,请在表具台账中录入表码:"
+ StringUtils.defaultIfBlank(device.getDisplayName(), device.getMeterAddress()));
}
devices.add(device);
}
EnergyMeterSyncRun run = newRun(EnergyMeterConstants.RUN_MANUAL,
"MANUAL:" + UUID.randomUUID(), null, rangeStart, rangeEnd);
runMapper.insert(run);
boolean withReadings = includeReadings == null ? properties.isRawReadingEnabled() : includeReadings;
int taskCount = 0;
for (EnergyMeterDevice device : devices) {
taskCount += createDeviceTasks(run.getId(), device, rangeStart, rangeEnd, withReadings);
}
applyRunRange(run, rangeStart, rangeEnd, taskCount);
log.info("手动查表批次创建完成 runId={} 设备数={} 任务数={} range={}~{}",
run.getId(), devices.size(), taskCount, rangeStart, rangeEnd);
if (taskCount > 0) {
executeRunAfterCommit(run.getId());
}
return run.getId();
}
/* ------------------------------------------------------------------ */
/* 批次执行 */
/* ------------------------------------------------------------------ */
@Override
public void executeRunAsync(Long runId) {
if (runId == null) {
return;
}
runExecutor.execute(() -> {
try {
processRun(runId);
} catch (Throwable e) {
log.error("同步批次执行异常 runId=" + runId, e);
}
});
}
/**
* 在事务提交后再开始执行,避免异步线程读不到尚未提交的任务记录。
*/
private void executeRunAfterCommit(Long runId) {
if (runId == null) {
return;
}
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executeRunAsync(runId);
}
});
} else {
executeRunAsync(runId);
}
}
private void processRun(Long runId) {
EnergyMeterSyncRun run = runMapper.selectById(runId);
if (run == null) {
return;
}
if (EnergyMeterConstants.RUN_PENDING.equals(run.getStatus())) {
EnergyMeterSyncRun running = new EnergyMeterSyncRun();
running.setId(runId);
running.setStatus(EnergyMeterConstants.RUN_RUNNING);
running.setStartedAt(EnergyMeterSupport.now());
runMapper.updateById(running);
}
LocalDateTime now = EnergyMeterSupport.now();
// 同一设备、同一接口、同一粒度的分片按日期顺序串行执行,保证连续进度不会被乱序推进;
// 不同设备/接口之间仍然并行,受线程池并发数限制。
Map> groups = new LinkedHashMap<>();
for (EnergyMeterSyncTask task : taskMapper.selectByRunId(runId)) {
if (!isRunnable(task, now)) {
continue;
}
String key = task.getMeterId() + "|" + task.getSourceApi() + "|" + task.getGranularity();
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(task);
}
List> futures = new ArrayList<>();
for (List group : groups.values()) {
group.sort(Comparator.comparing(EnergyMeterSyncTask::getRangeStart,
Comparator.nullsFirst(LocalDate::compareTo)));
futures.add(CompletableFuture.runAsync(() -> {
for (EnergyMeterSyncTask task : group) {
executeTask(task.getId());
}
}, taskExecutor));
}
if (!futures.isEmpty()) {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
finalizeRun(runId);
}
private boolean isRunnable(EnergyMeterSyncTask task, LocalDateTime now) {
if (EnergyMeterConstants.TASK_PENDING.equals(task.getStatus())) {
return true;
}
if (EnergyMeterConstants.TASK_RETRY.equals(task.getStatus())) {
return task.getNextRetryAt() == null || !task.getNextRetryAt().isAfter(now);
}
return false;
}
private void executeTask(Long taskId) {
LocalDateTime now = EnergyMeterSupport.now();
if (taskMapper.claim(taskId, now) == 0) {
return;
}
EnergyMeterSyncTask task = taskMapper.selectById(taskId);
EnergyMeterDevice device = deviceMapper.selectById(task.getMeterId());
if (device == null) {
markTaskFailed(task, "DEVICE_MISSING", "本地设备不存在,无法采集");
return;
}
try {
FetchResult result = fetch(device, task);
persist(device, task, result);
} catch (MeterProviderException e) {
markTaskFailed(task, e.getCode(), e.getMessage());
} catch (Exception e) {
log.error("同步任务异常 taskId=" + taskId, e);
markTaskFailed(task, EnergyMeterConstants.ERROR_UPSTREAM, e.getMessage());
}
}
private FetchResult fetch(EnergyMeterDevice device, EnergyMeterSyncTask task) {
String meterType = device.getMeterType();
String address = device.getMeterAddress();
String providerMeterId = device.getProviderMeterId();
LocalDate start = task.getRangeStart();
LocalDate end = task.getRangeEnd();
LocalDateTime collectedAt = EnergyMeterSupport.now();
List measurements = new ArrayList<>();
int fetched = 0;
if (EnergyMeterConstants.GRANULARITY_DAY.equals(task.getGranularity())) {
List points = provider.queryDailyUsage(meterType, address, providerMeterId, start, end);
fetched = points.size();
String metric = EnergyMeterConstants.usageMetric(meterType);
String unit = EnergyMeterConstants.unit(meterType);
for (MeterUsagePoint point : points) {
validateAddress(address, point.getAddress());
if (point.getTime() == null || point.getValue() == null) {
throw new MeterProviderException(EnergyMeterConstants.ERROR_PARSE, "用量数据缺少时间或数值");
}
LocalDate day = point.getTime().toLocalDate();
if (day.isBefore(start) || day.isAfter(end)) {
continue;
}
EnergyMeterMeasurement measurement = new EnergyMeterMeasurement();
measurement.setTenantId(device.getTenantId());
measurement.setMeterId(device.getId());
measurement.setMetricCode(metric);
measurement.setGranularity(EnergyMeterConstants.GRANULARITY_DAY);
measurement.setMeasureTime(day.atStartOfDay());
measurement.setMetricValue(point.getValue());
measurement.setUnit(unit);
measurement.setSourceApi(task.getSourceApi());
measurement.setSourceRecordTime(point.getTime());
measurement.setCollectedAt(collectedAt);
measurements.add(measurement);
}
} else {
List points = provider.queryReadings(meterType, address, providerMeterId, start, end);
fetched = points.size();
String metric = EnergyMeterConstants.readingMetric(meterType);
String unit = EnergyMeterConstants.unit(meterType);
for (MeterReadingPoint point : points) {
validateAddress(address, point.getAddress());
if (point.getTime() == null || point.getReading() == null) {
throw new MeterProviderException(EnergyMeterConstants.ERROR_PARSE, "示值数据缺少时间或数值");
}
LocalDate day = point.getTime().toLocalDate();
if (day.isBefore(start) || day.isAfter(end)) {
continue;
}
EnergyMeterMeasurement measurement = new EnergyMeterMeasurement();
measurement.setTenantId(device.getTenantId());
measurement.setMeterId(device.getId());
measurement.setMetricCode(metric);
measurement.setGranularity(EnergyMeterConstants.GRANULARITY_POINT);
measurement.setMeasureTime(point.getTime());
measurement.setMetricValue(point.getReading());
measurement.setUnit(unit);
measurement.setSourceApi(task.getSourceApi());
measurement.setSourceRecordTime(point.getTime());
measurement.setCollectedAt(collectedAt);
measurements.add(measurement);
}
}
FetchResult result = new FetchResult();
result.fetched = fetched;
result.measurements = measurements;
result.metricCode = EnergyMeterConstants.GRANULARITY_DAY.equals(task.getGranularity())
? EnergyMeterConstants.usageMetric(meterType)
: EnergyMeterConstants.readingMetric(meterType);
return result;
}
private void validateAddress(String expected, String actual) {
if (StringUtils.isNotBlank(actual) && !expected.equals(actual.trim())) {
throw new MeterProviderException(EnergyMeterConstants.ERROR_ADDRESS_MISMATCH,
"第三方返回的通信地址与本地绑定不一致");
}
}
private void persist(EnergyMeterDevice device, EnergyMeterSyncTask task, FetchResult result) {
LocalDateTime now = EnergyMeterSupport.now();
LocalDateTime rangeStart = task.getRangeStart().atStartOfDay();
LocalDateTime rangeEnd = task.getRangeEnd().plusDays(1).atStartOfDay();
int[] counters = transactionTemplate.execute(txStatus -> {
int inserted = 0;
int updated = 0;
List writes = new ArrayList<>();
if (!result.measurements.isEmpty()) {
Map existing = new HashMap<>();
for (EnergyMeterMeasurement row : measurementMapper.selectExistingPoints(device.getId(),
result.metricCode, task.getGranularity(), rangeStart, rangeEnd)) {
existing.put(row.getMeasureTime(), row.getMetricValue());
}
for (EnergyMeterMeasurement measurement : result.measurements) {
BigDecimal oldValue = existing.get(measurement.getMeasureTime());
if (oldValue == null) {
inserted++;
writes.add(measurement);
} else if (oldValue.compareTo(measurement.getMetricValue()) != 0) {
updated++;
writes.add(measurement);
}
}
}
for (int from = 0; from < writes.size(); from += UPSERT_CHUNK) {
int to = Math.min(from + UPSERT_CHUNK, writes.size());
measurementMapper.upsertBatch(writes.subList(from, to));
}
String status = result.measurements.isEmpty()
? EnergyMeterConstants.TASK_EMPTY : EnergyMeterConstants.TASK_SUCCESS;
taskMapper.markFinished(task.getId(), status, result.fetched, inserted, updated, now);
deviceMapper.markCollectSuccess(device.getId(), now);
advanceCheckpoint(device, task, now);
return new int[]{inserted, updated};
});
int inserted = counters == null ? 0 : counters[0];
int updated = counters == null ? 0 : counters[1];
log.info("同步任务完成 taskId={} meterId={} 拉取={} 新增={} 修正={}",
task.getId(), device.getId(), result.fetched, inserted, updated);
}
private void advanceCheckpoint(EnergyMeterDevice device, EnergyMeterSyncTask task, LocalDateTime now) {
LocalDate start = task.getRangeStart();
LocalDate end = task.getRangeEnd();
// 当天数据仍在滚动累计(手动查表或当天补采会不断覆盖),不能把今天记为“已完成”,
// 否则补采缺口判断会误以为今天已经完整。完成进度只推进到昨天及更早。
if (!end.isBefore(EnergyMeterSupport.today())) {
log.debug("跳过当天进度推进 meterId={} source={} granularity={} end={}",
device.getId(), task.getSourceApi(), task.getGranularity(), end);
return;
}
EnergyMeterSyncCheckpoint checkpoint = checkpointMapper.selectUnit(device.getId(),
task.getSourceApi(), task.getGranularity());
LocalDate existingDate = checkpoint == null ? null : checkpoint.getLastCompleteDate();
boolean contiguous = existingDate == null || !start.isAfter(existingDate.plusDays(1));
LocalDate lastComplete;
if (contiguous) {
lastComplete = existingDate == null || end.isAfter(existingDate) ? end : existingDate;
} else {
lastComplete = existingDate != null ? existingDate : start.minusDays(1);
}
EnergyMeterSyncCheckpoint upsert = new EnergyMeterSyncCheckpoint();
upsert.setTenantId(device.getTenantId());
upsert.setMeterId(device.getId());
upsert.setSourceApi(task.getSourceApi());
upsert.setGranularity(task.getGranularity());
upsert.setLastCompleteDate(lastComplete);
upsert.setLastSuccessAt(now);
upsert.setLastRunId(task.getRunId());
checkpointMapper.upsert(upsert);
}
private void markTaskFailed(EnergyMeterSyncTask task, String code, String message) {
String safeCode = StringUtils.defaultIfBlank(code, EnergyMeterConstants.ERROR_UPSTREAM);
String safeMessage = EnergyMeterSupport.truncate(
StringUtils.defaultIfBlank(message, "未知错误"), 450);
int attempts = task.getAttemptCount() == null ? 1 : task.getAttemptCount();
// max-attempts 表示“最多重试次数”,首次执行之外还可重试 N 次
int maxTotalAttempts = Math.max(1, properties.getMaxAttempts()) + 1;
boolean retry = attempts < maxTotalAttempts;
LocalDateTime now = EnergyMeterSupport.now();
LocalDateTime nextRetryAt = null;
if (retry) {
int index = Math.min(Math.max(attempts - 1, 0), EnergyMeterConstants.RETRY_DELAY_MINUTES.length - 1);
nextRetryAt = now.plusMinutes(EnergyMeterConstants.RETRY_DELAY_MINUTES[index]);
}
String status = retry ? EnergyMeterConstants.TASK_RETRY : EnergyMeterConstants.TASK_FAILED;
LocalDateTime finalNextRetryAt = nextRetryAt;
transactionTemplate.executeWithoutResult(state -> {
taskMapper.markFailed(task.getId(), status, safeCode, safeMessage, finalNextRetryAt, now);
deviceMapper.markCollectFailure(task.getMeterId(), safeCode, safeMessage);
});
log.warn("同步任务失败 taskId={} meterId={} code={} attempts={} 将重试={}",
task.getId(), task.getMeterId(), safeCode, attempts, retry);
}
private void finalizeRun(Long runId) {
TaskStatusCount count = taskMapper.countByRunId(runId);
runMapper.refreshTotals(runId);
int total = count == null || count.getTotal() == null ? 0 : count.getTotal();
int success = count == null || count.getSuccess() == null ? 0 : count.getSuccess();
int empty = count == null || count.getEmpty() == null ? 0 : count.getEmpty();
int failed = count == null || count.getFailed() == null ? 0 : count.getFailed();
int retry = count == null || count.getRetry() == null ? 0 : count.getRetry();
int pending = count == null || count.getPending() == null ? 0 : count.getPending();
int running = count == null || count.getRunning() == null ? 0 : count.getRunning();
boolean finished = pending == 0 && running == 0 && retry == 0;
String status;
if (!finished) {
status = EnergyMeterConstants.RUN_PARTIAL;
} else if (failed == 0) {
status = EnergyMeterConstants.RUN_SUCCESS;
} else if (success + empty > 0) {
status = EnergyMeterConstants.RUN_PARTIAL;
} else {
status = EnergyMeterConstants.RUN_FAILED;
}
EnergyMeterSyncRun update = new EnergyMeterSyncRun();
update.setId(runId);
update.setStatus(status);
if (finished) {
update.setFinishedAt(EnergyMeterSupport.now());
}
if (failed > 0) {
update.setErrorSummary(EnergyMeterSupport.truncate(
failed + "/" + total + " 个任务失败,可在批次详情中重试", 500));
}
runMapper.updateById(update);
log.info("同步批次汇总 runId={} status={} total={} success={} empty={} failed={} retry={}",
runId, status, total, success, empty, failed, retry);
}
/* ------------------------------------------------------------------ */
/* 重试与恢复 */
/* ------------------------------------------------------------------ */
@Override
public int recoverDueTasks() {
LocalDateTime now = EnergyMeterSupport.now();
int timeoutMinutes = Math.max(1, properties.getRunningTimeoutMinutes());
LocalDateTime deadline = now.minusMinutes(timeoutMinutes);
int handled = 0;
int maxTotalAttempts = Math.max(1, properties.getMaxAttempts()) + 1;
for (EnergyMeterSyncTask task : taskMapper.selectStaleRunning(deadline, 200)) {
int attempts = task.getAttemptCount() == null ? 0 : task.getAttemptCount();
if (attempts >= maxTotalAttempts) {
transactionTemplate.executeWithoutResult(state -> taskMapper.markFailed(task.getId(),
EnergyMeterConstants.TASK_FAILED, EnergyMeterConstants.ERROR_TIMEOUT,
"任务执行超时(超过 " + timeoutMinutes + " 分钟)", null, now));
} else {
taskMapper.resetToRetry(task.getId(), now, now, "任务执行超时,自动转入重试");
}
handled++;
}
List dueTasks = taskMapper.selectDueRetry(now, 200);
Set runIds = dueTasks.stream().map(EnergyMeterSyncTask::getRunId)
.collect(Collectors.toCollection(LinkedHashSet::new));
for (Long runId : runIds) {
try {
processRun(runId);
} catch (Exception e) {
log.error("恢复任务处理批次失败 runId=" + runId, e);
}
}
handled += dueTasks.size();
// 崩溃兜底:批次停在 RUNNING 但任务都已结束(例如汇总阶段异常退出)时重新汇总
for (EnergyMeterSyncRun stuck : runMapper.selectStuckRuns(now.minusMinutes(5), 50)) {
try {
finalizeRun(stuck.getId());
handled++;
log.info("兜底汇总滞留批次 runId={}", stuck.getId());
} catch (Exception e) {
log.error("兜底汇总批次失败 runId=" + stuck.getId(), e);
}
}
if (handled > 0) {
log.info("同步恢复作业处理 {} 个任务/批次", handled);
}
return handled;
}
/* ------------------------------------------------------------------ */
/* 内部工具 */
/* ------------------------------------------------------------------ */
private EnergyMeterSyncRun newRun(String triggerType, String runKey, LocalDate scheduleDate,
LocalDate start, LocalDate end) {
EnergyMeterSyncRun run = new EnergyMeterSyncRun();
run.setRunKey(runKey);
run.setTriggerType(triggerType);
run.setScheduleDate(scheduleDate);
run.setRangeStart(start);
run.setRangeEnd(end);
run.setStatus(EnergyMeterConstants.RUN_PENDING);
run.setTotalTasks(0);
run.setSuccessTasks(0);
run.setEmptyTasks(0);
run.setFailedTasks(0);
run.setCreateUser(EnergyMeterSupport.currentUserId());
return run;
}
private void applyRunRange(EnergyMeterSyncRun run, LocalDate start, LocalDate end, int taskCount) {
EnergyMeterSyncRun update = new EnergyMeterSyncRun();
update.setId(run.getId());
update.setRangeStart(start);
update.setRangeEnd(end);
update.setTotalTasks(taskCount);
runMapper.updateById(update);
}
private int createDeviceTasks(Long runId, EnergyMeterDevice device, LocalDate start, LocalDate end,
boolean includeReadings) {
if (EnergyMeterConstants.isManual(device.getSourceType())) {
return 0;
}
int created = createTasksFor(runId, device,
EnergyMeterConstants.usageSource(device.getMeterType()),
EnergyMeterConstants.GRANULARITY_DAY, start, end,
Math.max(1, properties.getMaxShardDays()));
if (includeReadings && !EnergyMeterConstants.isGas(device.getMeterType())) {
// 示值接口的查询跨度限制比用量接口更严(集社云为 15 天),必须用更小的分片。
// 气表示值接口只支持 mi15(15 分钟)粒度,96 点/天,数据量过大,暂不创建示值任务。
created += createTasksFor(runId, device,
EnergyMeterConstants.readingSource(device.getMeterType()),
EnergyMeterConstants.GRANULARITY_POINT, start, end,
Math.max(1, properties.getMaxReadingShardDays()));
}
return created;
}
private int createTasksFor(Long runId, EnergyMeterDevice device, String sourceApi, String granularity,
LocalDate start, LocalDate end, int shardDays) {
int created = 0;
LocalDate cursor = start;
while (!cursor.isAfter(end)) {
LocalDate shardEnd = cursor.plusDays(shardDays - 1L);
if (shardEnd.isAfter(end)) {
shardEnd = end;
}
created += taskMapper.insertIgnore(buildTask(runId, device, sourceApi, granularity, cursor, shardEnd));
cursor = shardEnd.plusDays(1);
}
return created;
}
private EnergyMeterSyncTask buildTask(Long runId, EnergyMeterDevice device, String sourceApi,
String granularity, LocalDate start, LocalDate end) {
EnergyMeterSyncTask task = new EnergyMeterSyncTask();
task.setRunId(runId);
task.setTenantId(device.getTenantId());
task.setMeterId(device.getId());
task.setSourceApi(sourceApi);
task.setGranularity(granularity);
task.setRangeStart(start);
task.setRangeEnd(end);
task.setStatus(EnergyMeterConstants.TASK_PENDING);
return task;
}
/**
* 自动同步起点:优先补齐进度缺口,同时始终回查最近 N 天(默认 3 天)。
*/
private LocalDate resolveAutoStart(EnergyMeterDevice device, LocalDate target) {
LocalDate base = device.getSyncStartDate() != null
? device.getSyncStartDate()
: LocalDate.of(target.getYear() - 1, 1, 1);
LocalDate start = base;
EnergyMeterSyncCheckpoint checkpoint = checkpointMapper.selectUnit(device.getId(),
EnergyMeterConstants.usageSource(device.getMeterType()), EnergyMeterConstants.GRANULARITY_DAY);
if (checkpoint != null && checkpoint.getLastCompleteDate() != null) {
LocalDate next = checkpoint.getLastCompleteDate().plusDays(1);
if (next.isAfter(start)) {
start = next;
}
}
int lookback = Math.max(1, properties.getLookbackDays());
LocalDate lookbackStart = target.minusDays(lookback - 1L);
if (start.isAfter(lookbackStart)) {
start = lookbackStart;
}
if (start.isBefore(base)) {
start = base;
}
if (start.isAfter(target)) {
return null;
}
return start;
}
/** 一次任务的拉取结果。 */
private static class FetchResult {
private int fetched;
private String metricCode;
private List measurements = new ArrayList<>();
}
}