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<>(); } }