From 1a21433e0babfa7cafc5a4d86609442ec9f150a4 Mon Sep 17 00:00:00 2001
From: yuan <123@>
Date: 星期二, 16 六月 2026 09:54:05 +0800
Subject: [PATCH] feat: 添加电表和采集器同步服务及相关数据模型

---
 src/main/java/com/ruoyi/http/mapper/TqdianbiaoMeterMapper.java                      |    7 
 src/main/java/com/ruoyi/http/service/TqdianbiaoMeterManageService.java              |   21 
 src/main/java/com/ruoyi/http/service/TqdianbiaoSyncLogService.java                  |    8 
 src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java                    |  252 ++++++
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleSyncServiceImpl.java         |  150 +++
 src/main/java/com/ruoyi/http/service/TqdianbiaoMeterSyncService.java                |    6 
 src/main/java/com/ruoyi/http/pojo/TqdianbiaoSyncLog.java                            |   32 
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorSyncServiceImpl.java   |   90 ++
 src/main/java/com/ruoyi/http/controller/StatisticEleController.java                 |   79 ++
 src/main/java/com/ruoyi/http/vo/StatisticEleSummaryVo.java                          |   33 
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterManageServiceImpl.java     |  120 +++
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorManageServiceImpl.java |   91 ++
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleRecordManageServiceImpl.java |  140 +++
 src/main/java/com/ruoyi/http/controller/TqdianbiaoEleRecordController.java          |   59 +
 src/main/java/com/ruoyi/http/config/TqdianbiaoConfig.java                           |   34 
 src/main/java/com/ruoyi/http/vo/StatisticEleSyncStatusVo.java                       |   19 
 src/main/java/com/ruoyi/http/vo/StatisticEleRecordVo.java                           |   67 +
 src/main/java/com/ruoyi/http/pojo/TqdianbiaoCollector.java                          |   40 +
 src/main/java/com/ruoyi/http/service/TqdianbiaoEleRecordManageService.java          |   22 
 src/main/java/com/ruoyi/http/mapper/TqdianbiaoCollectorMapper.java                  |    7 
 src/main/java/com/ruoyi/http/task/TqdianbiaoSyncTask.java                           |   58 +
 src/main/java/com/ruoyi/http/service/TqdianbiaoEleSyncService.java                  |   15 
 src/main/java/com/ruoyi/http/service/StatisticEleService.java                       |   26 
 src/main/java/com/ruoyi/http/util/StatisticEleReadingUtil.java                      |   72 +
 src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorSyncService.java            |    6 
 src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java              |  179 ++++
 src/main/java/com/ruoyi/http/mapper/TqdianbiaoSyncLogMapper.java                    |    7 
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoSyncLogServiceImpl.java         |   39 +
 src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorManageService.java          |   21 
 src/main/java/com/ruoyi/http/util/StatisticEleParseUtil.java                        |  122 +++
 src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterSyncServiceImpl.java       |  112 ++
 src/main/java/com/ruoyi/http/controller/TqdianbiaoCollectorController.java          |   66 +
 src/main/java/com/ruoyi/http/mapper/TqdianbiaoEleRecordMapper.java                  |   20 
 src/main/java/com/ruoyi/http/pojo/TqdianbiaoEleRecord.java                          |   70 +
 src/main/java/com/ruoyi/http/controller/TqdianbiaoMeterController.java              |   66 +
 src/main/resources/mapper/http/TqdianbiaoEleRecordMapper.xml                        |   45 +
 src/main/java/com/ruoyi/http/pojo/TqdianbiaoMeter.java                              |   50 +
 37 files changed, 2,251 insertions(+), 0 deletions(-)

diff --git a/src/main/java/com/ruoyi/http/config/TqdianbiaoConfig.java b/src/main/java/com/ruoyi/http/config/TqdianbiaoConfig.java
new file mode 100644
index 0000000..f949a4f
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/config/TqdianbiaoConfig.java
@@ -0,0 +1,34 @@
+package com.ruoyi.http.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 澶╁惎鐢佃〃骞冲彴閰嶇疆
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "tqdianbiao")
+public class TqdianbiaoConfig {
+
+    /** API 鍩虹鍦板潃 */
+    private String baseUrl = "https://168.tqdianbiao.com";
+
+    /** 璁よ瘉 token */
+    private String auth = "b6229401590539d5def7f2bf897b33de";
+
+    /** 鏄惁蹇界暐浜掓劅鍣ㄥ彉姣旓細0-鍚︼紝1-鏄� */
+    private Integer ignoreRadio = 1;
+
+    /** 鍚屾閰嶇疆 */
+    private Sync sync = new Sync();
+
+    @Data
+    public static class Sync {
+        /** 鏄惁鍚敤瀹氭椂鍚屾 */
+        private Boolean enabled = true;
+        /** 灏忔椂鍚屾鍥炵湅绐楀彛锛堝皬鏃舵暟锛� */
+        private Integer hourWindow = 2;
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/controller/StatisticEleController.java b/src/main/java/com/ruoyi/http/controller/StatisticEleController.java
new file mode 100644
index 0000000..7994103
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/controller/StatisticEleController.java
@@ -0,0 +1,79 @@
+package com.ruoyi.http.controller;
+
+import com.ruoyi.framework.aspectj.lang.annotation.Log;
+import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
+import com.ruoyi.framework.web.controller.BaseController;
+import com.ruoyi.framework.web.domain.AjaxResult;
+import com.ruoyi.http.service.StatisticEleService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@Tag(name = "鑳借�楃數琛ㄧ粺璁�")
+@RequestMapping("/statisticEle")
+@RequiredArgsConstructor
+public class StatisticEleController extends BaseController {
+
+    private final StatisticEleService statisticEleService;
+
+    @GetMapping("/list")
+    @Operation(summary = "鑳借�楁暟鎹�-鍒楄〃鏌ヨ(鏈湴搴�)")
+    @Log(title = "鑳借�楁暟鎹�-鍒楄〃鏌ヨ", businessType = BusinessType.OTHER)
+    public AjaxResult list(
+            @RequestParam(defaultValue = "hour") String dimension,
+            @RequestParam String startTime,
+            @RequestParam String endTime,
+            @RequestParam(defaultValue = "1") Integer ignoreRadio) {
+        return AjaxResult.success(statisticEleService.listRecords(dimension, startTime, endTime, ignoreRadio));
+    }
+
+    @GetMapping("/summary")
+    @Operation(summary = "鑳借�楁暟鎹�-姹囨�荤粺璁�(鏈湴搴�)")
+    @Log(title = "鑳借�楁暟鎹�-姹囨�荤粺璁�", businessType = BusinessType.OTHER)
+    public AjaxResult summary(
+            @RequestParam(defaultValue = "hour") String dimension,
+            @RequestParam String startTime,
+            @RequestParam String endTime) {
+        return AjaxResult.success(statisticEleService.getSummary(dimension, startTime, endTime));
+    }
+
+    @GetMapping("/yesterday")
+    @Operation(summary = "鏄ㄦ棩鐢ㄧ數閲忔眹鎬�")
+    public AjaxResult yesterday() {
+        return AjaxResult.success(statisticEleService.getYesterdaySummary());
+    }
+
+    @GetMapping("/syncStatus")
+    @Operation(summary = "鑳借�楁暟鎹�-鍚屾鐘舵��")
+    public AjaxResult syncStatus() {
+        return AjaxResult.success(statisticEleService.getSyncStatus());
+    }
+
+    @GetMapping("/raw")
+    @Operation(summary = "鑳借�楁暟鎹�-鍘熷鎺ュ彛(璋冭瘯鐢紝鎱庣敤)")
+    @Log(title = "鑳借�楁暟鎹�-鍘熷鎺ュ彛", businessType = BusinessType.OTHER)
+    public AjaxResult raw(
+            @RequestParam(defaultValue = "hour") String dimension,
+            @RequestParam String startTime,
+            @RequestParam String endTime) {
+        return AjaxResult.success(statisticEleService.fetchRawData(dimension, startTime, endTime));
+    }
+
+    @PostMapping("/export")
+    @Operation(summary = "鑳借�楁暟鎹�-瀵煎嚭")
+    @Log(title = "鑳借�楁暟鎹�-瀵煎嚭", businessType = BusinessType.EXPORT)
+    public void export(
+            @RequestParam(defaultValue = "hour") String dimension,
+            @RequestParam String startTime,
+            @RequestParam String endTime,
+            HttpServletResponse response) {
+        statisticEleService.exportRecords(dimension, startTime, endTime, response);
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/controller/TqdianbiaoCollectorController.java b/src/main/java/com/ruoyi/http/controller/TqdianbiaoCollectorController.java
new file mode 100644
index 0000000..90002dc
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/controller/TqdianbiaoCollectorController.java
@@ -0,0 +1,66 @@
+package com.ruoyi.http.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.framework.aspectj.lang.annotation.Log;
+import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
+import com.ruoyi.framework.web.controller.BaseController;
+import com.ruoyi.framework.web.domain.AjaxResult;
+import com.ruoyi.http.pojo.TqdianbiaoCollector;
+import com.ruoyi.http.service.TqdianbiaoCollectorManageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@Tag(name = "閲囬泦鍣ㄦ。妗�")
+@RequestMapping("/tqdianbiao/collector")
+@RequiredArgsConstructor
+public class TqdianbiaoCollectorController extends BaseController {
+
+    private final TqdianbiaoCollectorManageService collectorManageService;
+
+    @GetMapping("/listPage")
+    @Operation(summary = "閲囬泦鍣ㄦ。妗�-鍒嗛〉鏌ヨ")
+    public AjaxResult listPage(Page page, TqdianbiaoCollector query) {
+        return AjaxResult.success(collectorManageService.listPage(page, query));
+    }
+
+    @GetMapping("/listAll")
+    @Operation(summary = "閲囬泦鍣ㄦ。妗�-鍏ㄩ儴鍒楄〃")
+    public AjaxResult listAll() {
+        return AjaxResult.success(collectorManageService.list());
+    }
+
+    @PostMapping("/add")
+    @Log(title = "閲囬泦鍣ㄦ。妗�-鏂板", businessType = BusinessType.INSERT)
+    public AjaxResult add(@RequestBody TqdianbiaoCollector collector) {
+        return collectorManageService.addCollector(collector) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @PostMapping("/update")
+    @Log(title = "閲囬泦鍣ㄦ。妗�-淇敼", businessType = BusinessType.UPDATE)
+    public AjaxResult update(@RequestBody TqdianbiaoCollector collector) {
+        return collectorManageService.updateCollector(collector) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @DeleteMapping("/delete")
+    @Log(title = "閲囬泦鍣ㄦ。妗�-鍒犻櫎", businessType = BusinessType.DELETE)
+    public AjaxResult delete(@RequestBody List<Long> ids) {
+        return collectorManageService.deleteByIds(ids) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @PostMapping("/sync")
+    @Log(title = "閲囬泦鍣ㄦ。妗�-杩滅▼鍚屾", businessType = BusinessType.OTHER)
+    public AjaxResult sync() {
+        int count = collectorManageService.syncFromRemote();
+        return AjaxResult.success("鍚屾鎴愬姛锛屽叡鍚屾 " + count + " 鏉�");
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/controller/TqdianbiaoEleRecordController.java b/src/main/java/com/ruoyi/http/controller/TqdianbiaoEleRecordController.java
new file mode 100644
index 0000000..a165b8a
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/controller/TqdianbiaoEleRecordController.java
@@ -0,0 +1,59 @@
+package com.ruoyi.http.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.framework.aspectj.lang.annotation.Log;
+import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
+import com.ruoyi.framework.web.controller.BaseController;
+import com.ruoyi.framework.web.domain.AjaxResult;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+import com.ruoyi.http.service.TqdianbiaoEleRecordManageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@Tag(name = "鐢甸噺璁板綍绠$悊")
+@RequestMapping("/tqdianbiao/eleRecord")
+@RequiredArgsConstructor
+public class TqdianbiaoEleRecordController extends BaseController {
+
+    private final TqdianbiaoEleRecordManageService eleRecordManageService;
+
+    @GetMapping("/listPage")
+    @Operation(summary = "鐢甸噺璁板綍-鍒嗛〉鏌ヨ")
+    public AjaxResult listPage(Page page, TqdianbiaoEleRecord query) {
+        return AjaxResult.success(eleRecordManageService.listPage(page, query));
+    }
+
+    @PostMapping("/add")
+    @Log(title = "鐢甸噺璁板綍-鏂板", businessType = BusinessType.INSERT)
+    public AjaxResult add(@RequestBody TqdianbiaoEleRecord record) {
+        return eleRecordManageService.addRecord(record) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @PostMapping("/update")
+    @Log(title = "鐢甸噺璁板綍-淇敼", businessType = BusinessType.UPDATE)
+    public AjaxResult update(@RequestBody TqdianbiaoEleRecord record) {
+        return eleRecordManageService.updateRecord(record) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @DeleteMapping("/delete")
+    @Log(title = "鐢甸噺璁板綍-鍒犻櫎", businessType = BusinessType.DELETE)
+    public AjaxResult delete(@RequestBody List<Long> ids) {
+        return eleRecordManageService.deleteByIds(ids) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @GetMapping("/prevReading")
+    @Operation(summary = "鑾峰彇涓婃鐢甸噺璇绘暟")
+    public AjaxResult prevReading(Long meterId, String timeKey) {
+        return AjaxResult.success(eleRecordManageService.getPrevReading(meterId, timeKey));
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/controller/TqdianbiaoMeterController.java b/src/main/java/com/ruoyi/http/controller/TqdianbiaoMeterController.java
new file mode 100644
index 0000000..ec83f66
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/controller/TqdianbiaoMeterController.java
@@ -0,0 +1,66 @@
+package com.ruoyi.http.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.framework.aspectj.lang.annotation.Log;
+import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
+import com.ruoyi.framework.web.controller.BaseController;
+import com.ruoyi.framework.web.domain.AjaxResult;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+import com.ruoyi.http.service.TqdianbiaoMeterManageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@Tag(name = "鐢佃〃妗f")
+@RequestMapping("/tqdianbiao/meter")
+@RequiredArgsConstructor
+public class TqdianbiaoMeterController extends BaseController {
+
+    private final TqdianbiaoMeterManageService meterManageService;
+
+    @GetMapping("/listPage")
+    @Operation(summary = "鐢佃〃妗f-鍒嗛〉鏌ヨ")
+    public AjaxResult listPage(Page page, TqdianbiaoMeter query) {
+        return AjaxResult.success(meterManageService.listPage(page, query));
+    }
+
+    @GetMapping("/listAll")
+    @Operation(summary = "鐢佃〃妗f-鍏ㄩ儴鍒楄〃")
+    public AjaxResult listAll() {
+        return AjaxResult.success(meterManageService.list());
+    }
+
+    @PostMapping("/add")
+    @Log(title = "鐢佃〃妗f-鏂板", businessType = BusinessType.INSERT)
+    public AjaxResult add(@RequestBody TqdianbiaoMeter meter) {
+        return meterManageService.addMeter(meter) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @PostMapping("/update")
+    @Log(title = "鐢佃〃妗f-淇敼", businessType = BusinessType.UPDATE)
+    public AjaxResult update(@RequestBody TqdianbiaoMeter meter) {
+        return meterManageService.updateMeter(meter) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @DeleteMapping("/delete")
+    @Log(title = "鐢佃〃妗f-鍒犻櫎", businessType = BusinessType.DELETE)
+    public AjaxResult delete(@RequestBody List<Long> ids) {
+        return meterManageService.deleteByIds(ids) ? AjaxResult.success() : AjaxResult.error();
+    }
+
+    @PostMapping("/sync")
+    @Log(title = "鐢佃〃妗f-杩滅▼鍚屾", businessType = BusinessType.OTHER)
+    public AjaxResult sync() {
+        int count = meterManageService.syncFromRemote();
+        return AjaxResult.success("鍚屾鎴愬姛锛屽叡鍚屾 " + count + " 鏉�");
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/mapper/TqdianbiaoCollectorMapper.java b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoCollectorMapper.java
new file mode 100644
index 0000000..42b4197
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoCollectorMapper.java
@@ -0,0 +1,7 @@
+package com.ruoyi.http.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.http.pojo.TqdianbiaoCollector;
+
+public interface TqdianbiaoCollectorMapper extends BaseMapper<TqdianbiaoCollector> {
+}
diff --git a/src/main/java/com/ruoyi/http/mapper/TqdianbiaoEleRecordMapper.java b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoEleRecordMapper.java
new file mode 100644
index 0000000..8f648ce
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoEleRecordMapper.java
@@ -0,0 +1,20 @@
+package com.ruoyi.http.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+import com.ruoyi.http.vo.StatisticEleRecordVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface TqdianbiaoEleRecordMapper extends BaseMapper<TqdianbiaoEleRecord> {
+
+    List<StatisticEleRecordVo> selectRecordList(
+            @Param("dimensions") List<String> dimensions,
+            @Param("startTime") String startTime,
+            @Param("endTime") String endTime);
+
+    TqdianbiaoEleRecord selectPrevReading(
+            @Param("meterId") Long meterId,
+            @Param("timeKey") String timeKey);
+}
diff --git a/src/main/java/com/ruoyi/http/mapper/TqdianbiaoMeterMapper.java b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoMeterMapper.java
new file mode 100644
index 0000000..51ed2fe
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoMeterMapper.java
@@ -0,0 +1,7 @@
+package com.ruoyi.http.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+
+public interface TqdianbiaoMeterMapper extends BaseMapper<TqdianbiaoMeter> {
+}
diff --git a/src/main/java/com/ruoyi/http/mapper/TqdianbiaoSyncLogMapper.java b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoSyncLogMapper.java
new file mode 100644
index 0000000..c7edfe6
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/mapper/TqdianbiaoSyncLogMapper.java
@@ -0,0 +1,7 @@
+package com.ruoyi.http.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.http.pojo.TqdianbiaoSyncLog;
+
+public interface TqdianbiaoSyncLogMapper extends BaseMapper<TqdianbiaoSyncLog> {
+}
diff --git a/src/main/java/com/ruoyi/http/pojo/TqdianbiaoCollector.java b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoCollector.java
new file mode 100644
index 0000000..45580f9
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoCollector.java
@@ -0,0 +1,40 @@
+package com.ruoyi.http.pojo;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("tqdianbiao_collector")
+public class TqdianbiaoCollector {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String collectorId;
+
+    private String collectorNo;
+
+    private Boolean online;
+
+    private Integer csq;
+
+    private String connectTime;
+
+    private String disconnectTime;
+
+    private String description;
+
+    private LocalDateTime syncTime;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+
+    @TableField(exist = false)
+    private String keyword;
+}
diff --git a/src/main/java/com/ruoyi/http/pojo/TqdianbiaoEleRecord.java b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoEleRecord.java
new file mode 100644
index 0000000..9589247
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoEleRecord.java
@@ -0,0 +1,70 @@
+package com.ruoyi.http.pojo;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("tqdianbiao_ele_record")
+public class TqdianbiaoEleRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long meterId;
+
+    /** 鐢佃〃鍚嶇О(鍐椾綑) */
+    private String meterName;
+
+    private String dimension;
+
+    /** 鎶勮〃鏂瑰紡 sync/manual */
+    private String readingMethod;
+
+    private String timeKey;
+
+    private String startTime;
+
+    private String endTime;
+
+    private BigDecimal totalConsumption;
+
+    private BigDecimal sharpConsumption;
+
+    private BigDecimal peakConsumption;
+
+    private BigDecimal flatConsumption;
+
+    private BigDecimal valleyConsumption;
+
+    private BigDecimal deepValleyConsumption;
+
+    private String startReading;
+
+    private String endReading;
+
+    /** 涓婃鐢甸噺(鎬昏鏁�) */
+    private BigDecimal prevReading;
+
+    /** 鏈鐢甸噺(鎬昏鏁�) */
+    private BigDecimal currReading;
+
+    private Integer ratio;
+
+    private LocalDateTime syncTime;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+
+    @TableField(exist = false)
+    private String startTimeKey;
+
+    @TableField(exist = false)
+    private String endTimeKey;
+}
diff --git a/src/main/java/com/ruoyi/http/pojo/TqdianbiaoMeter.java b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoMeter.java
new file mode 100644
index 0000000..794bebb
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoMeter.java
@@ -0,0 +1,50 @@
+package com.ruoyi.http.pojo;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("tqdianbiao_meter")
+public class TqdianbiaoMeter {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long meterId;
+
+    /** 鐢佃〃鍚嶇О */
+    private String meterName;
+
+    private String collectorId;
+
+    private String collectorNo;
+
+    private String address;
+
+    private Integer rate;
+
+    private String relayState;
+
+    private String meterType;
+
+    private Integer csq;
+
+    private String description;
+
+    /** 鏉ユ簮 sync-骞冲彴鍚屾 manual-鎵嬪姩娣诲姞 */
+    private String source;
+
+    private LocalDateTime syncTime;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+
+    @TableField(exist = false)
+    private String keyword;
+}
diff --git a/src/main/java/com/ruoyi/http/pojo/TqdianbiaoSyncLog.java b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoSyncLog.java
new file mode 100644
index 0000000..5255fd8
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/pojo/TqdianbiaoSyncLog.java
@@ -0,0 +1,32 @@
+package com.ruoyi.http.pojo;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("tqdianbiao_sync_log")
+public class TqdianbiaoSyncLog {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String syncType;
+
+    private String windowStart;
+
+    private String windowEnd;
+
+    private String status;
+
+    private Integer recordCount;
+
+    private Integer apiCallCount;
+
+    private String errorMsg;
+
+    private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/ruoyi/http/service/StatisticEleService.java b/src/main/java/com/ruoyi/http/service/StatisticEleService.java
new file mode 100644
index 0000000..9b0cb00
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/StatisticEleService.java
@@ -0,0 +1,26 @@
+package com.ruoyi.http.service;
+
+import com.ruoyi.http.vo.StatisticEleRecordVo;
+import com.ruoyi.http.vo.StatisticEleSummaryVo;
+import com.ruoyi.http.vo.StatisticEleSyncStatusVo;
+
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.List;
+
+/**
+ * 澶╁惎鐢佃〃缁熻鎺ュ彛
+ */
+public interface StatisticEleService {
+
+    String fetchRawData(String dimension, String startTime, String endTime);
+
+    List<StatisticEleRecordVo> listRecords(String dimension, String startTime, String endTime, Integer ignoreRadio);
+
+    StatisticEleSummaryVo getSummary(String dimension, String startTime, String endTime);
+
+    StatisticEleSummaryVo getYesterdaySummary();
+
+    void exportRecords(String dimension, String startTime, String endTime, HttpServletResponse response);
+
+    StatisticEleSyncStatusVo getSyncStatus();
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorManageService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorManageService.java
new file mode 100644
index 0000000..c1879e5
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorManageService.java
@@ -0,0 +1,21 @@
+package com.ruoyi.http.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.http.pojo.TqdianbiaoCollector;
+
+import java.util.List;
+
+public interface TqdianbiaoCollectorManageService extends IService<TqdianbiaoCollector> {
+
+    IPage<TqdianbiaoCollector> listPage(Page page, TqdianbiaoCollector query);
+
+    boolean addCollector(TqdianbiaoCollector collector);
+
+    boolean updateCollector(TqdianbiaoCollector collector);
+
+    boolean deleteByIds(List<Long> ids);
+
+    int syncFromRemote();
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorSyncService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorSyncService.java
new file mode 100644
index 0000000..c68ef5e
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoCollectorSyncService.java
@@ -0,0 +1,6 @@
+package com.ruoyi.http.service;
+
+public interface TqdianbiaoCollectorSyncService {
+
+    int syncCollectors();
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoEleRecordManageService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoEleRecordManageService.java
new file mode 100644
index 0000000..b5e2403
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoEleRecordManageService.java
@@ -0,0 +1,22 @@
+package com.ruoyi.http.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public interface TqdianbiaoEleRecordManageService extends IService<TqdianbiaoEleRecord> {
+
+    IPage<TqdianbiaoEleRecord> listPage(Page page, TqdianbiaoEleRecord query);
+
+    boolean addRecord(TqdianbiaoEleRecord record);
+
+    boolean updateRecord(TqdianbiaoEleRecord record);
+
+    boolean deleteByIds(List<Long> ids);
+
+    BigDecimal getPrevReading(Long meterId, String timeKey);
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoEleSyncService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoEleSyncService.java
new file mode 100644
index 0000000..0cff7e2
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoEleSyncService.java
@@ -0,0 +1,15 @@
+package com.ruoyi.http.service;
+
+public interface TqdianbiaoEleSyncService {
+
+    int syncHourData();
+
+    /** 涓�娆℃�цˉ鏁帮細鍚屾鍓嶄笁澶� 00:00 鑷冲綋鍓嶅皬鏃讹紝鐢ㄥ畬鍙垹 */
+    int syncLast3DaysHourData();
+
+    int syncDayData();
+
+    int syncMonthData();
+
+    int syncYearData();
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoMeterManageService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoMeterManageService.java
new file mode 100644
index 0000000..5e6aec7
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoMeterManageService.java
@@ -0,0 +1,21 @@
+package com.ruoyi.http.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+
+import java.util.List;
+
+public interface TqdianbiaoMeterManageService extends IService<TqdianbiaoMeter> {
+
+    IPage<TqdianbiaoMeter> listPage(Page page, TqdianbiaoMeter query);
+
+    boolean addMeter(TqdianbiaoMeter meter);
+
+    boolean updateMeter(TqdianbiaoMeter meter);
+
+    boolean deleteByIds(List<Long> ids);
+
+    int syncFromRemote();
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoMeterSyncService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoMeterSyncService.java
new file mode 100644
index 0000000..eb0f264
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoMeterSyncService.java
@@ -0,0 +1,6 @@
+package com.ruoyi.http.service;
+
+public interface TqdianbiaoMeterSyncService {
+
+    int syncMeters();
+}
diff --git a/src/main/java/com/ruoyi/http/service/TqdianbiaoSyncLogService.java b/src/main/java/com/ruoyi/http/service/TqdianbiaoSyncLogService.java
new file mode 100644
index 0000000..c66eb95
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/TqdianbiaoSyncLogService.java
@@ -0,0 +1,8 @@
+package com.ruoyi.http.service;
+
+public interface TqdianbiaoSyncLogService {
+
+    void logSuccess(String syncType, String windowStart, String windowEnd, int recordCount);
+
+    void logFailure(String syncType, String windowStart, String windowEnd, String errorMsg);
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java
new file mode 100644
index 0000000..878e927
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java
@@ -0,0 +1,179 @@
+package com.ruoyi.http.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.http.HttpUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.http.config.TqdianbiaoConfig;
+import com.ruoyi.http.mapper.TqdianbiaoCollectorMapper;
+import com.ruoyi.http.mapper.TqdianbiaoEleRecordMapper;
+import com.ruoyi.http.mapper.TqdianbiaoMeterMapper;
+import com.ruoyi.http.mapper.TqdianbiaoSyncLogMapper;
+import com.ruoyi.http.pojo.TqdianbiaoCollector;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+import com.ruoyi.http.pojo.TqdianbiaoSyncLog;
+import com.ruoyi.http.service.StatisticEleService;
+import com.ruoyi.http.util.StatisticEleAggregateUtil;
+import com.ruoyi.http.util.StatisticEleAggregateUtil.HourRange;
+import com.ruoyi.http.vo.StatisticEleRecordVo;
+import com.ruoyi.http.vo.StatisticEleSummaryVo;
+import com.ruoyi.http.vo.StatisticEleSyncStatusVo;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class StatisticEleServiceImpl implements StatisticEleService {
+
+    private static final Set<String> STAT_DIMENSIONS = Set.of("day", "month", "quarter", "year");
+    private static final List<String> DATA_DIMENSIONS = List.of("hour", "manual");
+    private static final DateTimeFormatter LOG_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private final TqdianbiaoConfig config;
+    private final TqdianbiaoEleRecordMapper eleRecordMapper;
+    private final TqdianbiaoMeterMapper meterMapper;
+    private final TqdianbiaoCollectorMapper collectorMapper;
+    private final TqdianbiaoSyncLogMapper syncLogMapper;
+
+    @Override
+    public String fetchRawData(String dimension, String startTime, String endTime) {
+        if (!"hour".equals(dimension)) {
+            throw new ServiceException("浠呮敮鎸佹媺鍙栧皬鏃跺師濮嬫暟鎹�");
+        }
+        String url = config.getBaseUrl() + "/Api/StatisticEle/hour";
+        String param = String.format(
+                "auth=%s&start_time=%s&end_time=%s&ignore_radio=%d",
+                config.getAuth(), startTime, endTime, config.getIgnoreRadio()
+        );
+        log.warn("璋冪敤杩滅▼鐢佃〃鎺ュ彛(璋冭瘯): {}?{}", url, param);
+        return HttpUtils.sendGet(url, param);
+    }
+
+    @Override
+    public List<StatisticEleRecordVo> listRecords(String dimension, String startTime, String endTime, Integer ignoreRadio) {
+        if ("hour".equals(dimension) || "collection".equals(dimension)) {
+            return queryHourRecords(startTime, endTime);
+        }
+        return aggregateFromHour(dimension, startTime, endTime, true);
+    }
+
+    @Override
+    public StatisticEleSummaryVo getSummary(String dimension, String startTime, String endTime) {
+        if (!StringUtils.hasText(startTime) || !StringUtils.hasText(endTime)) {
+            throw new ServiceException("寮�濮嬫椂闂村拰缁撴潫鏃堕棿涓嶈兘涓虹┖");
+        }
+        if ("hour".equals(dimension)) {
+            List<StatisticEleRecordVo> detailRecords = queryHourRecords(startTime, endTime);
+            List<StatisticEleRecordVo> chartRecords = StatisticEleAggregateUtil.aggregateHourToBuckets(
+                    detailRecords, StatisticEleAggregateUtil.HOUR_TO_HOUR);
+            return buildSummary(detailRecords, chartRecords);
+        }
+        if (!STAT_DIMENSIONS.contains(dimension)) {
+            throw new ServiceException("缁熻缁村害鏃犳晥锛屾敮鎸� hour/day/month/quarter/year");
+        }
+
+        List<StatisticEleRecordVo> detailRecords = aggregateFromHour(dimension, startTime, endTime, true);
+        List<StatisticEleRecordVo> chartRecords = aggregateFromHour(dimension, startTime, endTime, false);
+        return buildSummary(detailRecords, chartRecords);
+    }
+
+    private StatisticEleSummaryVo buildSummary(
+            List<StatisticEleRecordVo> detailRecords, List<StatisticEleRecordVo> chartRecords) {
+        StatisticEleAggregateUtil.StatisticEleSummaryMetrics metrics =
+                StatisticEleAggregateUtil.calcMetrics(chartRecords);
+        StatisticEleSummaryVo summary = new StatisticEleSummaryVo();
+        summary.setRecords(detailRecords);
+        summary.setChartRecords(chartRecords);
+        summary.setRecordCount(metrics.getRecordCount());
+        summary.setTotalConsumption(metrics.getTotalConsumption());
+        summary.setAvgConsumption(metrics.getAvgConsumption());
+        summary.setMaxConsumption(metrics.getMaxConsumption());
+        summary.setMinConsumption(metrics.getMinConsumption());
+        return summary;
+    }
+
+    @Override
+    public StatisticEleSummaryVo getYesterdaySummary() {
+        HourRange range = StatisticEleAggregateUtil.yesterdayHourRange();
+        List<StatisticEleRecordVo> records = queryHourRecords(range.startTime(), range.endTime());
+        List<StatisticEleRecordVo> chartRecords = StatisticEleAggregateUtil.aggregateHourToBuckets(
+                records, StatisticEleAggregateUtil.HOUR_TO_HOUR);
+        StatisticEleSummaryVo summary = buildSummary(records, chartRecords);
+        summary.setTotalConsumption(round(StatisticEleAggregateUtil.sumRecordsTotal(records)));
+        return summary;
+    }
+
+    private static double round(double value) {
+        return Math.round(value * 100.0) / 100.0;
+    }
+
+    @Override
+    public void exportRecords(String dimension, String startTime, String endTime, HttpServletResponse response) {
+        List<StatisticEleRecordVo> records;
+        if ("hour".equals(dimension)) {
+            records = queryHourRecords(startTime, endTime);
+        } else {
+            records = aggregateFromHour(dimension, startTime, endTime, true);
+        }
+        ExcelUtil<StatisticEleRecordVo> util = new ExcelUtil<>(StatisticEleRecordVo.class);
+        util.exportExcel(response, records, "鑳借�楃粺璁℃暟鎹�");
+    }
+
+    @Override
+    public StatisticEleSyncStatusVo getSyncStatus() {
+        StatisticEleSyncStatusVo status = new StatisticEleSyncStatusVo();
+        status.setMeterCount(Math.toIntExact(meterMapper.selectCount(null)));
+        status.setCollectorCount(Math.toIntExact(collectorMapper.selectCount(null)));
+        status.setOnlineCollectorCount(Math.toIntExact(collectorMapper.selectCount(
+                Wrappers.<TqdianbiaoCollector>lambdaQuery().eq(TqdianbiaoCollector::getOnline, true))));
+
+        Map<String, Long> recordCountByDimension = new HashMap<>();
+        recordCountByDimension.put("hour", eleRecordMapper.selectCount(
+                Wrappers.<TqdianbiaoEleRecord>lambdaQuery().eq(TqdianbiaoEleRecord::getDimension, "hour")));
+        status.setRecordCountByDimension(recordCountByDimension);
+
+        Map<String, String> lastSyncTimeByType = new HashMap<>();
+        for (String syncType : List.of("collector", "meter", "hour")) {
+            TqdianbiaoSyncLog latest = syncLogMapper.selectOne(
+                    Wrappers.<TqdianbiaoSyncLog>lambdaQuery()
+                            .eq(TqdianbiaoSyncLog::getSyncType, syncType)
+                            .eq(TqdianbiaoSyncLog::getStatus, "success")
+                            .orderByDesc(TqdianbiaoSyncLog::getCreateTime)
+                            .last("LIMIT 1"));
+            if (latest != null && latest.getCreateTime() != null) {
+                lastSyncTimeByType.put(syncType, latest.getCreateTime().format(LOG_TIME_FMT));
+            }
+        }
+        status.setLastSyncTimeByType(lastSyncTimeByType);
+        return status;
+    }
+
+    private List<StatisticEleRecordVo> queryHourRecords(String startTime, String endTime) {
+        String normalizedStart = StatisticEleAggregateUtil.normalizeQueryStartTimeKey(startTime);
+        String normalizedEnd = StatisticEleAggregateUtil.normalizeQueryEndTimeKey(endTime);
+        return eleRecordMapper.selectRecordList(DATA_DIMENSIONS, normalizedStart, normalizedEnd);
+    }
+
+    private List<StatisticEleRecordVo> aggregateFromHour(
+            String dimension, String startTime, String endTime, boolean perMeter) {
+        HourRange range = StatisticEleAggregateUtil.toHourQueryRange(dimension, startTime, endTime);
+        List<StatisticEleRecordVo> hourRecords = queryHourRecords(range.startTime(), range.endTime());
+        Function<String, String> bucketFn = StatisticEleAggregateUtil.bucketFn(dimension);
+        if (perMeter) {
+            return StatisticEleAggregateUtil.aggregateHourPerMeter(hourRecords, bucketFn);
+        }
+        return StatisticEleAggregateUtil.aggregateHourToBuckets(hourRecords, bucketFn);
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorManageServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorManageServiceImpl.java
new file mode 100644
index 0000000..3661d10
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorManageServiceImpl.java
@@ -0,0 +1,91 @@
+package com.ruoyi.http.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.http.mapper.TqdianbiaoCollectorMapper;
+import com.ruoyi.http.pojo.TqdianbiaoCollector;
+import com.ruoyi.http.service.TqdianbiaoCollectorManageService;
+import com.ruoyi.http.service.TqdianbiaoCollectorSyncService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class TqdianbiaoCollectorManageServiceImpl
+        extends ServiceImpl<TqdianbiaoCollectorMapper, TqdianbiaoCollector>
+        implements TqdianbiaoCollectorManageService {
+
+    private final TqdianbiaoCollectorSyncService collectorSyncService;
+
+    @Override
+    public IPage<TqdianbiaoCollector> listPage(Page page, TqdianbiaoCollector query) {
+        return page(page, Wrappers.<TqdianbiaoCollector>lambdaQuery()
+                .and(StringUtils.isNotEmpty(query.getKeyword()), w -> w
+                        .like(TqdianbiaoCollector::getCollectorNo, query.getKeyword())
+                        .or()
+                        .like(TqdianbiaoCollector::getCollectorId, query.getKeyword())
+                        .or()
+                        .like(TqdianbiaoCollector::getDescription, query.getKeyword()))
+                .eq(StringUtils.isNotEmpty(query.getCollectorId()), TqdianbiaoCollector::getCollectorId, query.getCollectorId())
+                .orderByDesc(TqdianbiaoCollector::getUpdateTime));
+    }
+
+    @Override
+    public boolean addCollector(TqdianbiaoCollector collector) {
+        validateCollector(collector);
+        if (existsCollectorId(collector.getCollectorId(), null)) {
+            throw new ServiceException("閲囬泦鍣ㄦ。妗圛D宸插瓨鍦�");
+        }
+        collector.setSyncTime(LocalDateTime.now());
+        return save(collector);
+    }
+
+    @Override
+    public boolean updateCollector(TqdianbiaoCollector collector) {
+        if (collector.getId() == null) {
+            throw new ServiceException("ID涓嶈兘涓虹┖");
+        }
+        validateCollector(collector);
+        if (existsCollectorId(collector.getCollectorId(), collector.getId())) {
+            throw new ServiceException("閲囬泦鍣ㄦ。妗圛D宸插瓨鍦�");
+        }
+        collector.setSyncTime(LocalDateTime.now());
+        return updateById(collector);
+    }
+
+    @Override
+    public boolean deleteByIds(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            throw new ServiceException("璇烽�夋嫨鑷冲皯涓�鏉℃暟鎹�");
+        }
+        return removeBatchByIds(ids);
+    }
+
+    @Override
+    public int syncFromRemote() {
+        return collectorSyncService.syncCollectors();
+    }
+
+    private void validateCollector(TqdianbiaoCollector collector) {
+        if (StringUtils.isEmpty(collector.getCollectorId())) {
+            throw new ServiceException("閲囬泦鍣ㄦ。妗圛D涓嶈兘涓虹┖");
+        }
+        if (StringUtils.isEmpty(collector.getCollectorNo())) {
+            throw new ServiceException("閲囬泦鍣ㄥ彿涓嶈兘涓虹┖");
+        }
+    }
+
+    private boolean existsCollectorId(String collectorId, Long excludeId) {
+        return count(Wrappers.<TqdianbiaoCollector>lambdaQuery()
+                .eq(TqdianbiaoCollector::getCollectorId, collectorId)
+                .ne(excludeId != null, TqdianbiaoCollector::getId, excludeId)) > 0;
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorSyncServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorSyncServiceImpl.java
new file mode 100644
index 0000000..b54638e
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoCollectorSyncServiceImpl.java
@@ -0,0 +1,90 @@
+package com.ruoyi.http.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.ruoyi.common.utils.http.HttpUtils;
+import com.ruoyi.http.config.TqdianbiaoConfig;
+import com.ruoyi.http.mapper.TqdianbiaoCollectorMapper;
+import com.ruoyi.http.pojo.TqdianbiaoCollector;
+import com.ruoyi.http.service.TqdianbiaoCollectorSyncService;
+import com.ruoyi.http.service.TqdianbiaoSyncLogService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class TqdianbiaoCollectorSyncServiceImpl implements TqdianbiaoCollectorSyncService {
+
+    private final TqdianbiaoConfig config;
+    private final TqdianbiaoCollectorMapper collectorMapper;
+    private final TqdianbiaoSyncLogService syncLogService;
+
+    @Override
+    public int syncCollectors() {
+        String syncType = "collector";
+        try {
+            String url = config.getBaseUrl() + "/Api/Collector";
+            String param = "auth=" + config.getAuth();
+            String raw = HttpUtils.sendGet(url, param);
+            JSONArray list = parseList(raw);
+            LocalDateTime now = LocalDateTime.now();
+            int count = 0;
+            for (int i = 0; i < list.size(); i++) {
+                JSONObject item = list.getJSONObject(i);
+                String collectorId = item.getString("id");
+                if (collectorId == null) {
+                    continue;
+                }
+                TqdianbiaoCollector entity = collectorMapper.selectOne(
+                        Wrappers.<TqdianbiaoCollector>lambdaQuery()
+                                .eq(TqdianbiaoCollector::getCollectorId, collectorId)
+                                .last("LIMIT 1"));
+                if (entity == null) {
+                    entity = new TqdianbiaoCollector();
+                    entity.setCollectorId(collectorId);
+                }
+                entity.setCollectorNo(item.getString("collectorid"));
+                entity.setOnline(item.getBoolean("online"));
+                entity.setCsq(item.getInteger("csq"));
+                entity.setConnectTime(item.getString("connect_time"));
+                entity.setDisconnectTime(item.getString("disconnect_time"));
+                entity.setDescription(item.getString("description"));
+                entity.setSyncTime(now);
+                if (entity.getId() == null) {
+                    collectorMapper.insert(entity);
+                } else {
+                    collectorMapper.updateById(entity);
+                }
+                count++;
+            }
+            syncLogService.logSuccess(syncType, null, null, count);
+            return count;
+        } catch (Exception e) {
+            log.error("閲囬泦鍣ㄥ悓姝ュけ璐�", e);
+            syncLogService.logFailure(syncType, null, null, e.getMessage());
+            throw e;
+        }
+    }
+
+    private JSONArray parseList(String raw) {
+        Object parsed = JSON.parse(raw);
+        if (parsed instanceof JSONArray) {
+            return (JSONArray) parsed;
+        }
+        JSONObject root = (JSONObject) parsed;
+        if (root.getIntValue("status") != 1) {
+            throw new IllegalStateException("閲囬泦鍣ㄦ帴鍙h繑鍥炲紓甯�: " + raw);
+        }
+        Object data = root.get("data");
+        if (data instanceof JSONArray) {
+            return (JSONArray) data;
+        }
+        return new JSONArray();
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleRecordManageServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleRecordManageServiceImpl.java
new file mode 100644
index 0000000..43ec32c
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleRecordManageServiceImpl.java
@@ -0,0 +1,140 @@
+package com.ruoyi.http.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.http.mapper.TqdianbiaoEleRecordMapper;
+import com.ruoyi.http.mapper.TqdianbiaoMeterMapper;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+import com.ruoyi.http.service.TqdianbiaoEleRecordManageService;
+import com.ruoyi.http.util.StatisticEleReadingUtil;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class TqdianbiaoEleRecordManageServiceImpl
+        extends ServiceImpl<TqdianbiaoEleRecordMapper, TqdianbiaoEleRecord>
+        implements TqdianbiaoEleRecordManageService {
+
+    private static final Set<String> MANUAL_DIMENSIONS = Set.of("manual");
+
+    private final TqdianbiaoMeterMapper meterMapper;
+
+    public TqdianbiaoEleRecordManageServiceImpl(TqdianbiaoMeterMapper meterMapper) {
+        this.meterMapper = meterMapper;
+    }
+
+    @Override
+    public IPage<TqdianbiaoEleRecord> listPage(Page page, TqdianbiaoEleRecord query) {
+        return page(page, Wrappers.<TqdianbiaoEleRecord>lambdaQuery()
+                .eq(StringUtils.isNotEmpty(query.getDimension()), TqdianbiaoEleRecord::getDimension, query.getDimension())
+                .eq(query.getMeterId() != null, TqdianbiaoEleRecord::getMeterId, query.getMeterId())
+                .ge(StringUtils.isNotEmpty(query.getStartTimeKey()), TqdianbiaoEleRecord::getTimeKey, query.getStartTimeKey())
+                .le(StringUtils.isNotEmpty(query.getEndTimeKey()), TqdianbiaoEleRecord::getTimeKey, query.getEndTimeKey())
+                .orderByDesc(TqdianbiaoEleRecord::getTimeKey));
+    }
+
+    @Override
+    public boolean addRecord(TqdianbiaoEleRecord record) {
+        validateRecord(record);
+        enrichFromMeter(record);
+        if (existsUnique(record, null)) {
+            throw new ServiceException("璇ョ數琛ㄥ湪璇ユ椂闂寸偣宸插瓨鍦ㄨ褰�");
+        }
+        record.setSyncTime(LocalDateTime.now());
+        return save(record);
+    }
+
+    @Override
+    public boolean updateRecord(TqdianbiaoEleRecord record) {
+        if (record.getId() == null) {
+            throw new ServiceException("ID涓嶈兘涓虹┖");
+        }
+        validateRecord(record);
+        enrichFromMeter(record);
+        if (existsUnique(record, record.getId())) {
+            throw new ServiceException("璇ョ數琛ㄥ湪璇ユ椂闂寸偣宸插瓨鍦ㄨ褰�");
+        }
+        record.setSyncTime(LocalDateTime.now());
+        return updateById(record);
+    }
+
+    @Override
+    public boolean deleteByIds(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            throw new ServiceException("璇烽�夋嫨鑷冲皯涓�鏉℃暟鎹�");
+        }
+        return removeBatchByIds(ids);
+    }
+
+    @Override
+    public BigDecimal getPrevReading(Long meterId, String timeKey) {
+        if (meterId == null || StringUtils.isEmpty(timeKey)) {
+            return null;
+        }
+        TqdianbiaoEleRecord prev = baseMapper.selectPrevReading(meterId, timeKey);
+        if (prev == null) {
+            return null;
+        }
+        if (prev.getCurrReading() != null) {
+            return prev.getCurrReading();
+        }
+        return StatisticEleReadingUtil.parseFirstReading(prev.getEndReading());
+    }
+
+    private void enrichFromMeter(TqdianbiaoEleRecord record) {
+        TqdianbiaoMeter meter = meterMapper.selectOne(
+                Wrappers.<TqdianbiaoMeter>lambdaQuery()
+                        .eq(TqdianbiaoMeter::getMeterId, record.getMeterId())
+                        .last("LIMIT 1"));
+        if (meter != null) {
+            record.setMeterName(meter.getMeterName());
+            if (record.getRatio() == null && meter.getRate() != null) {
+                record.setRatio(meter.getRate());
+            }
+        }
+        if (record.getPrevReading() != null && record.getCurrReading() != null) {
+            record.setTotalConsumption(
+                    StatisticEleReadingUtil.calcConsumption(record.getPrevReading(), record.getCurrReading(), record.getRatio()));
+        }
+    }
+
+    private void validateRecord(TqdianbiaoEleRecord record) {
+        if (record.getMeterId() == null) {
+            throw new ServiceException("鐢佃〃涓嶈兘涓虹┖");
+        }
+        if (StringUtils.isEmpty(record.getDimension()) || !MANUAL_DIMENSIONS.contains(record.getDimension())) {
+            throw new ServiceException("浠呮敮鎸佹墜鍔ㄥ綍鍏�(manual)缁村害鏁版嵁");
+        }
+        if (StringUtils.isEmpty(record.getTimeKey())) {
+            throw new ServiceException("鏃堕棿鏍囪瘑涓嶈兘涓虹┖");
+        }
+        if (record.getTimeKey().length() != 12) {
+            throw new ServiceException("鎵嬪姩褰曞叆鏃堕棿鏍囪瘑鏍煎紡搴斾负 YYYYMMDDHHmm");
+        }
+        if (record.getCurrReading() == null) {
+            throw new ServiceException("鏈鐢甸噺涓嶈兘涓虹┖");
+        }
+        if (record.getPrevReading() == null) {
+            throw new ServiceException("涓婃鐢甸噺涓嶈兘涓虹┖");
+        }
+        record.setReadingMethod("manual");
+    }
+
+    private boolean existsUnique(TqdianbiaoEleRecord record, Long excludeId) {
+        return count(Wrappers.<TqdianbiaoEleRecord>lambdaQuery()
+                .eq(TqdianbiaoEleRecord::getMeterId, record.getMeterId())
+                .eq(TqdianbiaoEleRecord::getDimension, record.getDimension())
+                .eq(TqdianbiaoEleRecord::getTimeKey, record.getTimeKey())
+                .ne(excludeId != null, TqdianbiaoEleRecord::getId, excludeId)) > 0;
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleSyncServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleSyncServiceImpl.java
new file mode 100644
index 0000000..d7c6cf5
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoEleSyncServiceImpl.java
@@ -0,0 +1,150 @@
+package com.ruoyi.http.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.ruoyi.common.utils.http.HttpUtils;
+import com.ruoyi.http.config.TqdianbiaoConfig;
+import com.ruoyi.http.mapper.TqdianbiaoEleRecordMapper;
+import com.ruoyi.http.mapper.TqdianbiaoMeterMapper;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+import com.ruoyi.http.service.TqdianbiaoEleSyncService;
+import com.ruoyi.http.service.TqdianbiaoSyncLogService;
+import com.ruoyi.http.util.StatisticEleParseUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class TqdianbiaoEleSyncServiceImpl implements TqdianbiaoEleSyncService {
+
+    private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("yyyyMMddHH");
+    private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
+    private static final DateTimeFormatter YEAR_FMT = DateTimeFormatter.ofPattern("yyyy");
+
+    private final TqdianbiaoConfig config;
+    private final TqdianbiaoEleRecordMapper eleRecordMapper;
+    private final TqdianbiaoMeterMapper meterMapper;
+    private final TqdianbiaoSyncLogService syncLogService;
+
+    @Override
+    public int syncHourData() {
+        int window = config.getSync().getHourWindow() != null ? config.getSync().getHourWindow() : 2;
+        LocalDateTime end = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
+        LocalDateTime start = end.minusHours(window);
+        String startTime = start.format(HOUR_FMT);
+        String endTime = end.format(HOUR_FMT);
+        return syncDimension("hour", startTime, endTime);
+    }
+
+    @Override
+    public int syncLast3DaysHourData() {
+        LocalDateTime start = LocalDate.now().minusDays(3).atStartOfDay();
+        LocalDateTime end = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
+        return syncHourRangeInChunks(start, end);
+    }
+
+    /** 鎸夋渶澶� 24 灏忔椂涓�娈靛垎鎵规媺鍙栵紙骞冲彴鎺ュ彛闄愬埗锛� */
+    private int syncHourRangeInChunks(LocalDateTime start, LocalDateTime end) {
+        int total = 0;
+        LocalDateTime windowStart = start;
+        while (!windowStart.isAfter(end)) {
+            LocalDateTime windowEnd = windowStart.plusHours(23);
+            if (windowEnd.isAfter(end)) {
+                windowEnd = end;
+            }
+            total += syncDimension("hour", windowStart.format(HOUR_FMT), windowEnd.format(HOUR_FMT));
+            windowStart = windowEnd.plusHours(1);
+        }
+        return total;
+    }
+
+    @Override
+    public int syncDayData() {
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+        String day = yesterday.format(DAY_FMT);
+        return syncDimension("day", day, day);
+    }
+
+    @Override
+    public int syncMonthData() {
+        LocalDate now = LocalDate.now();
+        String month = now.format(MONTH_FMT);
+        return syncDimension("month", month, month);
+    }
+
+    @Override
+    public int syncYearData() {
+        LocalDate now = LocalDate.now();
+        String year = now.format(YEAR_FMT);
+        return syncDimension("year", year, year);
+    }
+
+    private int syncDimension(String dimension, String startTime, String endTime) {
+        return syncDimension(dimension, startTime, endTime, config.getIgnoreRadio());
+    }
+
+    private int syncDimension(String dimension, String startTime, String endTime, int ignoreRadio) {
+        try {
+            String url = config.getBaseUrl() + "/Api/StatisticEle/" + dimension;
+            String param = String.format(
+                    "auth=%s&start_time=%s&end_time=%s&ignore_radio=%d",
+                    config.getAuth(), startTime, endTime, ignoreRadio
+            );
+            log.info("鍚屾鐢甸噺鏁版嵁: {} {}-{} ignore_radio={}", dimension, startTime, endTime, ignoreRadio);
+            String raw = HttpUtils.sendGet(url, param);
+            List<TqdianbiaoEleRecord> records = StatisticEleParseUtil.parseToEntities(raw, dimension);
+            enrichMeterInfo(records);
+            int count = upsertRecords(records);
+            syncLogService.logSuccess(dimension, startTime, endTime, count);
+            return count;
+        } catch (Exception e) {
+            log.error("鐢甸噺鍚屾澶辫触 dimension={} {}-{}", dimension, startTime, endTime, e);
+            syncLogService.logFailure(dimension, startTime, endTime, e.getMessage());
+            throw e;
+        }
+    }
+
+    private void enrichMeterInfo(List<TqdianbiaoEleRecord> records) {
+        for (TqdianbiaoEleRecord record : records) {
+            if (record.getMeterId() == null) {
+                continue;
+            }
+            TqdianbiaoMeter meter = meterMapper.selectOne(
+                    Wrappers.<TqdianbiaoMeter>lambdaQuery()
+                            .eq(TqdianbiaoMeter::getMeterId, record.getMeterId())
+                            .last("LIMIT 1"));
+            if (meter != null && StringUtils.hasText(meter.getMeterName())) {
+                record.setMeterName(meter.getMeterName());
+            }
+        }
+    }
+
+    private int upsertRecords(List<TqdianbiaoEleRecord> records) {
+        int count = 0;
+        for (TqdianbiaoEleRecord record : records) {
+            TqdianbiaoEleRecord existing = eleRecordMapper.selectOne(
+                    Wrappers.<TqdianbiaoEleRecord>lambdaQuery()
+                            .eq(TqdianbiaoEleRecord::getMeterId, record.getMeterId())
+                            .eq(TqdianbiaoEleRecord::getDimension, record.getDimension())
+                            .eq(TqdianbiaoEleRecord::getTimeKey, record.getTimeKey())
+                            .last("LIMIT 1"));
+            if (existing == null) {
+                eleRecordMapper.insert(record);
+            } else {
+                record.setId(existing.getId());
+                eleRecordMapper.updateById(record);
+            }
+            count++;
+        }
+        return count;
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterManageServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterManageServiceImpl.java
new file mode 100644
index 0000000..fafafba
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterManageServiceImpl.java
@@ -0,0 +1,120 @@
+package com.ruoyi.http.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.http.mapper.TqdianbiaoMeterMapper;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+import com.ruoyi.http.service.TqdianbiaoMeterManageService;
+import com.ruoyi.http.service.TqdianbiaoMeterSyncService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class TqdianbiaoMeterManageServiceImpl
+        extends ServiceImpl<TqdianbiaoMeterMapper, TqdianbiaoMeter>
+        implements TqdianbiaoMeterManageService {
+
+    private static final long MANUAL_METER_ID_BASE = 900_000_000L;
+
+    private final TqdianbiaoMeterSyncService meterSyncService;
+
+    @Override
+    public IPage<TqdianbiaoMeter> listPage(Page page, TqdianbiaoMeter query) {
+        return page(page, Wrappers.<TqdianbiaoMeter>lambdaQuery()
+                .and(StringUtils.isNotEmpty(query.getKeyword()), w -> w
+                        .like(TqdianbiaoMeter::getMeterName, query.getKeyword())
+                        .or()
+                        .like(TqdianbiaoMeter::getAddress, query.getKeyword())
+                        .or()
+                        .like(TqdianbiaoMeter::getDescription, query.getKeyword()))
+                .eq(query.getMeterId() != null, TqdianbiaoMeter::getMeterId, query.getMeterId())
+                .eq(StringUtils.isNotEmpty(query.getSource()), TqdianbiaoMeter::getSource, query.getSource())
+                .orderByDesc(TqdianbiaoMeter::getUpdateTime));
+    }
+
+    @Override
+    public boolean addMeter(TqdianbiaoMeter meter) {
+        validateManualMeter(meter);
+        meter.setSource("manual");
+        meter.setMeterId(generateManualMeterId());
+        if (meter.getRate() == null) {
+            meter.setRate(1);
+        }
+        if (StringUtils.isEmpty(meter.getRelayState())) {
+            meter.setRelayState("1");
+        }
+        meter.setSyncTime(LocalDateTime.now());
+        return save(meter);
+    }
+
+    @Override
+    public boolean updateMeter(TqdianbiaoMeter meter) {
+        if (meter.getId() == null) {
+            throw new ServiceException("ID涓嶈兘涓虹┖");
+        }
+        TqdianbiaoMeter existing = getById(meter.getId());
+        if (existing == null) {
+            throw new ServiceException("鐢佃〃涓嶅瓨鍦�");
+        }
+        if (StringUtils.isEmpty(meter.getAddress())) {
+            throw new ServiceException("琛ㄥ湴鍧�涓嶈兘涓虹┖");
+        }
+        String meterName = StringUtils.isNotEmpty(meter.getMeterName()) ? meter.getMeterName() : meter.getAddress();
+        existing.setMeterName(meterName);
+        existing.setAddress(meter.getAddress());
+        existing.setDescription(meter.getDescription());
+        existing.setRelayState(meter.getRelayState());
+        if (existing.getSource() == null) {
+            existing.setSource("sync");
+        }
+        if ("manual".equals(existing.getSource())) {
+            existing.setRate(meter.getRate());
+        }
+        existing.setSyncTime(LocalDateTime.now());
+        return updateById(existing);
+    }
+
+    @Override
+    public boolean deleteByIds(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            throw new ServiceException("璇烽�夋嫨鑷冲皯涓�鏉℃暟鎹�");
+        }
+        return removeBatchByIds(ids);
+    }
+
+    @Override
+    public int syncFromRemote() {
+        return meterSyncService.syncMeters();
+    }
+
+    private void validateManualMeter(TqdianbiaoMeter meter) {
+        if (StringUtils.isEmpty(meter.getMeterName())) {
+            throw new ServiceException("鐢佃〃鍚嶇О涓嶈兘涓虹┖");
+        }
+        if (StringUtils.isEmpty(meter.getAddress())) {
+            throw new ServiceException("琛ㄥ湴鍧�涓嶈兘涓虹┖");
+        }
+    }
+
+    private Long generateManualMeterId() {
+        TqdianbiaoMeter maxManual = getOne(
+                Wrappers.<TqdianbiaoMeter>lambdaQuery()
+                        .eq(TqdianbiaoMeter::getSource, "manual")
+                        .orderByDesc(TqdianbiaoMeter::getMeterId)
+                        .last("LIMIT 1"),
+                false);
+        if (maxManual == null || maxManual.getMeterId() == null) {
+            return MANUAL_METER_ID_BASE + 1;
+        }
+        return maxManual.getMeterId() + 1;
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterSyncServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterSyncServiceImpl.java
new file mode 100644
index 0000000..33adca4
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoMeterSyncServiceImpl.java
@@ -0,0 +1,112 @@
+package com.ruoyi.http.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.ruoyi.common.utils.http.HttpUtils;
+import com.ruoyi.http.config.TqdianbiaoConfig;
+import com.ruoyi.http.mapper.TqdianbiaoMeterMapper;
+import com.ruoyi.http.pojo.TqdianbiaoMeter;
+import com.ruoyi.http.service.TqdianbiaoMeterSyncService;
+import com.ruoyi.http.service.TqdianbiaoSyncLogService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class TqdianbiaoMeterSyncServiceImpl implements TqdianbiaoMeterSyncService {
+
+    private final TqdianbiaoConfig config;
+    private final TqdianbiaoMeterMapper meterMapper;
+    private final TqdianbiaoSyncLogService syncLogService;
+
+    @Override
+    public int syncMeters() {
+        String syncType = "meter";
+        try {
+            String url = config.getBaseUrl() + "/Api/Meter";
+            String param = "auth=" + config.getAuth();
+            String raw = HttpUtils.sendGet(url, param);
+            JSONArray list = parseList(raw);
+            LocalDateTime now = LocalDateTime.now();
+            int count = 0;
+            for (int i = 0; i < list.size(); i++) {
+                JSONObject item = list.getJSONObject(i);
+                if (!"0".equals(String.valueOf(item.get("device_type")))) {
+                    continue;
+                }
+                Long meterId = item.getLong("id");
+                if (meterId == null) {
+                    continue;
+                }
+                TqdianbiaoMeter entity = meterMapper.selectOne(
+                        Wrappers.<TqdianbiaoMeter>lambdaQuery()
+                                .eq(TqdianbiaoMeter::getMeterId, meterId)
+                                .last("LIMIT 1"));
+                boolean isNew = entity == null;
+                if (isNew) {
+                    entity = new TqdianbiaoMeter();
+                    entity.setMeterId(meterId);
+                    entity.setSource("sync");
+                }
+                String savedName = entity.getMeterName();
+                String savedAddress = entity.getAddress();
+                String savedDesc = entity.getDescription();
+                String savedRelay = entity.getRelayState();
+
+                entity.setCollectorId(item.getString("cid"));
+                entity.setCollectorNo(item.getString("collectorid"));
+                entity.setMeterType(item.getString("type"));
+                entity.setCsq(item.getInteger("csq"));
+                entity.setRate(item.getInteger("rate"));
+                entity.setSyncTime(now);
+
+                if (isNew) {
+                    entity.setAddress(item.getString("address"));
+                    entity.setRelayState(item.getString("relay_state"));
+                    entity.setDescription(item.getString("description"));
+                    entity.setMeterName(StringUtils.hasText(savedName) ? savedName : item.getString("address"));
+                } else {
+                    entity.setMeterName(savedName);
+                    entity.setAddress(savedAddress);
+                    entity.setDescription(savedDesc);
+                    entity.setRelayState(savedRelay);
+                }
+                if (entity.getId() == null) {
+                    meterMapper.insert(entity);
+                } else {
+                    meterMapper.updateById(entity);
+                }
+                count++;
+            }
+            syncLogService.logSuccess(syncType, null, null, count);
+            return count;
+        } catch (Exception e) {
+            log.error("鐢佃〃鍚屾澶辫触", e);
+            syncLogService.logFailure(syncType, null, null, e.getMessage());
+            throw e;
+        }
+    }
+
+    private JSONArray parseList(String raw) {
+        Object parsed = JSON.parse(raw);
+        if (parsed instanceof JSONArray) {
+            return (JSONArray) parsed;
+        }
+        JSONObject root = (JSONObject) parsed;
+        if (root.getIntValue("status") != 1) {
+            throw new IllegalStateException("鐢佃〃鎺ュ彛杩斿洖寮傚父: " + raw);
+        }
+        Object data = root.get("data");
+        if (data instanceof JSONArray) {
+            return (JSONArray) data;
+        }
+        return new JSONArray();
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoSyncLogServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoSyncLogServiceImpl.java
new file mode 100644
index 0000000..8b46def
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/service/impl/TqdianbiaoSyncLogServiceImpl.java
@@ -0,0 +1,39 @@
+package com.ruoyi.http.service.impl;
+
+import com.ruoyi.http.mapper.TqdianbiaoSyncLogMapper;
+import com.ruoyi.http.pojo.TqdianbiaoSyncLog;
+import com.ruoyi.http.service.TqdianbiaoSyncLogService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class TqdianbiaoSyncLogServiceImpl implements TqdianbiaoSyncLogService {
+
+    private final TqdianbiaoSyncLogMapper syncLogMapper;
+
+    @Override
+    public void logSuccess(String syncType, String windowStart, String windowEnd, int recordCount) {
+        TqdianbiaoSyncLog log = new TqdianbiaoSyncLog();
+        log.setSyncType(syncType);
+        log.setWindowStart(windowStart);
+        log.setWindowEnd(windowEnd);
+        log.setStatus("success");
+        log.setRecordCount(recordCount);
+        log.setApiCallCount(1);
+        syncLogMapper.insert(log);
+    }
+
+    @Override
+    public void logFailure(String syncType, String windowStart, String windowEnd, String errorMsg) {
+        TqdianbiaoSyncLog log = new TqdianbiaoSyncLog();
+        log.setSyncType(syncType);
+        log.setWindowStart(windowStart);
+        log.setWindowEnd(windowEnd);
+        log.setStatus("fail");
+        log.setRecordCount(0);
+        log.setApiCallCount(1);
+        log.setErrorMsg(errorMsg != null && errorMsg.length() > 500 ? errorMsg.substring(0, 500) : errorMsg);
+        syncLogMapper.insert(log);
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/task/TqdianbiaoSyncTask.java b/src/main/java/com/ruoyi/http/task/TqdianbiaoSyncTask.java
new file mode 100644
index 0000000..ba390a9
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/task/TqdianbiaoSyncTask.java
@@ -0,0 +1,58 @@
+package com.ruoyi.http.task;
+
+import com.ruoyi.http.config.TqdianbiaoConfig;
+import com.ruoyi.http.service.TqdianbiaoCollectorSyncService;
+import com.ruoyi.http.service.TqdianbiaoEleSyncService;
+import com.ruoyi.http.service.TqdianbiaoMeterSyncService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 澶╁惎鐢佃〃瀹氭椂鍚屾锛氫粎鍚屾閲囬泦鍣ㄣ�佺數琛ㄣ�佸皬鏃剁數閲忥紙缁熻鐢卞皬鏃剁疮绉绠楋級
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class TqdianbiaoSyncTask {
+
+    private final TqdianbiaoConfig config;
+    private final TqdianbiaoCollectorSyncService collectorSyncService;
+    private final TqdianbiaoMeterSyncService meterSyncService;
+    private final TqdianbiaoEleSyncService eleSyncService;
+
+    @Scheduled(cron = "30 5 * * * ?")
+    public void syncCollectors() {
+        if (!isEnabled()) return;
+        try {
+            collectorSyncService.syncCollectors();
+        } catch (Exception e) {
+            log.error("閲囬泦鍣ㄥ畾鏃跺悓姝ュ紓甯�", e);
+        }
+    }
+
+    @Scheduled(cron = "30 7 * * * ?")
+    public void syncMeters() {
+        if (!isEnabled()) return;
+        try {
+            meterSyncService.syncMeters();
+        } catch (Exception e) {
+            log.error("鐢佃〃瀹氭椂鍚屾寮傚父", e);
+        }
+    }
+
+    @Scheduled(cron = "30 10 * * * ?")
+    public void syncHourEle() {
+        if (!isEnabled()) return;
+        try {
+            eleSyncService.syncHourData();
+        } catch (Exception e) {
+            log.error("灏忔椂鐢甸噺瀹氭椂鍚屾寮傚父", e);
+        }
+    }
+
+    private boolean isEnabled() {
+        return config.getSync() != null && Boolean.TRUE.equals(config.getSync().getEnabled());
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java b/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java
new file mode 100644
index 0000000..c57f8bd
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java
@@ -0,0 +1,252 @@
+package com.ruoyi.http.util;
+
+import com.ruoyi.http.vo.StatisticEleRecordVo;
+
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 鐢甸噺缁熻鑱氬悎宸ュ叿锛堝熀浜庡皬鏃舵暟鎹悜涓婃眹鎬伙級
+ */
+public final class StatisticEleAggregateUtil {
+
+    private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+    private StatisticEleAggregateUtil() {
+    }
+
+    public static final Function<String, String> HOUR_TO_DAY = tk -> tk != null && tk.length() >= 8 ? tk.substring(0, 8) : null;
+    public static final Function<String, String> HOUR_TO_HOUR = tk -> normalizeHourKey(tk);
+    public static final Function<String, String> HOUR_TO_MONTH = tk -> tk != null && tk.length() >= 6 ? tk.substring(0, 6) : null;
+    public static final Function<String, String> HOUR_TO_YEAR = tk -> tk != null && tk.length() >= 4 ? tk.substring(0, 4) : null;
+    public static final Function<String, String> HOUR_TO_QUARTER = tk -> {
+        String monthKey = HOUR_TO_MONTH.apply(tk);
+        return monthKey != null ? toQuarterKey(monthKey) : null;
+    };
+
+    /**
+     * 鎸夋椂闂存《姹囨�伙紙澶氱數琛ㄥ悎骞讹紝鐢ㄤ簬鍥捐〃锛�
+     */
+    public static List<StatisticEleRecordVo> aggregateHourToBuckets(
+            List<StatisticEleRecordVo> hourRecords, Function<String, String> bucketFn) {
+        Map<String, StatisticEleRecordVo> map = new LinkedHashMap<>();
+        for (StatisticEleRecordVo record : hourRecords) {
+            String bucket = bucketFn.apply(record.getTimeKey());
+            if (bucket == null) {
+                continue;
+            }
+            mergeInto(map, bucket, null, record);
+        }
+        return sorted(map);
+    }
+
+    /**
+     * 鎸夋椂闂存《+鐢佃〃姹囨�伙紙鐢ㄤ簬鏄庣粏锛�
+     */
+    public static List<StatisticEleRecordVo> aggregateHourPerMeter(
+            List<StatisticEleRecordVo> hourRecords, Function<String, String> bucketFn) {
+        Map<String, StatisticEleRecordVo> map = new LinkedHashMap<>();
+        for (StatisticEleRecordVo record : hourRecords) {
+            String bucket = bucketFn.apply(record.getTimeKey());
+            if (bucket == null || record.getMeterId() == null) {
+                continue;
+            }
+            String key = bucket + "_" + record.getMeterId();
+            mergeInto(map, key, bucket, record);
+            StatisticEleRecordVo agg = map.get(key);
+            agg.setMeterId(record.getMeterId());
+            agg.setAddress(record.getAddress());
+            agg.setCollectorNo(record.getCollectorNo());
+        }
+        return sorted(map);
+    }
+
+    /**
+     * 鍏煎锛氭寜 timeKey 鐩存帴姹囨��
+     */
+    public static List<StatisticEleRecordVo> aggregateByTimeKey(List<StatisticEleRecordVo> records) {
+        Map<String, StatisticEleRecordVo> map = new LinkedHashMap<>();
+        for (StatisticEleRecordVo record : records) {
+            String key = record.getTimeKey();
+            if (key == null) {
+                continue;
+            }
+            mergeInto(map, key, key, record);
+        }
+        return sorted(map);
+    }
+
+    public static String toQuarterKey(String monthOrDayKey) {
+        if (monthOrDayKey == null || monthOrDayKey.length() < 6) {
+            return null;
+        }
+        int year = Integer.parseInt(monthOrDayKey.substring(0, 4));
+        int month = Integer.parseInt(monthOrDayKey.substring(4, 6));
+        int quarter = (month - 1) / 3 + 1;
+        return year + "Q" + quarter;
+    }
+
+    /**
+     * 缁熻缁村害 -> 灏忔椂 time_key 鏌ヨ鑼冨洿
+     */
+    public static HourRange toHourQueryRange(String dimension, String startTime, String endTime) {
+        return switch (dimension) {
+            case "hour" -> new HourRange(startTime, endTime);
+            case "day" -> new HourRange(startTime + "00", endTime + "23");
+            case "month" -> new HourRange(startTime + "0100", endTime + lastDayOfMonth(endTime) + "23");
+            case "year" -> new HourRange(startTime + "010100", endTime + "123123");
+            case "quarter" -> new HourRange(
+                    startTime.length() >= 8 ? startTime.substring(0, 8) + "00" : startTime + "00",
+                    endTime.length() >= 8 ? endTime.substring(0, 8) + "23" : endTime + "23");
+            default -> throw new IllegalArgumentException("涓嶆敮鎸佺殑缁村害: " + dimension);
+        };
+    }
+
+    public static HourRange yesterdayHourRange() {
+        String day = LocalDate.now().minusDays(1).format(DAY_FMT);
+        return new HourRange(normalizeQueryStartTimeKey(day + "00"), normalizeQueryEndTimeKey(day + "23"));
+    }
+
+    /** 鏌ヨ璧峰 time_key锛堢粺涓� 12 浣嶏紝鍏煎 10 浣嶅皬鏃堕敭锛� */
+    public static String normalizeQueryStartTimeKey(String timeKey) {
+        if (timeKey == null || timeKey.isBlank()) {
+            return timeKey;
+        }
+        if (timeKey.length() == 8) {
+            return timeKey + "0000";
+        }
+        if (timeKey.length() == 10) {
+            return timeKey + "00";
+        }
+        return timeKey.length() > 12 ? timeKey.substring(0, 12) : timeKey;
+    }
+
+    /** 鏌ヨ缁撴潫 time_key锛堢粺涓� 12 浣嶏紝鍏煎 10 浣嶅皬鏃堕敭锛� */
+    public static String normalizeQueryEndTimeKey(String timeKey) {
+        if (timeKey == null || timeKey.isBlank()) {
+            return timeKey;
+        }
+        if (timeKey.length() == 8) {
+            return timeKey + "2359";
+        }
+        if (timeKey.length() == 10) {
+            return timeKey + "59";
+        }
+        return timeKey.length() > 12 ? timeKey.substring(0, 12) : timeKey;
+    }
+
+    /** 鏄庣粏璁板綍鎬荤敤鐢甸噺锛堜笌鏁版嵁閲囬泦椤垫眰鍜屾柟寮忎竴鑷达級 */
+    public static double sumRecordsTotal(List<StatisticEleRecordVo> records) {
+        return records.stream()
+                .map(StatisticEleRecordVo::getTotalConsumption)
+                .filter(v -> v != null)
+                .mapToDouble(Double::doubleValue)
+                .sum();
+    }
+
+    public static Function<String, String> bucketFn(String dimension) {
+        return switch (dimension) {
+            case "hour" -> HOUR_TO_HOUR;
+            case "day" -> HOUR_TO_DAY;
+            case "month" -> HOUR_TO_MONTH;
+            case "quarter" -> HOUR_TO_QUARTER;
+            case "year" -> HOUR_TO_YEAR;
+            default -> StatisticEleAggregateUtil::normalizeHourKey;
+        };
+    }
+
+    public static String normalizeHourKey(String timeKey) {
+        if (timeKey == null) {
+            return null;
+        }
+        return timeKey.length() >= 10 ? timeKey.substring(0, 10) : timeKey;
+    }
+
+    public static StatisticEleSummaryMetrics calcMetrics(List<StatisticEleRecordVo> buckets) {
+        StatisticEleSummaryMetrics metrics = new StatisticEleSummaryMetrics();
+        metrics.setRecordCount(buckets.size());
+        if (buckets.isEmpty()) {
+            metrics.setTotalConsumption(0.0);
+            metrics.setAvgConsumption(0.0);
+            metrics.setMaxConsumption(0.0);
+            metrics.setMinConsumption(0.0);
+            return metrics;
+        }
+        List<Double> values = buckets.stream()
+                .map(StatisticEleRecordVo::getTotalConsumption)
+                .filter(v -> v != null)
+                .collect(Collectors.toList());
+        double total = values.stream().mapToDouble(Double::doubleValue).sum();
+        metrics.setTotalConsumption(round(total));
+        metrics.setAvgConsumption(round(total / values.size()));
+        metrics.setMaxConsumption(round(values.stream().mapToDouble(Double::doubleValue).max().orElse(0)));
+        metrics.setMinConsumption(round(values.stream().mapToDouble(Double::doubleValue).min().orElse(0)));
+        return metrics;
+    }
+
+    private static void mergeInto(Map<String, StatisticEleRecordVo> map, String mapKey,
+                                  String timeKey, StatisticEleRecordVo record) {
+        StatisticEleRecordVo agg = map.computeIfAbsent(mapKey, k -> {
+            StatisticEleRecordVo vo = new StatisticEleRecordVo();
+            vo.setTimeKey(timeKey != null ? timeKey : k);
+            vo.setTotalConsumption(0.0);
+            vo.setSharpConsumption(0.0);
+            vo.setPeakConsumption(0.0);
+            vo.setFlatConsumption(0.0);
+            vo.setValleyConsumption(0.0);
+            return vo;
+        });
+        agg.setTotalConsumption(add(agg.getTotalConsumption(), record.getTotalConsumption()));
+        agg.setSharpConsumption(add(agg.getSharpConsumption(), record.getSharpConsumption()));
+        agg.setPeakConsumption(add(agg.getPeakConsumption(), record.getPeakConsumption()));
+        agg.setFlatConsumption(add(agg.getFlatConsumption(), record.getFlatConsumption()));
+        agg.setValleyConsumption(add(agg.getValleyConsumption(), record.getValleyConsumption()));
+    }
+
+    private static List<StatisticEleRecordVo> sorted(Map<String, StatisticEleRecordVo> map) {
+        return map.values().stream()
+                .sorted(Comparator.comparing(StatisticEleRecordVo::getTimeKey))
+                .collect(Collectors.toList());
+    }
+
+    private static String lastDayOfMonth(String yyyyMM) {
+        YearMonth ym = YearMonth.parse(yyyyMM, DateTimeFormatter.ofPattern("yyyyMM"));
+        return yyyyMM + String.format("%02d", ym.lengthOfMonth());
+    }
+
+    private static Double add(Double a, Double b) {
+        return (a == null ? 0.0 : a) + (b == null ? 0.0 : b);
+    }
+
+    private static double round(double value) {
+        return Math.round(value * 100.0) / 100.0;
+    }
+
+    public record HourRange(String startTime, String endTime) {}
+
+    public static class StatisticEleSummaryMetrics {
+        private Double totalConsumption;
+        private Double avgConsumption;
+        private Double maxConsumption;
+        private Double minConsumption;
+        private Integer recordCount;
+
+        public Double getTotalConsumption() { return totalConsumption; }
+        public void setTotalConsumption(Double v) { this.totalConsumption = v; }
+        public Double getAvgConsumption() { return avgConsumption; }
+        public void setAvgConsumption(Double v) { this.avgConsumption = v; }
+        public Double getMaxConsumption() { return maxConsumption; }
+        public void setMaxConsumption(Double v) { this.maxConsumption = v; }
+        public Double getMinConsumption() { return minConsumption; }
+        public void setMinConsumption(Double v) { this.minConsumption = v; }
+        public Integer getRecordCount() { return recordCount; }
+        public void setRecordCount(Integer v) { this.recordCount = v; }
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/util/StatisticEleParseUtil.java b/src/main/java/com/ruoyi/http/util/StatisticEleParseUtil.java
new file mode 100644
index 0000000..5177dd7
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/util/StatisticEleParseUtil.java
@@ -0,0 +1,122 @@
+package com.ruoyi.http.util;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.http.pojo.TqdianbiaoEleRecord;
+import com.ruoyi.http.vo.StatisticEleRecordVo;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+public final class StatisticEleParseUtil {
+
+    private StatisticEleParseUtil() {
+    }
+
+    public static List<TqdianbiaoEleRecord> parseToEntities(String raw, String dimension) {
+        JSONObject root = JSON.parseObject(raw);
+        if (root == null) {
+            throw new ServiceException("鐢佃〃鎺ュ彛杩斿洖涓虹┖");
+        }
+        if (root.getIntValue("status") != 1) {
+            String msg = root.getString("msg");
+            throw new ServiceException("鐢佃〃鎺ュ彛杩斿洖寮傚父: " + (msg != null ? msg : raw));
+        }
+        JSONObject data = root.getJSONObject("data");
+        if (data == null || data.isEmpty()) {
+            return new ArrayList<>();
+        }
+        LocalDateTime now = LocalDateTime.now();
+        List<TqdianbiaoEleRecord> list = new ArrayList<>();
+        for (String timeKey : data.keySet()) {
+            JSONArray records = data.getJSONArray(timeKey);
+            if (records == null) {
+                continue;
+            }
+            for (int i = 0; i < records.size(); i++) {
+                JSONObject rec = records.getJSONObject(i);
+                TqdianbiaoEleRecord entity = new TqdianbiaoEleRecord();
+                entity.setMeterId(rec.getLong("mid"));
+                entity.setDimension(dimension);
+                entity.setTimeKey(timeKey);
+                entity.setReadingMethod("sync");
+                Integer ratio = rec.getInteger("r");
+                entity.setRatio(ratio);
+                entity.setStartTime(rec.getString("st"));
+                entity.setEndTime(rec.getString("et"));
+
+                JSONArray sArr = rec.getJSONArray("s");
+                JSONArray eArr = rec.getJSONArray("e");
+                JSONArray dArr = rec.getJSONArray("d");
+                entity.setStartReading(StatisticEleReadingUtil.formatReadingArray(sArr));
+                entity.setEndReading(StatisticEleReadingUtil.formatReadingArray(eArr));
+                entity.setPrevReading(StatisticEleReadingUtil.firstReading(sArr));
+                entity.setCurrReading(StatisticEleReadingUtil.firstReading(eArr));
+
+                fillConsumption(entity, dArr, ratio, sArr, eArr);
+                entity.setSyncTime(now);
+                list.add(entity);
+            }
+        }
+        list.sort(Comparator.comparing(TqdianbiaoEleRecord::getTimeKey));
+        return list;
+    }
+
+    public static List<StatisticEleRecordVo> toVoList(List<TqdianbiaoEleRecord> entities) {
+        List<StatisticEleRecordVo> list = new ArrayList<>();
+        for (TqdianbiaoEleRecord entity : entities) {
+            list.add(toVo(entity));
+        }
+        return list;
+    }
+
+    public static StatisticEleRecordVo toVo(TqdianbiaoEleRecord entity) {
+        StatisticEleRecordVo vo = new StatisticEleRecordVo();
+        vo.setId(entity.getId());
+        vo.setTimeKey(entity.getTimeKey());
+        vo.setMeterId(entity.getMeterId());
+        vo.setMeterName(entity.getMeterName());
+        vo.setRatio(entity.getRatio());
+        vo.setReadingMethod(entity.getReadingMethod());
+        vo.setPrevReading(toDouble(entity.getPrevReading()));
+        vo.setCurrReading(toDouble(entity.getCurrReading()));
+        vo.setStartTime(entity.getStartTime());
+        vo.setEndTime(entity.getEndTime());
+        vo.setTotalConsumption(toDouble(entity.getTotalConsumption()));
+        vo.setSharpConsumption(toDouble(entity.getSharpConsumption()));
+        vo.setPeakConsumption(toDouble(entity.getPeakConsumption()));
+        vo.setFlatConsumption(toDouble(entity.getFlatConsumption()));
+        vo.setValleyConsumption(toDouble(entity.getValleyConsumption()));
+        vo.setStartReading(entity.getStartReading());
+        vo.setEndReading(entity.getEndReading());
+        return vo;
+    }
+
+    private static void fillConsumption(TqdianbiaoEleRecord entity, JSONArray d, Integer ratio,
+                                        JSONArray sArr, JSONArray eArr) {
+        BigDecimal rawTotal = null;
+        if (d != null && !d.isEmpty()) {
+            rawTotal = d.getBigDecimal(0);
+            entity.setSharpConsumption(d.size() >= 2 ? d.getBigDecimal(1) : null);
+            entity.setPeakConsumption(d.size() >= 3 ? d.getBigDecimal(2) : null);
+            entity.setFlatConsumption(d.size() >= 4 ? d.getBigDecimal(3) : null);
+            entity.setValleyConsumption(d.size() >= 5 ? d.getBigDecimal(4) : null);
+            entity.setDeepValleyConsumption(d.size() >= 6 ? d.getBigDecimal(5) : null);
+        } else {
+            rawTotal = StatisticEleReadingUtil.calcConsumption(
+                    StatisticEleReadingUtil.firstReading(sArr),
+                    StatisticEleReadingUtil.firstReading(eArr),
+                    1);
+        }
+        entity.setTotalConsumption(StatisticEleReadingUtil.calcConsumptionFromRaw(rawTotal, ratio));
+    }
+
+    private static Double toDouble(BigDecimal value) {
+        return value == null ? null : value.doubleValue();
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/util/StatisticEleReadingUtil.java b/src/main/java/com/ruoyi/http/util/StatisticEleReadingUtil.java
new file mode 100644
index 0000000..aacf4c4
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/util/StatisticEleReadingUtil.java
@@ -0,0 +1,72 @@
+package com.ruoyi.http.util;
+
+import com.alibaba.fastjson2.JSONArray;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 鐢甸噺璇绘暟涓庡�嶇巼璁$畻
+ */
+public final class StatisticEleReadingUtil {
+
+    private StatisticEleReadingUtil() {
+    }
+
+    public static BigDecimal firstReading(JSONArray arr) {
+        if (arr == null || arr.isEmpty()) {
+            return null;
+        }
+        return arr.getBigDecimal(0);
+    }
+
+    public static BigDecimal calcConsumption(BigDecimal prev, BigDecimal curr, Integer ratio) {
+        if (prev == null || curr == null) {
+            return null;
+        }
+        BigDecimal diff = curr.subtract(prev);
+        return applyRatio(diff, ratio);
+    }
+
+    public static BigDecimal calcConsumptionFromRaw(BigDecimal raw, Integer ratio) {
+        if (raw == null) {
+            return null;
+        }
+        return applyRatio(raw, ratio);
+    }
+
+    public static BigDecimal applyRatio(BigDecimal value, Integer ratio) {
+        if (value == null) {
+            return null;
+        }
+        int r = ratio == null || ratio <= 0 ? 1 : ratio;
+        return value.multiply(BigDecimal.valueOf(r)).setScale(4, RoundingMode.HALF_UP);
+    }
+
+    public static String formatReadingArray(JSONArray arr) {
+        if (arr == null || arr.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < arr.size(); i++) {
+            if (i > 0) {
+                sb.append("/");
+            }
+            sb.append(arr.getDouble(i));
+        }
+        return sb.toString();
+    }
+
+    public static BigDecimal parseFirstReading(String reading) {
+        if (reading == null || reading.isBlank()) {
+            return null;
+        }
+        int idx = reading.indexOf('/');
+        String first = idx > 0 ? reading.substring(0, idx) : reading;
+        try {
+            return new BigDecimal(first.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/vo/StatisticEleRecordVo.java b/src/main/java/com/ruoyi/http/vo/StatisticEleRecordVo.java
new file mode 100644
index 0000000..933529d
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/vo/StatisticEleRecordVo.java
@@ -0,0 +1,67 @@
+package com.ruoyi.http.vo;
+
+import com.ruoyi.framework.aspectj.lang.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 鐢佃〃缁熻鏁版嵁璁板綍
+ */
+@Data
+public class StatisticEleRecordVo {
+
+    private Long id;
+
+    @Excel(name = "鏃堕棿鏍囪瘑")
+    private String timeKey;
+
+    @Excel(name = "鐢佃〃ID")
+    private Long meterId;
+
+    @Excel(name = "鐢佃〃鍚嶇О")
+    private String meterName;
+
+    @Excel(name = "琛ㄥ湴鍧�")
+    private String address;
+
+    @Excel(name = "鍊嶇巼")
+    private Integer ratio;
+
+    @Excel(name = "涓婃鐢甸噺")
+    private Double prevReading;
+
+    @Excel(name = "鏈鐢甸噺")
+    private Double currReading;
+
+    @Excel(name = "鏈鐢ㄧ數閲�(kWh)")
+    private Double totalConsumption;
+
+    @Excel(name = "鎶勮〃鏂瑰紡")
+    private String readingMethod;
+
+    @Excel(name = "寮�濮嬫椂闂�")
+    private String startTime;
+
+    @Excel(name = "缁撴潫鏃堕棿")
+    private String endTime;
+
+    @Excel(name = "灏栧嘲(kWh)")
+    private Double sharpConsumption;
+
+    @Excel(name = "宄�(kWh)")
+    private Double peakConsumption;
+
+    @Excel(name = "骞�(kWh)")
+    private Double flatConsumption;
+
+    @Excel(name = "璋�(kWh)")
+    private Double valleyConsumption;
+
+    @Excel(name = "璧峰璇绘暟")
+    private String startReading;
+
+    @Excel(name = "缁撴潫璇绘暟")
+    private String endReading;
+
+    /** 鍏煎瀛楁锛岄〉闈笉灞曠ず */
+    private String collectorNo;
+}
diff --git a/src/main/java/com/ruoyi/http/vo/StatisticEleSummaryVo.java b/src/main/java/com/ruoyi/http/vo/StatisticEleSummaryVo.java
new file mode 100644
index 0000000..bb5ff88
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/vo/StatisticEleSummaryVo.java
@@ -0,0 +1,33 @@
+package com.ruoyi.http.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 鐢佃〃缁熻姹囨��
+ */
+@Data
+public class StatisticEleSummaryVo {
+
+    /** 鎬荤敤鐢甸噺(kWh) */
+    private Double totalConsumption;
+
+    /** 骞冲潎鐢ㄧ數閲�(kWh) */
+    private Double avgConsumption;
+
+    /** 鏈�澶х敤鐢甸噺(kWh) */
+    private Double maxConsumption;
+
+    /** 鏈�灏忕敤鐢甸噺(kWh) */
+    private Double minConsumption;
+
+    /** 鏁版嵁鏉℃暟 */
+    private Integer recordCount;
+
+    /** 鍥捐〃鏁版嵁锛堟寜鏃堕棿姹囨�伙級 */
+    private List<StatisticEleRecordVo> chartRecords;
+
+    /** 鏄庣粏璁板綍锛堟寜鐢佃〃锛� */
+    private List<StatisticEleRecordVo> records;
+}
diff --git a/src/main/java/com/ruoyi/http/vo/StatisticEleSyncStatusVo.java b/src/main/java/com/ruoyi/http/vo/StatisticEleSyncStatusVo.java
new file mode 100644
index 0000000..0a3f53a
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/vo/StatisticEleSyncStatusVo.java
@@ -0,0 +1,19 @@
+package com.ruoyi.http.vo;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class StatisticEleSyncStatusVo {
+
+    private Integer meterCount;
+
+    private Integer collectorCount;
+
+    private Integer onlineCollectorCount;
+
+    private Map<String, Long> recordCountByDimension;
+
+    private Map<String, String> lastSyncTimeByType;
+}
diff --git a/src/main/resources/mapper/http/TqdianbiaoEleRecordMapper.xml b/src/main/resources/mapper/http/TqdianbiaoEleRecordMapper.xml
new file mode 100644
index 0000000..04ae911
--- /dev/null
+++ b/src/main/resources/mapper/http/TqdianbiaoEleRecordMapper.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.http.mapper.TqdianbiaoEleRecordMapper">
+
+    <select id="selectRecordList" resultType="com.ruoyi.http.vo.StatisticEleRecordVo">
+        SELECT
+            r.id AS id,
+            r.time_key AS timeKey,
+            r.meter_id AS meterId,
+            COALESCE(r.meter_name, m.meter_name) AS meterName,
+            m.address AS address,
+            r.ratio AS ratio,
+            r.reading_method AS readingMethod,
+            r.prev_reading AS prevReading,
+            r.curr_reading AS currReading,
+            r.start_time AS startTime,
+            r.end_time AS endTime,
+            r.total_consumption AS totalConsumption,
+            r.sharp_consumption AS sharpConsumption,
+            r.peak_consumption AS peakConsumption,
+            r.flat_consumption AS flatConsumption,
+            r.valley_consumption AS valleyConsumption,
+            r.start_reading AS startReading,
+            r.end_reading AS endReading
+        FROM tqdianbiao_ele_record r
+        LEFT JOIN tqdianbiao_meter m ON r.meter_id = m.meter_id
+        WHERE r.dimension IN
+        <foreach collection="dimensions" item="dim" open="(" separator="," close=")">
+            #{dim}
+        </foreach>
+          AND RPAD(r.time_key, 12, '0') &gt;= #{startTime}
+          AND RPAD(r.time_key, 12, '0') &lt;= #{endTime}
+        ORDER BY r.time_key DESC, r.meter_id ASC
+    </select>
+
+    <select id="selectPrevReading" resultType="com.ruoyi.http.pojo.TqdianbiaoEleRecord">
+        SELECT id, meter_id, time_key, curr_reading, end_reading, ratio
+        FROM tqdianbiao_ele_record
+        WHERE meter_id = #{meterId}
+          AND time_key &lt; #{timeKey}
+        ORDER BY time_key DESC
+        LIMIT 1
+    </select>
+
+</mapper>

--
Gitblit v1.9.3