Преглед на файлове

fix:优化批量下载流程

Jerry Liang преди 11 месеца
родител
ревизия
53c1fcee9c
променени са 56 файла, в които са добавени 5035 реда и са изтрити 149 реда
  1. 10 0
      service/.env
  2. 8 0
      service/.vscode/settings.json
  3. 10 2
      service/cache/cache.go
  4. 2 2
      service/cache/redisx/hashx.go
  5. 8 0
      service/cache/redisx/setx.go
  6. 46 14
      service/config/config.go
  7. 131 0
      service/controllers/batchDownloadControllers.go
  8. 120 0
      service/domain/batchDownloadDomain.go
  9. 145 1
      service/domain/fwhProDomain.go
  10. 1 0
      service/domain/taskQueueDomain.go
  11. BIN
      service/fwh-toolbox-server
  12. 20 8
      service/go.mod
  13. 34 0
      service/go.sum
  14. 629 0
      service/jobs/callFwhFile.go
  15. 15 2
      service/jobs/getAlarmSnapshot.go
  16. 632 0
      service/jobs/saveBatchDownloadData.go
  17. 15 0
      service/line.html
  18. 12 0
      service/main.go
  19. 4 4
      service/repositories/api/fwh_pro/fwhHttpClient.go
  20. 120 0
      service/repositories/api/fwh_pro/fwh_emsLog.go
  21. 19 5
      service/repositories/api/fwh_pro/fwh_eventAlarmLog.go
  22. 245 0
      service/repositories/api/fwh_pro/fwh_faultRecord.go
  23. 6 4
      service/repositories/api/fwh_pro/fwh_login.go
  24. 7 1
      service/repositories/api/fwh_pro/fwh_mqttLog.go
  25. 8 1
      service/repositories/api/fwh_pro/fwh_powerFlow.go
  26. 8 1
      service/repositories/api/fwh_pro/fwh_realtimeAalrm.go
  27. 8 1
      service/repositories/api/fwh_pro/fwh_shutdownList.go
  28. 68 3
      service/repositories/api/fwh_pro/fwh_siteInfo.go
  29. 3 0
      service/repositories/api/fwh_pro/fwh_sttings.go
  30. 124 0
      service/repositories/api/fwh_pro/fwh_update.go
  31. 430 0
      service/repositories/mongo/batchDownloadRepo.go
  32. 22 0
      service/repositories/pgsql/alarmSnapshot/siteAlarmSnapshotData.go
  33. 11 9
      service/repositories/redis/authRepo.go
  34. 182 0
      service/repositories/redis/batchDownloadRepo.go
  35. 48 2
      service/repositories/repositories.go
  36. 32 0
      service/routes/batchDownloadRoutes.go
  37. 1 0
      service/routes/routes.go
  38. 1 0
      service/routes/sitesAnalysisRoutes.go
  39. 1 0
      service/services/authServices.go
  40. 159 0
      service/services/batchDownloadServices.go
  41. 554 0
      service/taskQueue/taskBatchDownload.go
  42. 21 0
      service/taskQueue/taskQueue.go
  43. 62 0
      service/test/echart/echartTest.go
  44. 84 0
      service/test/echart/powerFlow/powerFlowEchartTest.go
  45. 17 0
      service/test/getApowerSn/getApowerSn.go
  46. 6 0
      service/utils/echart/echart.go
  47. 65 0
      service/utils/echart/line.go
  48. 119 0
      service/utils/echart/powerFlow.go
  49. 62 16
      service/utils/httpClient/httpClient.go
  50. 114 0
      service/utils/minio/minio.go
  51. 188 0
      src/assets/data/mqttLogNameList.json
  52. 38 5
      src/service/batchDownload/index.ts
  53. 72 43
      src/service/batchDownload/interface.ts
  54. 1 1
      src/service/sitesAnalysis/index.ts
  55. 202 0
      src/views/batch-download/ModalData.vue
  56. 85 24
      src/views/batch-download/index.vue

+ 10 - 0
service/.env

@@ -1,6 +1,16 @@
 APP_ENV="dev"
 APP_PORT="8301"
+FWH_USER="FMU006"
+FWH_PASSWORD="78728578"
+# FWH_ENERGY="energy.franklinwh.com"
+FWH_ENERGY="energy.fwh-toolbox.top"
+# FWH_OSS="oss.franklinwh.com"
+FWH_OSS="energy-oss.fwh-toolbox.top"
 MYSQL_DSN="admin:V1hdkLzRoleI0YG6If24@tcp(34.211.183.235:3308)/home_energy_storage_server?charset=utf8mb4&parseTime=True&loc=Local"
 POSTGRES_DSN="host=localhost user=fwh_toolbox password=fwh_toolbox dbname=fwh_toolbox_db port=5432 sslmode=disable"
 MONGO_DSN="mongodb://127.0.0.1:27017/FwhToolboxDbDev"
 REDIS_ADDR="localhost:6379"
+MINIO_ENDPOINT="localhost:9198"
+MINIO_ACCESS_KEY="fwh-toolbox"
+MINIO_SECRET_KEY="fwh-toolbox"
+MINIO_BUCKET_NAME="fwh-toolbox"

+ 8 - 0
service/.vscode/settings.json

@@ -1,5 +1,6 @@
 {
     "cSpell.words": [
+        "bson",
         "infof",
         "infoln",
         "warnf",
@@ -10,5 +11,12 @@
         "redisx",
         "robfig",
         "mqtt",
+        "echart",
+        "echarts",
+        "excelize",
+        "apower",
+        "powerlink",
+        "mppt",
+        "fwhtoolbox"
     ]
 }

+ 10 - 2
service/cache/cache.go

@@ -84,8 +84,8 @@ func Incr(ctx context.Context, key string) (int64, error) {
 
 /********* 哈希操作 *********/
 
-func HSet(ctx context.Context, key string, values map[string]interface{}) error {
-	return redisx.HSet(ctx, key, values)
+func HSet(ctx context.Context, key string, values ...interface{}) error {
+	return redisx.HSet(ctx, key, values...)
 }
 
 func HGet(ctx context.Context, key, field string) (string, error) {
@@ -116,10 +116,18 @@ func SAdd(ctx context.Context, key string, members ...interface{}) error {
 	return redisx.SAdd(ctx, key, members...)
 }
 
+func SRem(ctx context.Context, key string, members ...interface{}) error {
+	return redisx.SRem(ctx, key, members...)
+}
+
 func SMembers(ctx context.Context, key string) ([]string, error) {
 	return redisx.SMembers(ctx, key)
 }
 
+func SIsMember(ctx context.Context, key string, member interface{}) (bool, error) {
+	return redisx.SIsMember(ctx, key, member)
+}
+
 /********* 有序集合操作 *********/
 
 func ZAdd(ctx context.Context, key string, members ...redis.Z) error {

+ 2 - 2
service/cache/redisx/hashx.go

@@ -4,8 +4,8 @@ import "context"
 
 // 哈希封装
 
-func HSet(ctx context.Context, key string, values map[string]interface{}) error {
-	return getClient().HSet(ctx, key, values).Err()
+func HSet(ctx context.Context, key string, values ...interface{}) error {
+	return getClient().HSet(ctx, key, values...).Err()
 }
 
 func HGet(ctx context.Context, key, field string) (string, error) {

+ 8 - 0
service/cache/redisx/setx.go

@@ -8,6 +8,14 @@ func SAdd(ctx context.Context, key string, members ...interface{}) error {
 	return getClient().SAdd(ctx, key, members...).Err()
 }
 
+func SRem(ctx context.Context, key string, members ...interface{}) error {
+	return getClient().SRem(ctx, key, members...).Err()
+}
+
 func SMembers(ctx context.Context, key string) ([]string, error) {
 	return getClient().SMembers(ctx, key).Result()
 }
+
+func SIsMember(ctx context.Context, key string, member interface{}) (bool, error) {
+	return getClient().SIsMember(ctx, key, member).Result()
+}

+ 46 - 14
service/config/config.go

@@ -1,22 +1,34 @@
 package config
 
 import (
+	"crypto/md5"
+	"encoding/hex"
 	"fmt"
 
 	"github.com/spf13/viper"
 )
 
 type Config struct {
-	AppEnv      string
-	AppPort     string
-	RedisAddr   string
-	MySQLDSN    string
-	PostgresDSN string
-	MongoDSN    string
+	AppEnv          string
+	AppPort         string
+	FwhEnergy       string
+	FwhOss          string
+	RedisAddr       string
+	MySQLDSN        string
+	PostgresDSN     string
+	MongoDSN        string
+	MinIoEndpoint   string
+	MinioAccessKey  string
+	MinioSecretKey  string
+	MinioBucketName string
 }
 
-// 需先执行LoadConfig后才能读取APP_ENV
-var APP_ENV string
+// 需先执行LoadConfig后才能读取ENV
+var APP_ENV string = "dev"
+var FWH_ENERGY string = "energy.fwh-toolbox.top"
+var FWH_OSS string = "energy-oss.fwh-toolbox.top"
+var FWH_USER string = ""
+var FWH_PASSWORD string = ""
 
 // LoadConfig 读取配置文件和环境变量
 func LoadConfig() (*Config, error) {
@@ -25,10 +37,16 @@ func LoadConfig() (*Config, error) {
 	// 默认值(避免缺省时崩溃)
 	v.SetDefault("APP_ENV", "dev")
 	v.SetDefault("APP_PORT", "8080")
+	v.SetDefault("FWH_ENERGY", "energy.fwh-toolbox.top")
+	v.SetDefault("FWH_OSS", "energy-oss.fwh-toolbox.top")
 	v.SetDefault("REDIS_ADDR", "localhost:6379")
 	v.SetDefault("MYSQL_DSN", "")
 	v.SetDefault("POSTGRES_DSN", "host=localhost user=fwh_toolbox password=fwh_toolbox dbname=fwh_toolbox_db port=5432 sslmode=disable")
 	v.SetDefault("MONGO_DSN", "mongodb://localhost:27017/FwhToolboxDbDev")
+	v.SetDefault("MINIO_ENDPOINT", "localhost:9198")
+	v.SetDefault("MINIO_ACCESS_KEY", "fwh-toolbox")
+	v.SetDefault("MINIO_SECRET_KEY", "fwh-toolbox")
+	v.SetDefault("MINIO_BUCKET_NAME", "fwh-toolbox")
 
 	// 多文件路径支持
 	paths := []string{"./.env", "./config.env"}
@@ -53,13 +71,27 @@ func LoadConfig() (*Config, error) {
 	}
 
 	APP_ENV = v.GetString("APP_ENV")
+	FWH_ENERGY = v.GetString("FWH_ENERGY")
+	FWH_OSS = v.GetString("FWH_OSS")
+	FWH_USER = v.GetString("FWH_USER")
+	FWH_PASSWORD = v.GetString("FWH_PASSWORD")
+
+	h := md5.New()
+	h.Write([]byte(FWH_PASSWORD))
+	FWH_PASSWORD = hex.EncodeToString(h.Sum(nil))
 
 	return &Config{
-		AppEnv:      v.GetString("APP_ENV"),
-		AppPort:     v.GetString("APP_PORT"),
-		RedisAddr:   v.GetString("REDIS_ADDR"),
-		MySQLDSN:    v.GetString("MYSQL_DSN"),
-		PostgresDSN: v.GetString("POSTGRES_DSN"),
-		MongoDSN:    v.GetString("MONGO_DSN"),
+		AppEnv:          v.GetString("APP_ENV"),
+		AppPort:         v.GetString("APP_PORT"),
+		FwhEnergy:       v.GetString("FWH_ENERGY"),
+		FwhOss:          v.GetString("FWH_OSS"),
+		RedisAddr:       v.GetString("REDIS_ADDR"),
+		MySQLDSN:        v.GetString("MYSQL_DSN"),
+		PostgresDSN:     v.GetString("POSTGRES_DSN"),
+		MongoDSN:        v.GetString("MONGO_DSN"),
+		MinIoEndpoint:   v.GetString("MINIO_ENDPOINT"),
+		MinioAccessKey:  v.GetString("MINIO_ACCESS_KEY"),
+		MinioSecretKey:  v.GetString("MINIO_SECRET_KEY"),
+		MinioBucketName: v.GetString("MINIO_BUCKET_NAME"),
 	}, nil
 }

+ 131 - 0
service/controllers/batchDownloadControllers.go

@@ -0,0 +1,131 @@
+/*
+ * Batch Download API List
+ * /batchDownload/manage/list - GET
+ * /batchDownload/manage/config - POST 创建新配置
+ * /batchDownload/manage/description/{id} - POST 更新任务说明
+ * /batchDownload/manage/config/{id} - DELETE 删除配置
+ * /batchDownload/data/{id} - GET 获取指定ID的数据包下载地址
+ */
+package controllers
+
+import (
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/services"
+	"strconv"
+
+	"github.com/gofiber/fiber/v2"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+// 获取批量数据下载管理表
+// /batchDownload/manage/list
+func GetBatchDownloadList(c *fiber.Ctx) error {
+	pageInt, _ := strconv.Atoi(c.Query("pageNum", "1"))
+	sizeInt, _ := strconv.Atoi(c.Query("pageSize", "20"))
+
+	if pageInt <= 0 || sizeInt <= 0 {
+		return Fail(c, "Invalid request para")
+	}
+
+	sn := c.Query("sn", "")
+	progress := c.Query("progress", "")
+
+	var p_sn *string = nil
+	if sn != "" {
+		p_sn = &sn
+	}
+
+	var p_progress *string = nil
+	if progress != "" {
+		p_progress = &progress
+	}
+
+	listPage, err := services.GetBatchDownloadManageList(uint(pageInt), uint(sizeInt), p_sn, p_progress)
+	if err != nil {
+		zap.L().Warn("Database read failed. (MongoDb)", zap.String("err", err.Error()))
+		return Error(c, "Database read failed. (MongoDb)")
+	}
+	return Success(c, listPage)
+}
+
+// 创建一个批量数据下载管任务
+// /batchDownload/manage/config
+func CreateBatchDownload(c *fiber.Ctx) error {
+	var body domain.BatchDownloadConfigWrite
+	if err := c.BodyParser(&body); err != nil {
+		zap.L().Warn("Invalid request body", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request body")
+	}
+
+	if err := services.InsertBatchDownloadConfig(body); err != nil {
+		zap.L().Warn("Database write failed. (MongoDb)", zap.String("err", err.Error()))
+		return Error(c, "Database write failed. (MongoDb)")
+	}
+	return Success(c, body)
+}
+
+// 更新一个批量数据下载说明
+// /batchDownload/manage/description/:id
+func UpdateBatchDownloadDescription(c *fiber.Ctx) error {
+	id := c.Params("id")
+	var body string
+	if err := c.BodyParser(&body); err != nil {
+		zap.L().Warn("Invalid request body", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request body")
+	}
+
+	objID, err := primitive.ObjectIDFromHex(id)
+	if err != nil {
+		zap.L().Warn("Invalid request para :id", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request para :id")
+	}
+
+	if err := services.UpdateBatchDownloadDescription(objID, body); err != nil {
+		zap.L().Warn("Database write failed. (MongoDb)", zap.String("err", err.Error()))
+		return Error(c, "Database write failed. (MongoDb)")
+	}
+	return Success(c, body)
+}
+
+// 删除一个批量数据下载数据
+// /batchDownload/manage/config/:id
+func DeleteBatchDownload(c *fiber.Ctx) error {
+	id := c.Params("id")
+
+	objID, err := primitive.ObjectIDFromHex(id)
+	if err != nil {
+		zap.L().Warn("Invalid request para :id", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request para :id")
+	}
+
+	err = services.DeleteBatchDownloadConfig(objID)
+	if err != nil {
+		zap.L().Warn("Database delete failed. (MongoDb)", zap.String("err", err.Error()))
+		return Error(c, "Database delete failed. (MongoDb)")
+	}
+	return NoContent(c)
+}
+
+// 获取指定ID的批量数据下载数据包地址
+// /batchDownload/data/:id
+func GetBatchDownloadData(c *fiber.Ctx) error {
+	id := c.Params("id")
+
+	objID, err := primitive.ObjectIDFromHex(id)
+	if err != nil {
+		zap.L().Warn("Invalid request para :id", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request para :id")
+	}
+
+	c.Set("Content-Type", "application/zip")
+	c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"BatchDownload_%s.zip\"", id))
+
+	data, err := services.GetBatchDownloadData(objID)
+	if err != nil {
+		zap.L().Warn("Database read failed. (MongoDb)", zap.String("err", err.Error()))
+		return Error(c, "Database read failed. (MongoDb)")
+	}
+	return Success(c, data)
+}

+ 120 - 0
service/domain/batchDownloadDomain.go

@@ -0,0 +1,120 @@
+package domain
+
+import (
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+type BatchDownloadInfo struct {
+	Id          primitive.ObjectID   `bson:"_id" json:"id"`
+	Sn          []string             `bson:"sn" json:"sn"`
+	Description string               `bson:"description" json:"description"`
+	TimePeriod  [2]string            `bson:"timePeriod" json:"timePeriod"`
+	Options     BatchDownloadOptions `bson:"options"`
+	Progress    Progress             `bson:"progress" json:"progress"`
+	FailReasons []string             `bson:"failReasons" json:"failReasons"`
+}
+
+type BatchDownloadOptions struct {
+	PowerFlow     bool     `bson:"powerFlow" json:"powerFlow"`         // 功率流图
+	MqttLogs      []string `bson:"mqttLogs" json:"mqttLogs"`           // MQTT日志
+	EventAlarm    bool     `bson:"eventAlarm" json:"eventAlarm"`       // 事件和告警日志
+	AlarmSnapshot bool     `bson:"alarmSnapshot" json:"alarmSnapshot"` // 告警快照
+	EmsLogs       []string `bson:"emsLogs" json:"emsLogs"`             // EMS日志
+	FaultRecord   bool     `bson:"faultRecord" json:"faultRecord"`     // 故障录波
+	UpdateLog     bool     `bson:"updateLog" json:"updateLog"`         // 升级记录
+	Settings      bool     `bson:"settings" json:"settings"`           // 站点设置
+}
+
+type BatchDownloadConfigWrite struct {
+	Sn          []string             `bson:"sn" json:"sn"`
+	Description string               `bson:"description" json:"description"`
+	TimePeriod  [2]string            `bson:"timePeriod" json:"timePeriod"`
+	Options     BatchDownloadOptions `bson:"options"`
+}
+
+type BatchDownloadConfig struct {
+	Id          primitive.ObjectID           `bson:"_id,omitempty" json:"id,omitempty"`
+	Sn          []string                     `bson:"sn" json:"sn"`
+	Description string                       `bson:"description" json:"description"`
+	TimePeriod  [2]string                    `bson:"timePeriod" json:"timePeriod"`
+	Options     BatchDownloadOptions         `bson:"options"`
+	Progress    Progress                     `bson:"progress" json:"progress"`
+	Data        map[string]BatchDownloadData `bson:"data" json:"data"` // map-key为SN
+	FailReasons []string                     `bson:"failReasons" json:"failReasons"`
+}
+
+type TaskBatchDownload struct {
+	Id         primitive.ObjectID   `bson:"_id" json:"id"`
+	Sn         []string             `bson:"sn" json:"sn"`
+	TimePeriod [2]string            `bson:"timePeriod" json:"timePeriod"`
+	Options    BatchDownloadOptions `bson:"options"`
+}
+
+// 数据说明
+// map的key为日期,格式为 YYYY-MM-DD
+// MqttLogs和EmsLogs的二级map为日志类型名称
+// 存储的最终数据是文件服务器(minio)的存储路径
+type BatchDownloadData struct {
+	PowerFlow     map[string]string            `bson:"powerFlow" json:"powerFlow"`         // 功率流图
+	MqttLogs      map[string]map[string]string `bson:"mqttLogs" json:"mqttLogs"`           // MQTT日志
+	EventAlarm    map[string]string            `bson:"eventAlarm" json:"eventAlarm"`       // 事件和告警日志
+	AlarmSnapshot map[string]string            `bson:"alarmSnapshot" json:"alarmSnapshot"` // 告警快照
+	EmsLogs       map[string]map[string]string `bson:"emsLogs" json:"emsLogs"`             // EMS日志
+	FaultRecord   map[string]string            `bson:"faultRecord" json:"faultRecord"`     // 故障录波 map的key为文件名
+	UpdateLog     string                       `bson:"updateLog" json:"updateLog"`         // 升级记录
+	Settings      string                       `bson:"settings" json:"settings"`           // 站点设置
+}
+
+// 下载任务数据 (保存于redis数据库)
+type BatchDownloadFileTaskInfo struct {
+	DevType     int    `bson:"devType" json:"devType"` // 设备类型:-1-忽略 0-aPower 1-aGate 2-MPPT 3-aHub
+	Sn          string `bson:"sn" json:"sn"`
+	Date        string `bson:"date" json:"date"`
+	FileType    string `bson:"fileType" json:"fileType"`
+	FileSupType string `bson:"fileSubType" json:"fileSubType"`
+	Result      int    `bson:"result" json:"result"` // -1-失败 0-进行中 1-成功
+	Reason      string `bson:"reason" json:"reason"` // 失败原因
+}
+
+type BatchDownloadUpdateLogVersionInfo struct {
+	Name   string `json:"name"` // 名称
+	OldVer string `json:"oldVer"`
+	NewVer string `json:"newVer"`
+}
+
+type BatchDownloadUpdateLogSubDevInfo struct {
+	DevSn      string                              `json:"devSn"`
+	DevVerList []BatchDownloadUpdateLogVersionInfo `json:"devVerList"`
+}
+
+type BatchDownloadUpdateLogAHubVersionInfo struct {
+	FileName   string `json:"fileName"`   // 文件名称
+	FileOrder  string `json:"fileOrder"`  // 文件单号
+	Result     string `json:"result"`     // 升级结果 "0"-成功
+	ResultDesc string `json:"resultDesc"` // 升级结果说明
+	OldVer     string `json:"oldVer"`
+	NewVer     string `json:"newVer"`
+}
+
+// 系统升级日志信息数据
+type BatchDownloadSystemUpdateLogInfo struct {
+	OrderNo     string                              `json:"orderNo"`     // 升级工单号
+	TriggerType string                              `json:"triggerType"` // 触发类型 自动 or 手动
+	GateSn      string                              `json:"gateSn"`
+	FileName    string                              `json:"fileName"`   // 升级包名
+	UpdateTime  string                              `json:"updateTime"` // 触发升级时间 - 北京时间
+	Result      string                              `json:"result"`     // 升级结果
+	GateVerList []BatchDownloadUpdateLogVersionInfo `json:"gateVerList"`
+	SubDevList  []BatchDownloadUpdateLogSubDevInfo  `json:"subVerList"`
+}
+
+// aHub升级日志信息数据
+type BatchDownloadAHubUpdateLogInfo struct {
+	OrderNo     string                                  `json:"orderNo"`     // 升级工单号
+	TriggerType string                                  `json:"triggerType"` // 触发类型 自动 or 手动
+	AHubSn      string                                  `json:"aHubSn"`      // aHubSn
+	GateSn      string                                  `json:"gateSn"`
+	EndTime     string                                  `json:"endTime"` // 升级时间 - 北京时间
+	Result      string                                  `json:"result"`  // 升级结果
+	DetailList  []BatchDownloadUpdateLogAHubVersionInfo `json:"detailList"`
+}

+ 145 - 1
service/domain/fwhProDomain.go

@@ -3,7 +3,7 @@ package domain
 type FwhPageResponse[T any] struct {
 	Code    int    `json:"code"`
 	Message string `json:"message"`
-	Result  []T    `json:"result"`
+	Result  T      `json:"result"`
 	Success bool   `json:"success"`
 	Total   uint64 `json:"total"`
 }
@@ -49,3 +49,147 @@ type ShutdownRecord struct {
 	ActiveTime   string `json:"activeTime"`   // 激活时间
 	DeviceTime   string `json:"deviceTime"`   // 告警时的设备时间 (Shutdown开始时间)
 }
+
+type EmsLogRecordInfo struct {
+	Id           int64  `json:"id"`
+	FileName     string `json:"fileName"` // 不含后缀
+	CallTime     string `json:"callTime"`
+	CompleteTime string `json:"completeTime"`
+	Status       int    `json:"status"` // 召唤状态 1-成功 0-召唤中 -1-失败
+	FileUrl      string `json:"fileUrl"`
+	LinkId       string `json:"linkId"` // SN
+	LogTime      string `json:"logTime"`
+	CodeType     int    `json:"codeType"` // 日志类型 4-IBG运行日志 7-电池日志 9-设备系统资源监控日志 10-4G日志 11-AWS日志 12-主进程日志
+}
+
+type FaultRecordInfo struct {
+	Id                int64   `json:"id"`
+	DeviceType        int     `json:"deviceType"`        // 0-aPower 1-aGate 2-MPPT 3-aHub
+	ApowerId          *string `json:"apowerId"`          // aPower SN, deviceType不为0或2时为null
+	GatewayId         string  `json:"gatewayId"`         // aGate SN
+	PowerlinkId       *string `json:"powerlinkId"`       // aHub SN, deviceType不为3时为null
+	TriggerType       int     `json:"triggerType"`       // 触发类中:1-手动 2-自动
+	FaultRecordsCount int     `json:"faultRecordsCount"` // 故障录波召唤数量
+	EarliestFaultTime string  `json:"earliestFaultTime"` // 故障录波列表Format: YYY-MM-DD hh:mm:ss / 召唤记录Format: 时间戳
+	LatestFaultTime   string  `json:"latestFaultTime"`   // 故障录波列表Format: YYY-MM-DD hh:mm:ss / 召唤记录Format: 时间戳
+	CellTime          string  `json:"cellTime"`          // 召唤时间 Format: YYY-MM-DD hh:mm:ss
+	FileUrl           string  `json:"fileUrl"`           // 文件下载路径
+	FileName          string  `json:"fileName"`          // 文件名,不含后缀
+	Status            *int    `json:"status"`            // 录波数据查询为null,召唤记录查询则为召唤状态 0-开始召唤 2-召唤成功 -1-召唤失败
+	FaultReason       *string `json:"faultReason"`       // 录波数据查询为null,status为-1时为失败原因,否则为null
+}
+
+type FaultRecordCallableInfo struct {
+	Idx                int    `json:"idx"`                // 数据序号
+	IndexNumber        string `json:"indexNumber"`        // 数据序号 exg."01"
+	FailureTime        string `json:"failureTime"`        // 故障时间 Format: YYY-MM-DD hh:mm:ss
+	ParamVersionNumber string `json:"paramVersionNumber"` // 故障信息
+	TriggerSourceCode  string `json:"triggerSourceCode"`  // 故障码
+}
+
+type FaultRecordChannelInfo struct {
+	Code     string `json:"code"`
+	Describe string `json:"describe"` // 说明
+	Index    int    `json:"index"`
+}
+
+type FaultRecordSubInfo struct {
+	DeviceType             int                      `json:"deviceType"` // 0-aPower 1-aGate 2-MPPT 3-aHub
+	ApowerId               *string                  `json:"apowerId"`   // aPower SN
+	GatewayId              string                   `json:"gatewayId"`  // aGate SN
+	FileName               string                   `json:"fileName"`   // 不含后缀
+	Id                     int64                    `json:"id"`         // 本条记录的ID
+	FaultId                int64                    `json:"faultId"`
+	EventTriggerSource     string                   `json:"eventTriggerSource"`     // 故障码
+	EventTriggerSourceName string                   `json:"eventTriggerSourceName"` // 故障码解释
+	SamplingCount          uint                     `json:"samplingCount"`          // 采样通道数量
+	FailureTime            string                   `json:"failureTime"`            //故障发送时间 Format: YYY-MM-DD hh:mm:ss
+	SnapshotCount          string                   `json:"snapshotCount"`          // 快照采样通道数量
+	SamplingPoints         string                   `json:"samplingPoints"`         // 采样点总数
+	MaxFrequency           string                   `json:"maxFrequency"`           // 最大采样频率
+	CurrFrequency          string                   `json:"currFrequency"`          // 当前采样频率
+	SnapshotMeaningVal     string                   `json:"snapshotMeaningVal"`     // 快照含义?
+	SnapshotChannel        []FaultRecordChannelInfo `json:"snapshotChannel"`        // 快照通道信息
+	SamplingMeaning        string                   `json:"samplingMeaning"`        // 采样含义?
+	SamplingChannel        []FaultRecordChannelInfo `json:"samplingChannel"`        // 采样通道信息
+}
+
+type SystemUpdateRecordInfo struct {
+	Id                   int64  `json:"id"`
+	OrderNo              string `json:"orderNo"` // 升级单号
+	Name                 string `json:"name"`    //
+	UpgradeType          string `json:"upgradeType"`
+	UpgradeTime          string `json:"upgradeTime"` // 升级开始时间 - UTC时间
+	UpgradeNum           int    `json:"upgradeNum"`  // 升级设备数量
+	Result               int    `json:"result"`      // 升级结果 1-升级中 2-升级成功 3-升级失败
+	Status               int    `json:"status"`
+	SuccessNum           int    `json:"successNum"`
+	FailuresNum          int    `json:"failuresNum"`
+	TurnoverTime         string `json:"turnoverTime"`         // 升级完成时间 - UTC时间
+	OrderUpgradeTypeName string `json:"orderUpgradeTypeName"` // 升级类型 exg.无感升级
+}
+
+type AHubUpdateRecordInfo struct {
+	AHubSn        string `json:"ahubSn"`        // aHub SN
+	OrderNo       string `json:"orderNo"`       // 升级单号
+	GatewayId     string `json:"gatewayId"`     // Gate SN
+	EndTime       int64  `json:"endTime"`       // 升级结束时间 - 时间戳(ms) - 只能显示北京时间
+	UpgradeResult int    `json:"upgradeResult"` // 升级结果 0-成功
+	DetailList    []struct {
+		UpgradeFileName   string `json:"upgradeFileName"`   // 升级包名
+		UpgradeFileOrder  string `json:"upgradeFileOrder"`  // 升级包单号
+		UpgradeResult     string `json:"upgradeResult"`     // 升级结果 '0'-成功
+		UpgradeResultDesc string `json:"upgradeResultDesc"` // 升级结果说明
+		OldVersion        string `json:"oldVersion"`        // 升级前版本
+		NewVersion        string `json:"newVersion"`        // 升级后版本
+	} `json:"detailList"`
+}
+
+type SystemUpdateDetailInfo struct {
+	Id            int64      `json:"id"`
+	OrderNo       string     `json:"orderNo"`   // 升级单号
+	GatewayId     string     `json:"gatewayId"` // SN
+	GatewayName   string     `json:"gatewayName"`
+	GatewayStatus int        `json:"gatewayStatus"`
+	ActiveStatus  int        `json:"activeStatus"` // 激活状态 0-未激活 1-已激活
+	Result        int        `json:"result"`       // 升级结果 1-升级中 2-升级成功 3-升级失败
+	UpgradeTime   string     `json:"upgradeTime"`  // 升级开始时间
+	TurnoverTime  string     `json:"turnoverTime"` // 升级完成时间
+	IbgVersion    string     `json:"ibgVersion"`   // 设备版本信息(同IbgVersionNew) JSON string
+	IbgVersionNew []struct { // Gate 版本
+		Type       string `json:"type"`       // 版本类型
+		OldVersion string `json:"oldVersion"` // 升级前版本
+		Version    string `json:"version"`    // 升级后版本
+		Children   []struct {
+			Type       string `json:"type"`       // 版本类型
+			OldVersion string `json:"oldVersion"` // 升级前版本
+			Version    string `json:"version"`    // 升级后版本
+		} `json:"children"`
+	} `json:"ibgVersionNew"`
+	ErrCode             int        `json:"errCode"` // 错误码 0-无错误
+	ErrMsg              string     `json:"errMsg"`  // 错误信息
+	DownloadAfterStatus int        `json:"downloadAfterStatus"`
+	CoverOrderNo        string     `json:"coverOrderNo"`
+	SysHdVersion        int        `json:"sysHdVersion"`     // 系统硬件版本 int eg.104=FHP1.4
+	RealSysHdVersion    string     `json:"realSysHdVersion"` // 系统硬件版本 string
+	EmsDeviceVer        int        `json:"emsDeviceVer"`
+	OldEmsDeviceVer     int        `json:"oldEmsDeviceVer"`
+	SonNewDevices       []struct { // 子设备版本 (aPower、aHub)
+		ApowerSn  string     `json:"apowerSn"` // 子设备SN
+		FirstList []struct { // 1级菜单
+			BigType    string     `json:"bigType"`  // 设备类型
+			DeviceSn   string     `json:"deviceSn"` // 设备SN
+			Secondlist []struct { // 2级菜单
+				Type      string     `json:"type"` // 版本类型
+				Thirdlist []struct { // 3级类型
+					SubType    string `json:"subType"`    // 版本类型
+					Version    string `json:"version"`    // 升级后版本
+					OldVersion string `json:"oldVersion"` // 升级前版本
+				} `json:"thirdlist"`
+			} `json:"secondlist"`
+		} `json:"firstList"`
+	} `json:"sonNewDevices"`
+	// sonDevices ??? TODO
+	// apboxVersion ??? TODO
+	// apboxVersionVOList ??? TODO
+}

+ 1 - 0
service/domain/taskQueueDomain.go

@@ -7,6 +7,7 @@ type Trigger string  // 'auto' | 'manual'
 const (
 	ProgressPending  Progress = "pending"
 	ProgressRunning  Progress = "running"
+	ProgressFailed   Progress = "failed"
 	ProgressError    Progress = "error"
 	ProgressComplete Progress = "complete"
 )

BIN
service/fwh-toolbox-server


+ 20 - 8
service/go.mod

@@ -1,6 +1,6 @@
 module fwh-toolbox-server
 
-go 1.23.0
+go 1.24.0
 
 toolchain go1.24.5
 
@@ -27,6 +27,17 @@ require (
 	gorm.io/plugin/dbresolver v1.6.2
 )
 
+require (
+	github.com/go-echarts/go-echarts/v2 v2.6.6 // indirect
+	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/richardlehane/mscfb v1.0.4 // indirect
+	github.com/richardlehane/msoleps v1.0.4 // indirect
+	github.com/tiendc/go-deepcopy v1.7.1 // indirect
+	github.com/xuri/efp v0.0.1 // indirect
+	github.com/xuri/excelize/v2 v2.10.0 // indirect
+	github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
+)
+
 require (
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/KyleBanks/depth v1.2.1 // indirect
@@ -65,6 +76,7 @@ require (
 	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/minio/crc64nvme v1.0.2 // indirect
 	github.com/minio/md5-simd v1.1.2 // indirect
+	github.com/minio/minio-go v6.0.14+incompatible
 	github.com/minio/minio-go/v7 v7.0.95 // indirect
 	github.com/montanaflynn/stats v0.7.1 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
@@ -89,13 +101,13 @@ require (
 	github.com/xdg-go/stringprep v1.0.4 // indirect
 	github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/crypto v0.41.0 // indirect
-	golang.org/x/mod v0.27.0 // indirect
-	golang.org/x/net v0.43.0 // indirect
-	golang.org/x/sync v0.16.0 // indirect
-	golang.org/x/sys v0.35.0 // indirect
-	golang.org/x/text v0.28.0 // indirect
-	golang.org/x/tools v0.36.0 // indirect
+	golang.org/x/crypto v0.43.0 // indirect
+	golang.org/x/mod v0.28.0 // indirect
+	golang.org/x/net v0.46.0 // indirect
+	golang.org/x/sync v0.17.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
+	golang.org/x/text v0.30.0 // indirect
+	golang.org/x/tools v0.37.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	gorm.io/hints v1.1.2 // indirect

+ 34 - 0
service/go.sum

@@ -32,6 +32,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-echarts/go-echarts/v2 v2.6.6 h1:3edScZCB36YmWeSu4JiT8Jnh/gTrpeV1dWmEoQpc46I=
+github.com/go-echarts/go-echarts/v2 v2.6.6/go.mod h1:Z+spPygZRIEyqod69r0WMnkN5RV3MwhYDtw601w3G8w=
 github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
 github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
 github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -120,8 +122,12 @@ github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg
 github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
 github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
 github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o=
+github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8=
 github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
 github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -133,6 +139,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
 github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
+github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
+github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
+github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -163,6 +174,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
 github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
@@ -171,6 +183,8 @@ github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
 github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
 github.com/tidwall/evio v1.0.8 h1:+M7lh83rL4KwEObDGtXP3J1wE5utH80LeaAhrKCGVfE=
 github.com/tidwall/evio v1.0.8/go.mod h1:MJhRp4iVVqx/n/5mJk77oKmSABVhC7yYykcJiKaFYYw=
+github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
+github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
 github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
 github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -185,6 +199,12 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
 github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
 github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
+github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -200,21 +220,29 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
 golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
 golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
 golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
 golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
 golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
 golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -225,6 +253,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
 golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -234,11 +264,15 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
 golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
 golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 629 - 0
service/jobs/callFwhFile.go

@@ -0,0 +1,629 @@
+package jobs
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+	"path"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+type TaskQueueMutex struct {
+	running bool
+	mu      sync.Mutex
+}
+
+type EmsLogType = string
+
+var (
+	EmsRunLog     = "EmsRunLog"     // 4-IBG运行日志
+	EmsBattLog    = "EmsBattLog"    // 7-电池日志
+	EmsMonitorLog = "EmsMonitorLog" // 9-设备系统资源监控日志
+	Ems4gLog      = "Ems4gLog"      // 10-4G日志
+	EmsAwsLog     = "EmsAwsLog"     // 11-AWS日志
+	EmsMainLog    = "EmsMainLog"    // 12-主进程日志
+)
+
+// EMS日志类型map
+var mapEmsLogType = map[string]int{
+	"EmsRunLog":     4,
+	"EmsBattLog":    7,
+	"EmsMonitorLog": 9,
+	"Ems4gLog":      10,
+	"EmsAwsLog":     11,
+	"EmsMainLog":    12,
+}
+
+var (
+	callFwhFileCtx   = context.Background()
+	streamNamePrefix = "fwhtoolbox:CallFwhFile:"
+	groupName        = "task_queue"
+	consumerID       = "worker-taskQueue"
+
+	// 防止队列任务函数重复启动
+	taskQueueMutex   = map[string]*TaskQueueMutex{}
+	taskQueueMutexMu sync.RWMutex // 用于保护 map
+
+	// 最大召唤次数
+	maxCount = 3
+)
+
+// 召唤文件类型
+var (
+	FileTypeEmsLog      = "EmsLog"
+	FileTypeFaultRecord = "FaultRecord"
+)
+
+type CallFileTaskStreamInfo struct {
+	Ctx        context.Context
+	StreamName string
+	GroupName  string
+	ID         string
+}
+
+type CallFileTaskInfo struct {
+	MainTaskId primitive.ObjectID `bson:"mainTaskId" json:"mainTaskId"`
+}
+
+func CreateCallFwhFileTask(mainTaskId primitive.ObjectID, taskList []domain.BatchDownloadFileTaskInfo) error {
+	// 筛选SN,不同SN可并行执行
+	sortTaskList := map[string][]domain.BatchDownloadFileTaskInfo{}
+	for _, task := range taskList {
+		sortTaskList[task.Sn] = append(sortTaskList[task.Sn], task)
+	}
+
+	taskRecordList := map[string]domain.BatchDownloadFileTaskInfo{}
+	for sn, payloadList := range sortTaskList {
+		for _, payload := range payloadList {
+			// 确保消费组存在(忽略已存在错误)
+			_ = cache.XGroupCreate(callFwhFileCtx, streamNamePrefix+sn, groupName, "$")
+			id, err := enqueueTask(sn, CallFileTaskInfo{
+				MainTaskId: mainTaskId,
+			}, payload)
+			if err != nil {
+				zap.L().Error("创建召唤FWH文件任务队列失败")
+				return err
+			}
+			taskRecordList[id] = payload
+		}
+	}
+
+	if err := repositories.CreateBatchDownloadCallFileTaskList(mainTaskId.Hex(), taskRecordList); err != nil {
+		zap.L().Error("插入召唤FWH文件任务队列记录列表失败")
+		return err
+	}
+
+	// 提取SN list
+	snList := make([]string, 0, len(sortTaskList))
+	for sn := range sortTaskList {
+		snList = append(snList, sn)
+	}
+
+	// 启动任务队列
+	for _, sn := range snList {
+		go startTaskQueueWorker(sn)
+		time.Sleep(time.Second) // 任务队列启动延时,避免过多请求同时发出,将任务错开运行
+	}
+
+	return nil
+}
+
+func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.BatchDownloadFileTaskInfo) {
+	var reason string
+	streamName := streamNamePrefix + info.Sn
+	for count := 0; count < maxCount; count++ {
+		// 请求召唤
+		switch info.FileType {
+		case FileTypeEmsLog: // 召唤EMS日志
+			fmt.Printf("callEmsLog SN-%s Date-%s Type-%s\n", info.Sn, info.Date, info.FileSupType)
+			if err := callEmsLog(info.Sn, info.Date, info.FileSupType); err != nil {
+				ackTask(CallFileTaskStreamInfo{
+					Ctx:        callFwhFileCtx,
+					StreamName: streamName,
+					GroupName:  groupName,
+					ID:         taskId,
+				}, false, fmt.Errorf("handle task CallEmsLog error - %s", err.Error()))
+				return
+			}
+
+			ok, err := waitUntilDone(func() (bool, error) {
+				// 查询召唤结果
+				for count := 0; count < maxCount; count++ {
+					fmt.Printf("getEmsLogUrl SN-%s Date-%s Type-%s\n", info.Sn, info.Date, info.FileSupType)
+					recordInfo, err := getEmsLogUrl(info.Sn, info.Date, info.FileSupType)
+					if err != nil {
+						zap.L().Error("getEmsLogUrl读取EMS日志地址失败")
+						continue
+					}
+					if recordInfo.Status == -1 {
+						zap.L().Error("EMS日志召唤失败")
+						return false, fmt.Errorf("EMS日志召唤失败")
+					} else if recordInfo.Status == 1 {
+						// 下载文件
+						var downloadCount int = 0
+						for downloadCount = 0; downloadCount < maxCount; downloadCount++ {
+							dataBytes, err := repositories.QueryDownloadEmsLogFile(recordInfo.FileUrl)
+							if err != nil {
+								zap.L().Error("EMS日志文件下载失败")
+								continue
+							}
+
+							// 提取文件后缀
+							baseStr := path.Base(recordInfo.FileUrl) // 取最后的文件名部分
+							if baseStr == "." || baseStr == "/" {
+								baseStr = ""
+							}
+
+							// 使用正则匹配多级后缀,例如 .tar.bz2, .tar.gz, .min.js 等
+							re := regexp.MustCompile(`(\.[a-zA-Z0-9]+)+$`)
+							matchSuffix := re.FindString(baseStr)
+
+							// 加载加州时区(Pacific Time)
+							loc, err := time.LoadLocation("America/Los_Angeles")
+							if err != nil {
+								zap.L().Error("Ems日志LoadLocation设置失败", zap.String("callTime", recordInfo.CallTime), zap.String("err", err.Error()))
+								return false, err
+							}
+
+							// 按指定时区解析时间,生效召唤时间的时间戳
+							callTime, err := time.ParseInLocation(time.DateTime, recordInfo.CallTime, loc)
+							if err != nil {
+								zap.L().Error("Ems日志CallTime解析失败", zap.String("callTime", recordInfo.CallTime), zap.String("err", err.Error()))
+								return false, err
+							}
+
+							// 生成文件名 Format "EmsLog_[SN]_[logType]_[logDate]_[cellTimestamp].[Suffix]"
+							fileName := fmt.Sprintf("EmsLog_%s_%s_%s_%s%s", info.Sn, info.FileSupType, strings.ReplaceAll(info.Date, "-", "_"), strconv.FormatInt(callTime.Unix(), 10), matchSuffix)
+
+							// 保存数据到文件服务器
+							objName, err := SaveBatchDownloadEmsLog(info.Sn, dataBytes, fileName, "application/x-bzip2")
+							if err != nil {
+								zap.L().Error("Ems日志文件保存失败", zap.String("devType", strconv.Itoa(info.DevType)), zap.String("sn", info.Sn), zap.String("err", err.Error()))
+								return false, fmt.Errorf("Ems日志文件保存失败")
+							}
+							if err := repositories.InsertBatchDownloadPathEmsLogs(mainTaskId, info.Sn, info.Date, info.FileSupType, objName); err != nil {
+								zap.L().Error("BatchDownload - EmsLog文件地址写入失败", zap.String("sn", info.Sn), zap.String("date", info.Date), zap.String("objName", objName), zap.String("err", err.Error()))
+								return false, fmt.Errorf("BatchDownload - EmsLog文件地址写入失败")
+							}
+
+							break
+						}
+
+						// 超过最大尝试次数
+						if downloadCount >= maxCount {
+							return false, fmt.Errorf("EMS日志文件下载失败,超过最大尝试次数:%d", maxCount)
+						}
+
+						return true, nil
+					}
+					return false, nil
+				}
+				return false, fmt.Errorf("读取EMS日志地址请求失败")
+			}, time.Minute, 30*time.Minute) // 每分钟查看,半小时超时
+			if err != nil {
+				reason = err.Error() // 保存失败原因
+			}
+			if ok {
+				// 召唤成功
+				repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, 1, "")
+				return
+			} else {
+				zap.L().Warn("Call FWH file失败, 再次尝试", zap.String("count", strconv.Itoa(count)))
+			}
+		case FileTypeFaultRecord: // 召唤故障录波
+			timeRange := strings.Split(info.Date, ",")
+			if len(timeRange) != 2 {
+				ackTask(CallFileTaskStreamInfo{
+					Ctx:        callFwhFileCtx,
+					StreamName: streamName,
+					GroupName:  groupName,
+					ID:         taskId,
+				}, false, fmt.Errorf("handle task CallFaultRecord timeRange error - timeRange: %s", info.Date))
+				return
+			}
+
+			// 提取下载记录的indexList
+			indexList := []int{}
+			indexStrList := strings.Split(info.FileSupType, ",")
+			for _, indexStr := range indexStrList {
+				idx, err := strconv.Atoi(indexStr)
+				if err != nil {
+					ackTask(CallFileTaskStreamInfo{
+						Ctx:        callFwhFileCtx,
+						StreamName: streamName,
+						GroupName:  groupName,
+						ID:         taskId,
+					}, false, fmt.Errorf("handle task CallFaultRecord indexStrList error - indexStrList: %s - err: %s", info.FileSupType, err.Error()))
+					return
+				}
+				indexList = append(indexList, idx)
+			}
+
+			// 召唤故障录波数据
+			err := callFaultRecord(info.DevType, info.Sn, timeRange[0], timeRange[1], indexList)
+			if err != nil {
+				ackTask(CallFileTaskStreamInfo{
+					Ctx:        callFwhFileCtx,
+					StreamName: streamName,
+					GroupName:  groupName,
+					ID:         taskId,
+				}, false, fmt.Errorf("handle task CallFaultRecord error - err: %s", err.Error()))
+				return
+			}
+
+			ok, err := waitUntilDone(func() (bool, error) {
+				// 查询召唤结果
+				for count := 0; count < maxCount; count++ {
+					status, faultId, reasonStr, err := getFaultRecordUrl(info.DevType, info.Sn, timeRange[0], timeRange[1])
+					if err != nil {
+						zap.L().Error("getFaultRecordUrl读取故障录波地址失败", zap.String("err", err.Error()))
+						continue
+					}
+					if status == -1 {
+						zap.L().Error("故障录波召唤失败", zap.String("reason", reasonStr))
+						return false, fmt.Errorf("故障录波召唤失败 - %s", reasonStr)
+					} else if status == 2 {
+						// 查询子记录
+						subRecordList, err := repositories.QueryFaultRecordSubList(faultId)
+						if err != nil {
+							zap.L().Error("故障录波子记录查询失败", zap.String("err", err.Error()))
+							return false, fmt.Errorf("故障录波子记录查询失败 - %w", err)
+						}
+
+						// 下载所有自己录中的文件
+						for _, subRecord := range subRecordList {
+							var downloadCount int = 0
+							for downloadCount = 0; downloadCount < maxCount; downloadCount++ {
+								_, dataBytes, err := repositories.QueryDownloadFaultRecordData(faultId, subRecord.Id)
+								if err != nil {
+									zap.L().Error("故障录波记录文件下载失败", zap.String("devType", strconv.Itoa(subRecord.DeviceType)), zap.String("sn", subRecord.GatewayId), zap.String("faultId", strconv.FormatInt(subRecord.FaultId, 10)), zap.String("id", strconv.FormatInt(subRecord.Id, 10)), zap.String("err", err.Error()))
+									continue
+								}
+
+								// 保存数据到文件服务器
+								objName, err := SaveBatchDownloadFaultRecord(subRecord.GatewayId, dataBytes, subRecord.FileName+".xls", "application/vnd.ms-excel;charset=gb2312")
+								if err != nil {
+									zap.L().Error("故障录波记录文件保存失败", zap.String("devType", strconv.Itoa(subRecord.DeviceType)), zap.String("sn", subRecord.GatewayId), zap.String("faultId", strconv.FormatInt(subRecord.FaultId, 10)), zap.String("id", strconv.FormatInt(subRecord.Id, 10)), zap.String("err", err.Error()))
+									return false, fmt.Errorf("故障录波记录文件保存失败")
+								}
+								if err := repositories.InsertBatchDownloadPathFaultRecord(mainTaskId, subRecord.GatewayId, subRecord.FileName, objName); err != nil {
+									zap.L().Error("BatchDownload - FaultRecord文件地址写入失败", zap.String("sn", subRecord.GatewayId), zap.String("fileName", subRecord.FileName), zap.String("objName", objName), zap.String("err", err.Error()))
+									return false, fmt.Errorf("BatchDownload - FaultRecord文件地址写入失败")
+								}
+
+								break
+							}
+
+							// 超过最大尝试次数
+							if downloadCount >= maxCount {
+								return false, fmt.Errorf("故障录波记录文件下载失败,超过最大尝试次数:%d", maxCount)
+							}
+						}
+
+						return true, nil
+					}
+					return false, nil
+				}
+				return false, fmt.Errorf("读取故障录波地址请求失败")
+			}, time.Minute, 30*time.Minute) // 每分钟查看,半小时超时
+			if err != nil {
+				reason = err.Error() // 保存失败原因
+			}
+			if ok {
+				// 召唤成功
+				repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, 1, "")
+				return
+			} else {
+				zap.L().Warn("Call FWH file失败, 再次尝试", zap.String("count", strconv.Itoa(count)))
+			}
+
+		default:
+			logger.Std.Println("Unknown call file type:", info.FileType)
+		}
+	}
+
+	// 召唤失败
+	repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, -1, reason)
+}
+
+/************** 任务轮询 **************/
+func waitUntilDone(check func() (bool, error), interval time.Duration, timeout time.Duration) (bool, error) {
+	ticker := time.NewTicker(interval)
+	defer ticker.Stop()
+
+	timeoutChan := time.After(timeout)
+
+	for {
+		select {
+		case <-ticker.C:
+			ok, err := check()
+			if err != nil {
+				zap.L().Debug("❌ 轮询任务失败", zap.String("err", err.Error()))
+				return false, err
+			} else if ok {
+				zap.L().Debug("✅ 轮询任务完成")
+				return true, nil
+			} else {
+				zap.L().Debug("⏳ 任务轮询中...")
+			}
+
+		case <-timeoutChan:
+			zap.L().Debug("❌ 轮询超时")
+			return false, fmt.Errorf("召唤结果轮询超时")
+		}
+	}
+}
+
+/************** 召唤文件任务队列 **************/
+func startTaskQueueWorker(sn string) {
+	taskQueueMutexMu.Lock()
+	taskMutex, ok := taskQueueMutex[sn]
+	if !ok || taskMutex == nil {
+		taskMutex = &TaskQueueMutex{
+			running: false,
+			mu:      sync.Mutex{},
+		}
+		taskQueueMutex[sn] = taskMutex
+	}
+	taskQueueMutexMu.Unlock()
+
+	taskMutex.mu.Lock()
+	if taskMutex.running {
+		taskMutex.mu.Unlock()
+		zap.L().Info("任务已在运行,跳过执行")
+		return
+	}
+	taskMutex.running = true
+	taskMutex.mu.Unlock()
+
+	streamName := streamNamePrefix + sn
+	streamsMap := make(map[string]string)
+	streamsMap[streamName] = ">"
+
+	for {
+		// 处理新任务
+		res, err := cache.XReadGroup(callFwhFileCtx, groupName, consumerID, streamsMap, 1) // 等待任务
+		if err != nil {
+			if err == redis.Nil {
+				// 未读取到新消息 - 退出
+				logger.Std.Println("未读取到新消息: ", sn)
+				taskMutex.mu.Lock()
+				taskMutex.running = false
+				taskMutex.mu.Unlock()
+				cache.Delete(callFwhFileCtx, streamNamePrefix+sn)
+				return
+			}
+			// Redis异常 - 退出
+			logger.Std.Println("XReadGroup error:", err)
+			taskMutex.mu.Lock()
+			taskMutex.running = false
+			taskMutex.mu.Unlock()
+			return
+		}
+
+		for _, stream := range res {
+			for _, message := range stream.Messages {
+				logger.Std.Println("XReadGroup read message:", message)
+				handleTaskQueueMsg(sn, message)
+			}
+		}
+	}
+}
+
+// 处理单条消息
+func handleTaskQueueMsg(sn string, message redis.XMessage) {
+	id := message.ID
+	rawPayload := message.Values["payload"].(string)
+	rawTaskInfo := message.Values["taskInfo"].(string)
+	streamName := streamNamePrefix + sn
+
+	// 反序列化payload
+	var payload domain.BatchDownloadFileTaskInfo
+	if err := json.Unmarshal([]byte(rawPayload), &payload); err != nil {
+		ackTask(CallFileTaskStreamInfo{
+			Ctx:        callFwhFileCtx,
+			StreamName: streamName,
+			GroupName:  groupName,
+			ID:         id,
+		}, false, fmt.Errorf("unmarshal payload error - %s", err.Error()))
+		return
+	}
+
+	// 反序列化taskInfo
+	var taskInfo CallFileTaskInfo
+	if err := json.Unmarshal([]byte(rawTaskInfo), &taskInfo); err != nil {
+		ackTask(CallFileTaskStreamInfo{
+			Ctx:        callFwhFileCtx,
+			StreamName: streamName,
+			GroupName:  groupName,
+			ID:         id,
+		}, false, fmt.Errorf("unmarshal taskInfo error - %s", err.Error()))
+		return
+	}
+
+	logger.Std.Printf("Processing task %s: %+v\n", id, payload)
+
+	// 异步执行召唤文件任务
+	callFwhFile(taskInfo.MainTaskId, id, payload)
+
+	// switch payload.FileType {
+	// case FileTypeEmsLog: // 召唤EMS日志
+	// 	if err := handleTaskSiteAnalysis(ctx, payload.Payload); err != nil {
+	// 		ackTask(CallFileTaskStreamInfo{
+	// 			Ctx:        callFwhFileCtx,
+	// 			StreamName: streamName,
+	// 			GroupName:  groupName,
+	// 			ID:         id,
+	// 		}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
+	// 		return
+	// 	}
+	// case FileTypeFaultRecord: // 召唤故障录波
+	// 	if err := handleTaskAlarmStatistics(ctx, payload.Payload); err != nil {
+	// 		ackTask(CallFileTaskStreamInfo{
+	// 			Ctx:        callFwhFileCtx,
+	// 			StreamName: streamName,
+	// 			GroupName:  groupName,
+	// 			ID:         id,
+	// 		}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
+	// 		return
+	// 	}
+	// default:
+	// 	logger.Std.Println("Unknown task type:", payload.FileType)
+	// }
+
+	// 任务完成
+	ackTask(CallFileTaskStreamInfo{
+		Ctx:        callFwhFileCtx,
+		StreamName: streamName,
+		GroupName:  groupName,
+		ID:         id,
+	}, true, nil)
+}
+
+func ackTask(streamInfo CallFileTaskStreamInfo, success bool, err error) {
+	// 删除任务
+	if err := cache.XAckDel(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); err != nil {
+		logger.Std.Println("XAck error:", err)
+	}
+
+	if success {
+		// TODO 确认任务完成
+
+	} else {
+		// TODO 任务失败
+		logger.Std.Println("Task queue execution failed, error:", err)
+	}
+
+	if err != nil {
+		zap.L().Error("taskQueue ackTask error.", zap.String("err", err.Error()))
+	}
+}
+
+func enqueueTask(sn string, taskInfo CallFileTaskInfo, payload domain.BatchDownloadFileTaskInfo) (string, error) {
+	payloadBytes, err := json.Marshal(payload)
+	if err != nil {
+		return "", err
+	}
+
+	taskInfoBytes, err := json.Marshal(taskInfo)
+	if err != nil {
+		return "", err
+	}
+
+	return cache.XAdd(callFwhFileCtx, streamNamePrefix+sn, map[string]interface{}{"payload": string(payloadBytes), "taskInfo": string(taskInfoBytes)})
+}
+
+/************* 文件召唤&查询&下载接口 *************/
+// 召唤EMS日志
+func callEmsLog(sn string, date string, logType string) error {
+	logTypeNum, ok := mapEmsLogType[logType]
+	if !ok {
+		zap.L().Error("logType不存在", zap.String("logType", logType))
+		return fmt.Errorf("logType不存在,logType=%s", logType)
+	}
+	err := repositories.QueryCallEmsLog(sn, date, logTypeNum)
+	if err != nil {
+		zap.L().Error("QueryCallEmsLog请求失败", zap.String("err", err.Error()))
+		return err
+	}
+
+	return nil
+}
+
+// 获取日志下载地址
+func getEmsLogUrl(sn string, date string, logType string) (*domain.EmsLogRecordInfo, error) {
+	logTypeNum, ok := mapEmsLogType[logType]
+	if !ok {
+		zap.L().Error("logType不存在", zap.String("logType", logType))
+		return nil, fmt.Errorf("logType不存在,logType=%s", logType)
+	}
+	recordInfo, err := repositories.QueryEmsLogCallRecord(sn, date, logTypeNum)
+	if err != nil {
+		zap.L().Error("GetEmsLogUrl请求失败", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	return recordInfo, nil
+}
+
+// 召唤故障录波
+func callFaultRecord(devType int, sn string, startTime string, endTime string, indexList []int) error {
+	// 查询故障录波列表
+	err := repositories.QueryCellFaultRecord(devType, sn, indexList)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 获取日志下载地址
+// return (status, faultId, faultReason, error)
+// status: -1-失败 0-召唤中 2-成功
+func getFaultRecordUrl(devType int, sn string, startTime string, endTime string) (int, int64, string, error) {
+	// 查询召唤记录
+	recordList, err := repositories.QueryFaultRecordCallRecordList()
+	if err != nil {
+		return 0, 0, "", err
+	}
+
+	for _, record := range recordList {
+		// // 解析时间
+		// t, err := time.Parse(time.DateTime, record.CellTime)
+		// if err != nil {
+		// 	return 0, nil, err
+		// }
+
+		// // 加 8 小时
+		// newTime := t.Add(8 * time.Hour)
+
+		// // 转回字符串
+		// callTime := newTime.Format(time.DateTime)
+
+		// // 判断记录是否当天召唤
+		// currTime := time.Now().Format(time.DateOnly)
+		// if !strings.HasPrefix(callTime, currTime) {
+		// 	// 之后的非当天召唤的数据
+		// 	break
+		// }
+
+		// 匹配符合条件的第一个数据
+		if record.DeviceType == devType {
+			var devSn string = ""
+			switch devType {
+			case 0: // aPower
+				devSn = *record.ApowerId
+			case 1: // aGate
+				devSn = record.GatewayId
+			case 2: // MPPT
+				devSn = *record.ApowerId
+			case 3: // aHub
+				devSn = *record.PowerlinkId
+			default:
+				continue
+			}
+
+			if devSn == sn {
+				// 匹配到的站点
+				if *record.Status == -1 {
+					return *record.Status, record.Id, *record.FaultReason, nil
+				}
+				return *record.Status, record.Id, "", nil
+			}
+		}
+	}
+
+	return 0, 0, "", fmt.Errorf("未找到召唤匹配记录(devType=%d,sn=%s,startTime=%s,endTime=%s)", devType, sn, startTime, endTime)
+}

+ 15 - 2
service/jobs/getAlarmSnapshot.go

@@ -22,12 +22,25 @@ import (
 	"gorm.io/gorm"
 )
 
-type AlarmSnapshotListResponse = domain.FwhPageResponse[domain.AlarmSnapshotInfo]
+type AlarmSnapshotListResponse = domain.FwhPageResponse[[]domain.AlarmSnapshotInfo]
+
+// 获取告警快照数据
+// 从FHW后端请求快照文件后,处理并放入数据库
+// logTime 格式 YYYY-MM-DD
+func GetAlarmSnapshotData(ctx context.Context, sn, logTime string) ([]*domain.SiteAlarmSnapshotData, error) {
+	// 请求获取和解析快照数据到数据库
+	if err := GetAlarmSnapshotToDB(ctx, sn, logTime); err != nil {
+		return nil, err
+	}
+
+	// 从数据库读取数据
+	return repositories.QuerySnapshotData(ctx, sn, logTime)
+}
 
 // 获取告警快照
 // 从FHW后端请求快照文件后,处理并放入数据库
 // logTime 格式 YYYY-MM-DD
-func GetAlarmSnapshot(ctx context.Context, sn, logTime string) error {
+func GetAlarmSnapshotToDB(ctx context.Context, sn, logTime string) error {
 	// 请求快照信息列表
 	snapshotList, err := repositories.QueryAlarmSnapshotInfo(sn, logTime)
 	if err != nil {

+ 632 - 0
service/jobs/saveBatchDownloadData.go

@@ -0,0 +1,632 @@
+package jobs
+
+import (
+	"bytes"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/utils/echart"
+	"fwh-toolbox-server/utils/minio"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/go-echarts/go-echarts/v2/opts"
+	"github.com/xuri/excelize/v2"
+	"go.uber.org/zap"
+)
+
+/* 文件服务器存储结构
+ * BatchDownloadData/
+ * └── `SN`/
+ *			├── PowerFlow/		功率流图文件
+ *			├── MQTT/					MQTT通讯数据文件
+ *			├── EventAlarm/		事件日志与告警日志文件
+ *			├── AlarmSnapshot	告警快照
+ *			├── EmsLogs/			EMS日志文件
+ *			├── FaultRecord/	故障录波文件
+ *			├── UpdateLog/		升级日志数据文件
+ *			└── Settings/			设置数据文件
+ *
+ * BatchDownloadPackage/	任务打包数据
+ */
+var (
+	mainPathName             string = "BatchDownloadData"
+	subPowerFlowPathName     string = "PowerFlow"
+	subMqttPathName          string = "MQTT"
+	subEventAlarmPathName    string = "EventAlarm"
+	subAlarmSnapshotPathName string = "AlarmSnapshot"
+	subEmsLogsPathName       string = "EmsLogs"
+	subFaultRecordPathName   string = "FaultRecord"
+	subUpdateLogPathName     string = "UpdateLog"
+	subSettingsPathName      string = "Settings"
+)
+
+func stringPtr(s string) *string {
+	return &s
+}
+
+// 创建临时保存路径
+func createTempPath(pathType string) (string, error) {
+	// 创建临时目录存储下载和解压数据
+	tempDirName := fmt.Sprintf("fwhtb-batchDownload-%s-*", pathType)
+	tempDir, err := os.MkdirTemp("", tempDirName)
+	if err != nil {
+		zap.L().Error("创建临时目录失败", zap.String("pathName", tempDirName), zap.String("err", err.Error()))
+		return "", err
+	}
+	return tempDir, nil
+}
+
+// Excel 指定行-列写入数据, 行列从1开始
+func excelSetCellValue(f *excelize.File, sheetName string, col int, row int, val interface{}) error {
+	colName, err := excelize.ColumnNumberToName(col)
+	if err != nil {
+		zap.L().Error("Excel 列标转换失败", zap.String("col", strconv.Itoa(col)), zap.String("err", err.Error()))
+		return err
+	}
+
+	return f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colName, row), val)
+}
+
+// 保存功率流图到文件服务器
+// return objName
+func SaveBatchDownloadPowerFlow(sn string, dateStr string, powerFlowData dto.SitePowerFlow) (string, error) {
+	// 创建临时保存文件路径
+	tempPath, err := createTempPath("powerFlow")
+	if err != nil {
+		return "", err
+	}
+	defer os.RemoveAll(tempPath) // 程序结束时清理临时目录
+	fileName := fmt.Sprintf("PowerFlow_%s.html", strings.ReplaceAll(dateStr, "-", ""))
+	filePath := filepath.Join(tempPath, fileName)
+
+	// 数据结构转换
+	pvData := []opts.LineData{}
+	genData := []opts.LineData{}
+	gridData := []opts.LineData{}
+	aPowerData := []opts.LineData{}
+	loadData := []opts.LineData{}
+	socData := []opts.LineData{}
+	for i, t := range powerFlowData.DeviceTimeArray {
+		var pvValue []interface{}
+		if i < len(powerFlowData.PowerSunArray) {
+			pvValue = []interface{}{
+				t,
+				powerFlowData.PowerSunArray[i],
+			}
+		} else {
+			pvValue = []interface{}{
+				t,
+				nil,
+			}
+		}
+		pvData = append(pvData, opts.LineData{
+			Value: pvValue,
+		})
+
+		var genValue []interface{}
+		if i < len(powerFlowData.PowerGenArray) {
+			genValue = []interface{}{
+				t,
+				powerFlowData.PowerGenArray[i],
+			}
+		} else {
+			genValue = []interface{}{
+				t,
+				nil,
+			}
+		}
+		genData = append(genData, opts.LineData{
+			Value: genValue,
+		})
+
+		var gridValue []interface{}
+		if i < len(powerFlowData.PowerUtiArray) {
+			gridValue = []interface{}{
+				t,
+				powerFlowData.PowerUtiArray[i],
+			}
+		} else {
+			gridValue = []interface{}{
+				t,
+				nil,
+			}
+		}
+		gridData = append(gridData, opts.LineData{
+			Value: gridValue,
+		})
+
+		var aPowerValue []interface{}
+		if i < len(powerFlowData.PowerFhpArray) {
+			aPowerValue = []interface{}{
+				t,
+				powerFlowData.PowerFhpArray[i],
+			}
+		} else {
+			aPowerValue = []interface{}{
+				t,
+				nil,
+			}
+		}
+		aPowerData = append(aPowerData, opts.LineData{
+			Value: aPowerValue,
+		})
+
+		var loadValue []interface{}
+		if i < len(powerFlowData.PowerLoadArray) {
+			loadValue = []interface{}{
+				t,
+				powerFlowData.PowerLoadArray[i],
+			}
+		} else {
+			loadValue = []interface{}{
+				t,
+				nil,
+			}
+		}
+		loadData = append(loadData, opts.LineData{
+			Value: loadValue,
+		})
+
+		// SOC
+		var socValue []interface{}
+		if i < len(powerFlowData.SocArray) {
+			socValue = []interface{}{
+				t,
+				powerFlowData.SocArray[i],
+			}
+		} else {
+			socValue = []interface{}{
+				t,
+				nil,
+			}
+		}
+		socData = append(socData, opts.LineData{
+			Value: socValue,
+		})
+	}
+
+	// 生成功率流图
+	var pfChartData []echart.PowerFlowChartData = []echart.PowerFlowChartData{}
+	pfChartData = append(pfChartData, echart.PowerFlowChartData{
+		Legend: "光伏",
+		Color:  stringPtr("#F1A502"),
+		Data:   pvData,
+	})
+	pfChartData = append(pfChartData, echart.PowerFlowChartData{
+		Legend: "发电机",
+		Color:  stringPtr("#5398FF"),
+		Data:   genData,
+	})
+	pfChartData = append(pfChartData, echart.PowerFlowChartData{
+		Legend: "市电",
+		Color:  stringPtr("#BB56FD"),
+		Data:   gridData,
+	})
+	pfChartData = append(pfChartData, echart.PowerFlowChartData{
+		Legend: "aPower",
+		Color:  stringPtr("#41D187"),
+		Data:   aPowerData,
+	})
+	pfChartData = append(pfChartData, echart.PowerFlowChartData{
+		Legend: "家庭负载",
+		Color:  stringPtr("#0BF5FF"),
+		Data:   loadData,
+	})
+
+	var socChartData echart.SocChartData = echart.SocChartData{
+		Color: stringPtr("#3E7B4D"),
+		Data:  socData,
+	}
+
+	lineOpt := echart.LineChartOpt{
+		Global: echart.ChartOpt{
+			Title: "功率流图",
+		},
+		Smooth: true,
+	}
+
+	line := echart.CreatePowerFlowChart(lineOpt, pfChartData, socChartData)
+	f, _ := os.Create(filePath)
+	defer f.Close()
+	line.Render(f)
+
+	// 上传到文件服务器
+	objectName := fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subPowerFlowPathName, fileName)
+	if err = minio.UploadFile(filePath, objectName); err != nil {
+		zap.L().Error("上传PowerFlow文件失败", zap.String("path", filePath), zap.String("objectName", objectName), zap.String("err", err.Error()))
+		return "", err
+	}
+
+	return objectName, nil
+}
+
+// 保存MqttLog excel到文件服务器
+// return objName
+func SaveBatchDownloadMqttLog(sn string, cmdType string, dateStr string, data []domain.MqttLogItem) (objectName string, err error) {
+	err = nil
+	f := excelize.NewFile()
+	defer func() {
+		closeErr := f.Close()
+		if closeErr != nil {
+			zap.L().Error("Excel 文件close失败", zap.String("err", err.Error()))
+			if err == nil {
+				err = closeErr
+			}
+		}
+	}()
+
+	// 写标题行
+	f.SetCellValue("Sheet1", "A1", "Type")
+	f.SetCellValue("Sheet1", "B1", "Time")
+	f.SetCellValue("Sheet1", "C1", "Data")
+
+	// 写入数据
+	for i, d := range data {
+		f.SetCellValue("Sheet1", "A"+strconv.Itoa(i+2), d.CmdType)
+		f.SetCellValue("Sheet1", "B"+strconv.Itoa(i+2), d.LogTime)
+		f.SetCellValue("Sheet1", "C"+strconv.Itoa(i+2), d.LogData)
+	}
+
+	// 创建临时保存文件路径
+	tempPath, err := createTempPath("mqtt_" + cmdType)
+	if err != nil {
+		return
+	}
+	defer os.RemoveAll(tempPath) // 程序结束时清理临时目录
+	fileName := fmt.Sprintf("MqttLog_%s_%s.xlsx", cmdType, strings.ReplaceAll(dateStr, "-", ""))
+	filePath := filepath.Join(tempPath, fileName)
+
+	// 保存到文件
+	err = f.SaveAs(filePath)
+	if err != nil {
+		zap.L().Error("Excel文件save失败", zap.String("path", filePath), zap.String("err", err.Error()))
+		return
+	}
+
+	// 上传到文件服务器
+	objectName = fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subMqttPathName, fileName)
+	if err = minio.UploadFile(filePath, objectName); err != nil {
+		zap.L().Error("上传Excel文件失败", zap.String("path", filePath), zap.String("objectName", objectName), zap.String("err", err.Error()))
+		return
+	}
+
+	return
+}
+
+// 保存EventAlarm excel到文件服务器
+// return objName
+func SaveBatchDownloadEventAlarm(sn string, dateStr string, eventAlarmData []domain.EventAlarmItem) (objectName string, err error) {
+	err = nil
+	f := excelize.NewFile()
+	defer func() {
+		closeErr := f.Close()
+		if closeErr != nil {
+			zap.L().Error("Excel 文件close失败", zap.String("err", err.Error()))
+			if err == nil {
+				err = closeErr
+			}
+		}
+	}()
+
+	// 写标题行
+	f.SetCellValue("Sheet1", "A1", "ID")
+	f.SetCellValue("Sheet1", "B1", "Time")
+	f.SetCellValue("Sheet1", "C1", "Type")
+	f.SetCellValue("Sheet1", "D1", "AlarmCode")
+	f.SetCellValue("Sheet1", "E1", "Name")
+	f.SetCellValue("Sheet1", "F1", "StartTime")
+	f.SetCellValue("Sheet1", "G1", "EndTime")
+	f.SetCellValue("Sheet1", "H1", "Data")
+
+	for i, data := range eventAlarmData {
+		var id string
+		if data.ID == nil || *data.ID == "" {
+			id = strconv.Itoa(i + 1)
+		} else {
+			id = *data.ID
+		}
+		f.SetCellValue("Sheet1", "A"+strconv.Itoa(i+2), id)
+		f.SetCellValue("Sheet1", "B"+strconv.Itoa(i+2), data.LogTime)
+		f.SetCellValue("Sheet1", "C"+strconv.Itoa(i+2), data.LogType)
+		f.SetCellValue("Sheet1", "D"+strconv.Itoa(i+2), data.AlarmCode)
+		f.SetCellValue("Sheet1", "E"+strconv.Itoa(i+2), data.LogName)
+		f.SetCellValue("Sheet1", "F"+strconv.Itoa(i+2), data.StartTime)
+		f.SetCellValue("Sheet1", "G"+strconv.Itoa(i+2), data.EndTime)
+		f.SetCellValue("Sheet1", "H"+strconv.Itoa(i+2), data.LogData)
+	}
+
+	// 创建临时保存文件路径
+	tempPath, err := createTempPath("eventAlarm")
+	if err != nil {
+		return
+	}
+	defer os.RemoveAll(tempPath) // 程序结束时清理临时目录
+	fileName := fmt.Sprintf("EventAlarm_%s.xlsx", strings.ReplaceAll(dateStr, "-", ""))
+	filePath := filepath.Join(tempPath, fileName)
+
+	// 保存到文件
+	err = f.SaveAs(filePath)
+	if err != nil {
+		zap.L().Error("Excel文件save失败", zap.String("path", filePath), zap.String("err", err.Error()))
+		return
+	}
+
+	// 上传到文件服务器
+	objectName = fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subEventAlarmPathName, fileName)
+	if err = minio.UploadFile(filePath, objectName); err != nil {
+		zap.L().Error("上传Excel文件失败", zap.String("path", filePath), zap.String("objectName", objectName), zap.String("err", err.Error()))
+		return
+	}
+
+	return
+}
+
+// 保存AlarmSnapshot文件到文件服务器
+// return objName
+func SaveBatchDownloadAlarmSnapshot(sn string, dateStr string, alarmSnapshotData []*domain.SiteAlarmSnapshotData) (objectName string, err error) {
+	err = nil
+	f := excelize.NewFile()
+	defer func() {
+		closeErr := f.Close()
+		if closeErr != nil {
+			zap.L().Error("Excel 文件close失败", zap.String("err", err.Error()))
+			if err == nil {
+				err = closeErr
+			}
+		}
+	}()
+
+	// 写标题行
+	f.SetCellValue("Sheet1", "A1", "ID")
+	f.SetCellValue("Sheet1", "B1", "Time")
+	f.SetCellValue("Sheet1", "C1", "Data")
+
+	for i, data := range alarmSnapshotData {
+		var logTime string
+		loc, e := time.LoadLocation(data.ZoneInfo)
+		if e != nil {
+			zap.L().Warn("本地时间加载失败", zap.String("ZoneInfo", data.ZoneInfo), zap.String("err", err.Error()))
+			logTime = data.LogTime.Format(time.DateTime)
+		} else {
+			logTime = data.LogTime.In(loc).Format(time.DateTime)
+		}
+		f.SetCellValue("Sheet1", "A"+strconv.Itoa(i+2), data.ID)
+		f.SetCellValue("Sheet1", "B"+strconv.Itoa(i+2), logTime)
+		f.SetCellValue("Sheet1", "C"+strconv.Itoa(i+2), data.JSONData)
+	}
+
+	// 创建临时保存文件路径
+	tempPath, err := createTempPath("alarmSnapshot")
+	if err != nil {
+		return
+	}
+	defer os.RemoveAll(tempPath) // 程序结束时清理临时目录
+	fileName := fmt.Sprintf("EventAlarm_%s.xlsx", strings.ReplaceAll(dateStr, "-", ""))
+	filePath := filepath.Join(tempPath, fileName)
+
+	// 保存到文件
+	err = f.SaveAs(filePath)
+	if err != nil {
+		zap.L().Error("Excel文件save失败", zap.String("path", filePath), zap.String("err", err.Error()))
+		return
+	}
+
+	// 上传到文件服务器
+	objectName = fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subAlarmSnapshotPathName, fileName)
+	if err = minio.UploadFile(filePath, objectName); err != nil {
+		zap.L().Error("上传Excel文件失败", zap.String("path", filePath), zap.String("objectName", objectName), zap.String("err", err.Error()))
+		return
+	}
+
+	return
+}
+
+// 保存EmsLog文件到文件服务器
+// return objName
+func SaveBatchDownloadEmsLog(sn string, dataBytes []byte, emsLogFileName string, contentType string) (string, error) {
+	// 将 []byte 封装成 io.Reader
+	dataReader := bytes.NewReader(dataBytes)
+	// 保存到minio
+	objectName := fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subEmsLogsPathName, emsLogFileName)
+	return objectName, minio.UploadFromReader(dataReader, int64(len(dataBytes)), objectName, contentType)
+}
+
+// 保存FaultRecord文件到文件服务器
+// return objName
+func SaveBatchDownloadFaultRecord(sn string, dataBytes []byte, faultRecordFileName string, contentType string) (string, error) {
+	// 将 []byte 封装成 io.Reader
+	dataReader := bytes.NewReader(dataBytes)
+	// 保存到minio
+	objectName := fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subFaultRecordPathName, faultRecordFileName)
+	return objectName, minio.UploadFromReader(dataReader, int64(len(dataBytes)), objectName, contentType)
+}
+
+// 保存UpdateLog excel到文件服务器
+// return objName
+func SaveBatchDownloadUpdateLog(sn string, systemUpdateLogData []domain.BatchDownloadSystemUpdateLogInfo, aHubUpdateLogData []domain.BatchDownloadAHubUpdateLogInfo) (objectName string, err error) {
+	err = nil
+	f := excelize.NewFile()
+	defer func() {
+		closeErr := f.Close()
+		if closeErr != nil {
+			zap.L().Error("Excel 文件close失败", zap.String("err", err.Error()))
+			if err == nil {
+				err = closeErr
+			}
+		}
+	}()
+
+	systemUpdateSheetName := "SystemUpdate"
+	aHubUpdateSheetName := "aHubUpdate"
+
+	err = f.SetSheetName("Sheet1", systemUpdateSheetName)
+	if err != nil {
+		zap.L().Error("Excel Sheet1更名失败", zap.String("sheetName", systemUpdateSheetName), zap.String("err", err.Error()))
+		return
+	}
+
+	_, err = f.NewSheet(aHubUpdateSheetName)
+	if err != nil {
+		zap.L().Error("Excel 新增Sheet失败", zap.String("sheetName", aHubUpdateSheetName), zap.String("err", err.Error()))
+		return
+	}
+
+	// 样式集
+	// 加粗
+	// 创建加粗样式
+	styleBold, err := f.NewStyle(&excelize.Style{
+		Font: &excelize.Font{
+			Bold: true,
+		},
+	})
+	if err != nil {
+		zap.L().Error("Excel 新增Sheet失败", zap.String("err", err.Error()))
+		return
+	}
+
+	var rowIndex int = 1
+	// 写入系统升级日志
+	for _, updateItem := range systemUpdateLogData {
+		// 写标题行
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), "OrderNo")
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("B%d", rowIndex), "GateSn")
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("C%d", rowIndex), "TriggerType")
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("D%d", rowIndex), "FileName")
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("E%d", rowIndex), "UpdateTime")
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("F%d", rowIndex), "Result")
+		f.SetCellStyle(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), fmt.Sprintf("F%d", rowIndex), styleBold)
+
+		rowIndex++
+
+		// 写入项目信息内容
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), updateItem.OrderNo)
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("B%d", rowIndex), updateItem.GateSn)
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("C%d", rowIndex), updateItem.TriggerType)
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("D%d", rowIndex), updateItem.FileName)
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("E%d", rowIndex), updateItem.UpdateTime)
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("F%d", rowIndex), updateItem.Result)
+
+		rowIndex = rowIndex + 2
+
+		// Gate版本信息
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), "GateVer")
+		f.SetCellStyle(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), fmt.Sprintf("A%d", rowIndex), styleBold)
+		rowIndex++
+
+		for idx, modeVer := range updateItem.GateVerList {
+			excelSetCellValue(f, systemUpdateSheetName, (3*idx)+1, rowIndex, "Name")
+			excelSetCellValue(f, systemUpdateSheetName, (3*idx)+1, rowIndex+1, "OldVer")
+			excelSetCellValue(f, systemUpdateSheetName, (3*idx)+1, rowIndex+2, "NewVer")
+
+			excelSetCellValue(f, systemUpdateSheetName, (3*idx)+2, rowIndex, modeVer.Name)
+			excelSetCellValue(f, systemUpdateSheetName, (3*idx)+2, rowIndex+1, modeVer.OldVer)
+			excelSetCellValue(f, systemUpdateSheetName, (3*idx)+2, rowIndex+2, modeVer.NewVer)
+		}
+
+		rowIndex = rowIndex + 2
+
+		// SubDev版本信息
+		f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), "SubVer")
+		f.SetCellStyle(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), fmt.Sprintf("A%d", rowIndex), styleBold)
+		rowIndex++
+
+		for _, subVer := range updateItem.SubDevList {
+			f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("A%d", rowIndex), "SubSn")
+			f.SetCellValue(systemUpdateSheetName, fmt.Sprintf("B%d", rowIndex), subVer.DevSn)
+			rowIndex++
+
+			for idx, modeVer := range subVer.DevVerList {
+				excelSetCellValue(f, systemUpdateSheetName, (3*idx)+1, rowIndex, "Name")
+				excelSetCellValue(f, systemUpdateSheetName, (3*idx)+1, rowIndex+1, "OldVer")
+				excelSetCellValue(f, systemUpdateSheetName, (3*idx)+1, rowIndex+2, "NewVer")
+
+				excelSetCellValue(f, systemUpdateSheetName, (3*idx)+2, rowIndex, modeVer.Name)
+				excelSetCellValue(f, systemUpdateSheetName, (3*idx)+2, rowIndex+1, modeVer.OldVer)
+				excelSetCellValue(f, systemUpdateSheetName, (3*idx)+2, rowIndex+2, modeVer.NewVer)
+			}
+			rowIndex = rowIndex + 3
+		}
+
+		rowIndex++
+	}
+
+	rowIndex = 1
+	// 写入aHub升级日志
+	for _, aHubUpdateItem := range aHubUpdateLogData {
+		// 写标题行
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("A%d", rowIndex), "OrderNo")
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("B%d", rowIndex), "GateSn")
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("C%d", rowIndex), "TriggerType")
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("D%d", rowIndex), "AHubSn")
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("E%d", rowIndex), "EndTime")
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("F%d", rowIndex), "Result")
+		f.SetCellStyle(aHubUpdateSheetName, fmt.Sprintf("A%d", rowIndex), fmt.Sprintf("F%d", rowIndex), styleBold)
+
+		rowIndex++
+
+		// 写入项目信息内容
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("A%d", rowIndex), aHubUpdateItem.OrderNo)
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("B%d", rowIndex), aHubUpdateItem.GateSn)
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("C%d", rowIndex), aHubUpdateItem.TriggerType)
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("D%d", rowIndex), aHubUpdateItem.AHubSn)
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("E%d", rowIndex), aHubUpdateItem.EndTime)
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("F%d", rowIndex), aHubUpdateItem.Result)
+
+		rowIndex = rowIndex + 2
+
+		// aHub版本信息
+		f.SetCellValue(aHubUpdateSheetName, fmt.Sprintf("A%d", rowIndex), "aHubVer")
+		f.SetCellStyle(aHubUpdateSheetName, fmt.Sprintf("A%d", rowIndex), fmt.Sprintf("A%d", rowIndex), styleBold)
+		rowIndex++
+
+		for idx, modeVer := range aHubUpdateItem.DetailList {
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+1, rowIndex, "FileName")
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+1, rowIndex+1, "FileOrder")
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+1, rowIndex+2, "ResultDesc")
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+1, rowIndex+3, "OldVer")
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+1, rowIndex+4, "NewVer")
+
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+2, rowIndex, modeVer.FileName)
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+2, rowIndex+1, modeVer.FileOrder)
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+2, rowIndex+2, modeVer.ResultDesc)
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+2, rowIndex+3, modeVer.OldVer)
+			excelSetCellValue(f, aHubUpdateSheetName, (3*idx)+2, rowIndex+4, modeVer.NewVer)
+		}
+
+		rowIndex = rowIndex + 6
+	}
+
+	// 创建临时保存文件路径
+	tempPath, err := createTempPath("updateLog")
+	if err != nil {
+		return
+	}
+	defer os.RemoveAll(tempPath) // 程序结束时清理临时目录
+	fileName := fmt.Sprintf("UpdateLog_%s_%s.xlsx", sn, strings.ReplaceAll(time.Now().Format(time.DateTime), " ", "_"))
+	filePath := filepath.Join(tempPath, fileName)
+
+	// 保存到文件
+	err = f.SaveAs(filePath)
+	if err != nil {
+		zap.L().Error("Excel文件save失败", zap.String("path", filePath), zap.String("err", err.Error()))
+		return
+	}
+
+	// 上传到文件服务器
+	objectName = fmt.Sprintf("%s/%s/%s/%s", mainPathName, sn, subUpdateLogPathName, fileName)
+	if err = minio.UploadFile(filePath, objectName); err != nil {
+		zap.L().Error("上传Excel文件失败", zap.String("path", filePath), zap.String("objectName", objectName), zap.String("err", err.Error()))
+		return
+	}
+
+	return
+}
+
+// 保存Settings excel到文件服务器
+// return objName
+// func SaveBatchDownloadSettings(sn string, dateStr string, settingsData []domain.SiteSettings) (string, error) {
+// }

Файловите разлики са ограничени, защото са твърде много
+ 15 - 0
service/line.html


+ 12 - 0
service/main.go

@@ -5,8 +5,10 @@ import (
 	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/repositories/api/fwh_pro"
 	"fwh-toolbox-server/taskQueue"
 	"fwh-toolbox-server/utils/casdoor"
+	"fwh-toolbox-server/utils/minio"
 
 	// "fwh-toolbox-server/database"
 	_ "fwh-toolbox-server/docs" // swag 自动生成文档
@@ -44,6 +46,9 @@ func main() {
 	// 初始化缓存
 	cache.Init(cfg.RedisAddr)
 
+	// 初始化Minio
+	minio.InitClient(cfg)
+
 	// 初始化FwhApi
 	repositories.FwhHttpInit()
 
@@ -76,6 +81,13 @@ func main() {
 		}
 	}()
 
+	// 提前登录FWH
+	err = fwh_pro.Login()
+	if err != nil {
+		zap.L().Error("FWH login failed.", zap.String("err", err.Error()))
+		panic(err)
+	}
+
 	err = app.Listen("localhost:" + cfg.AppPort)
 	if err != nil {
 		zap.L().Error("Port listening failed.", zap.String("err", err.Error()))

+ 4 - 4
service/repositories/api/fwh_pro/fwhHttpClient.go

@@ -17,7 +17,7 @@ func FwhHttpInit() {
 }
 
 // 仅用户登录或者不需要token的请求
-func FwhHttpGetWithoutToken(url string, params url.Values) ([]byte, error) {
+func FwhHttpGetWithoutToken(url string, params url.Values) (*httpClient.Response, error) {
 	t := timeout
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
@@ -28,7 +28,7 @@ func FwhHttpGetWithoutToken(url string, params url.Values) ([]byte, error) {
 	return httpClient.HttpGet(url, params, &opt)
 }
 
-func FwhHttpGet(url string, params url.Values) ([]byte, error) {
+func FwhHttpGet(url string, params url.Values) (*httpClient.Response, error) {
 	t := timeout
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
@@ -41,7 +41,7 @@ func FwhHttpGet(url string, params url.Values) ([]byte, error) {
 }
 
 // 仅用户登录或者不需要token的请求
-func FwhHttpPostWithoutToken(url string, body any) ([]byte, error) {
+func FwhHttpPostWithoutToken(url string, body any) (*httpClient.Response, error) {
 	t := timeout
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
@@ -52,7 +52,7 @@ func FwhHttpPostWithoutToken(url string, body any) ([]byte, error) {
 	return httpClient.HttpPost(url, body, &opt)
 }
 
-func FwhHttpPost(url string, body any) ([]byte, error) {
+func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
 	t := timeout
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,

+ 120 - 0
service/repositories/api/fwh_pro/fwh_emsLog.go

@@ -0,0 +1,120 @@
+package fwh_pro
+
+import (
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/config"
+	"fwh-toolbox-server/domain"
+	"net/url"
+	"strconv"
+)
+
+// 召唤EMS日志
+func queryCallEmsLog(sn string, dateStr string, logType int) ([]byte, error) {
+	// 要发送的表单数据
+	// form := url.Values{}
+	// form.Set("gatewayId", sn)                  // SN
+	// form.Set("logEqSn", sn)                    // SN
+	// form.Set("logId", sn)                      // SN
+	// form.Set("logTime", dateStr)               // 日志时间
+	// form.Set("logType", strconv.Itoa(logType)) // 日志类型 4-IBG运行日志 7-电池日志 9-设备系统资源监控日志 10-4G日志 11-AWS日志 12-主进程日志
+	// form.Set("lang", "ZH_CN")
+
+	form := map[string]interface{}{
+		"gatewayId": sn,
+		"logEqSn":   sn,
+		"logId":     sn,
+		"logTime":   dateStr,
+		"logType":   logType,
+		"lang":      "ZH_CN",
+	}
+
+	url := fmt.Sprintf("https://%s/hes-gateway/manage/logFileCall", config.FWH_ENERGY)
+	response, err := FwhHttpPost(url, form)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 召唤EMS日志
+func QueryCallEmsLog(sn string, dateStr string, logType int) error {
+	// 查询告警快照数据
+	resByte, err := queryCallEmsLog(sn, dateStr, logType)
+	if err != nil {
+		return fmt.Errorf("[QueryCallEmsLog] %s", err.Error())
+	}
+
+	var callRes domain.FwhPageResponse[bool]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &callRes)
+	if jsonErr != nil {
+		return fmt.Errorf("[QueryCallEmsLog] %s", jsonErr)
+	}
+
+	if callRes.Code != 200 {
+		return fmt.Errorf("[QueryCallEmsLog] 查询失败 code: %d, message: %s", callRes.Code, callRes.Message)
+	}
+
+	if !callRes.Result {
+		return fmt.Errorf("[QueryCallEmsLog] 查询失败 code: %d, result: %v, message: %s", callRes.Code, callRes.Result, callRes.Message)
+	}
+
+	return nil
+}
+
+// 查询EMS日志下载地址
+func queryEmsLogCallRecord(sn string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("linkId", sn) // SN
+	params.Add("pageIndex", strconv.Itoa(1))
+	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/iotDeviceFileRecords", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询EMS日志下载地址
+func QueryEmsLogCallRecord(sn string, dateStr string, logType int) (*domain.EmsLogRecordInfo, error) {
+	// 查询告警快照数据
+	resByte, err := queryEmsLogCallRecord(sn)
+	if err != nil {
+		return nil, fmt.Errorf("[QueryEmsLogCallRecord] %s", err.Error())
+	}
+
+	var callRecordRes domain.FwhPageResponse[[]domain.EmsLogRecordInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &callRecordRes)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QueryEmsLogCallRecord] %s", jsonErr)
+	}
+
+	if callRecordRes.Code != 200 {
+		return nil, fmt.Errorf("[QueryEmsLogCallRecord] 查询失败 code: %d, message: %s", callRecordRes.Code, callRecordRes.Message)
+	}
+
+	for _, record := range callRecordRes.Result {
+		if record.LogTime == dateStr && record.CodeType == logType {
+			return &record, nil
+		}
+	}
+
+	return nil, fmt.Errorf("[QueryEmsLogCallRecord] 没有召唤日志")
+}
+
+// 请求下载EMS日志文件
+func QueryDownloadEmsLogFile(filePath string) ([]byte, error) {
+	reqUrl := fmt.Sprintf("https://%s/hes-upload/v1/file/download/"+filePath, config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, url.Values{})
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}

+ 19 - 5
service/repositories/api/fwh_pro/fwh_eventAlarmLog.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -15,8 +16,9 @@ import (
 type EventAlarmType int
 
 const (
-	Event EventAlarmType = iota // 0
-	Alarm                       // 1
+	All   EventAlarmType = iota // 0
+	Event                       // 1
+	Alarm                       // 2
 )
 
 type EventAlarmListResponse struct {
@@ -40,7 +42,12 @@ func queryEventAndAlarmLog(sn string, params url.Values) ([]byte, error) {
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("land", "ZH_CN")
 
-	return FwhHttpGet("https://energy.franklinwh.com/hes-gateway/manage/iotDeviceRunLogs", params)
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/iotDeviceRunLogs", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 查询所有事件告警
@@ -129,6 +136,8 @@ func queryEventOrAlarmListOnePageExec(sn string, startTime, endTime time.Time, t
 		resByte, err = queryEventLog(sn, startTimeStr, endTimeStr)
 	case Alarm:
 		resByte, err = queryAlarmLog(sn, startTimeStr, endTimeStr, "")
+	default:
+		resByte, err = queryAllEventAlarm(sn, startTimeStr, endTimeStr)
 	}
 	if err != nil {
 		return nil, err
@@ -213,7 +222,12 @@ func queryAlarmSnapshotInfo(sn, logTime string, page int) ([]byte, error) {
 	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("land", "ZH_CN")
 
-	return FwhHttpGet("https://energy.franklinwh.com/hes-gateway/manage/selectAlarmSnapshotList", params)
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/selectAlarmSnapshotList", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 func QueryAlarmSnapshotInfo(sn, logTime string) ([]domain.AlarmSnapshotInfo, error) {
@@ -227,7 +241,7 @@ func QueryAlarmSnapshotInfo(sn, logTime string) ([]domain.AlarmSnapshotInfo, err
 			return nil, fmt.Errorf("[QueryAlarmSnapshotInfo] %s", err.Error())
 		}
 
-		var alarmSnapshotRes domain.FwhPageResponse[domain.AlarmSnapshotInfo]
+		var alarmSnapshotRes domain.FwhPageResponse[[]domain.AlarmSnapshotInfo]
 		// 反序列化(unmarshal)
 		jsonErr := json.Unmarshal(resByte, &alarmSnapshotRes)
 		if jsonErr != nil {

+ 245 - 0
service/repositories/api/fwh_pro/fwh_faultRecord.go

@@ -0,0 +1,245 @@
+package fwh_pro
+
+import (
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/config"
+	"fwh-toolbox-server/domain"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+// 查询故障录波日志
+func queryFaultRecordList(sn string, startTime string, endTime string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("deviseSerialNumber", sn)       // SN
+	params.Add("earliestStartTime", startTime) // 时间范围 - 开始时间 format YYYY-MM-DD hh:mm:ss
+	params.Add("earliestEndTime", endTime)     // 时间范围 - 结束时间 format YYYY-MM-DD hh:mm:ss
+	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("lang", "ZH_CN")
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/faultRecordingLst", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询故障录波日志
+func QueryFaultRecordList(sn string, startTime string, endTime string) ([]domain.FaultRecordInfo, error) {
+	resByte, err := queryFaultRecordList(sn, startTime, endTime)
+	if err != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordList] %s", err.Error())
+	}
+
+	var faultRecordResp domain.FwhPageResponse[[]domain.FaultRecordInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &faultRecordResp)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordList] %s", jsonErr)
+	}
+
+	if faultRecordResp.Code != 200 {
+		return nil, fmt.Errorf("[QueryFaultRecordList] 查询失败 code: %d, message: %s", faultRecordResp.Code, faultRecordResp.Message)
+	}
+
+	return faultRecordResp.Result, nil
+}
+
+// 查询设备可召唤数据列表 - 仅自动录播
+func queryFaultRecordCallableList(devType int, sn string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("deviceSerial", sn) // aGate or aPower or aHub SN
+	params.Add("callType", strconv.Itoa(devType))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/getWaveRecordingCatalog", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询设备可召唤数据列表 - 仅自动录播
+// devType: 0-aPower 1-aGate 2-MPPT 3-aHub
+func QueryFaultRecordCallableList(devType int, sn string) ([]domain.FaultRecordCallableInfo, error) {
+	resByte, err := queryFaultRecordCallableList(devType, sn)
+	if err != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordCallableList] %s", err.Error())
+	}
+
+	var faultRecordCallableResp domain.FwhPageResponse[[]domain.FaultRecordCallableInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &faultRecordCallableResp)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordCallableList] %s", jsonErr)
+	}
+
+	if faultRecordCallableResp.Code != 200 {
+		// 处理未查到数据的情况
+		if faultRecordCallableResp.Code == 400 && faultRecordCallableResp.Message == "故障目录为空" {
+			return []domain.FaultRecordCallableInfo{}, nil
+		}
+		return nil, fmt.Errorf("[QueryFaultRecordCallableList] 查询失败 code: %d, message: %s", faultRecordCallableResp.Code, faultRecordCallableResp.Message)
+	}
+
+	return faultRecordCallableResp.Result, nil
+}
+
+// 召唤故障录波日志
+func queryCellFaultRecord(devType int, sn string, indexList []int) ([]byte, error) {
+	if len(indexList) < 1 {
+		return nil, fmt.Errorf("未选择召唤数据idx")
+	}
+
+	// 将 []int 转为 []string
+	indexListStr := make([]string, len(indexList))
+	for i, v := range indexList {
+		indexListStr[i] = strconv.Itoa(v)
+	}
+
+	// 要发送的表单数据
+	form := url.Values{}
+	form.Set("deviceId", sn)                           // aGate or aPower or aHub SN
+	form.Set("index", strings.Join(indexListStr, ",")) // 要召唤数据的idx
+	form.Set("callType", strconv.Itoa(devType))
+	form.Set("triggerType", strconv.Itoa(2)) // 触发类型,目前仅支持2-自动
+	form.Set("lang", "ZH_CN")
+
+	url := fmt.Sprintf("https://%s/hes-gateway/manage/callFaultRecording", config.FWH_ENERGY)
+	response, err := FwhHttpPost(url, form)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 召唤故障录波日志
+func QueryCellFaultRecord(devType int, sn string, indexList []int) error {
+	resByte, err := queryCellFaultRecord(devType, sn, indexList)
+	if err != nil {
+		return fmt.Errorf("[QueryCellFaultRecord] %s", err.Error())
+	}
+
+	var cellFaultRecordResp domain.FwhPageResponse[interface{}]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &cellFaultRecordResp)
+	if jsonErr != nil {
+		return fmt.Errorf("[QueryCellFaultRecord] %s", jsonErr)
+	}
+
+	if cellFaultRecordResp.Code != 200 {
+		return fmt.Errorf("[QueryCellFaultRecord] 查询失败 code: %d, message: %s", cellFaultRecordResp.Code, cellFaultRecordResp.Message)
+	}
+
+	return nil
+}
+
+// 查询故障录波召唤记录
+func queryFaultRecordCallRecordList() ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("pageSize", strconv.Itoa(50))
+	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/lstFaultWaveCall", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询故障录波召唤记录
+func QueryFaultRecordCallRecordList() ([]domain.FaultRecordInfo, error) {
+	resByte, err := queryFaultRecordCallRecordList()
+	if err != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordCallRecordList] %s", err.Error())
+	}
+
+	var faultRecordCallRecordResp domain.FwhPageResponse[[]domain.FaultRecordInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &faultRecordCallRecordResp)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordCallRecordList] %s", jsonErr)
+	}
+
+	if faultRecordCallRecordResp.Code != 200 {
+		return nil, fmt.Errorf("[QueryFaultRecordCallRecordList] 查询失败 code: %d, message: %s", faultRecordCallRecordResp.Code, faultRecordCallRecordResp.Message)
+	}
+
+	return faultRecordCallRecordResp.Result, nil
+}
+
+// 查询故障录波子记录
+func queryFaultRecordSubList(id int64) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("faultId", strconv.FormatInt(id, 10))
+	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/lstSubFaultWave", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询故障录波子记录
+func QueryFaultRecordSubList(id int64) ([]domain.FaultRecordSubInfo, error) {
+	resByte, err := queryFaultRecordSubList(id)
+	if err != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordSubList] %s", err.Error())
+	}
+
+	var faultRecordSubResp domain.FwhPageResponse[[]domain.FaultRecordSubInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &faultRecordSubResp)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QueryFaultRecordSubList] %s", jsonErr)
+	}
+
+	if faultRecordSubResp.Code != 200 {
+		return nil, fmt.Errorf("[QueryFaultRecordSubList] 查询失败 code: %d, message: %s", faultRecordSubResp.Code, faultRecordSubResp.Message)
+	}
+
+	return faultRecordSubResp.Result, nil
+}
+
+// 请求下载录波数据
+// return FileName, FileData, Error
+func QueryDownloadFaultRecordData(faultId int64, subRecordId int64) (string, []byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("faultId", strconv.FormatInt(faultId, 10))
+	params.Add("id", strconv.FormatInt(subRecordId, 10))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprint("https://%s/hes-gateway/manage/exportSubFaultWave", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return "", nil, err
+	}
+
+	// 从 Content-Disposition 提取真实文件名
+	filename := "download.xls"
+	if cd := response.Header.Get("Content-Disposition"); cd != "" {
+		if strings.Contains(cd, "filename=") {
+			name := strings.Split(cd, "filename=")[1]
+			name = strings.Trim(name, "\"")
+			name, _ = url.QueryUnescape(name) // 处理中文名
+			filename = name
+		}
+	}
+
+	return filename, response.Body, nil
+}

+ 6 - 4
service/repositories/api/fwh_pro/fwh_login.go

@@ -4,6 +4,7 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"fwh-toolbox-server/config"
 	"strings"
 	"time"
 
@@ -50,13 +51,14 @@ type JwtPayload struct {
 func Login() error {
 	// 要发送的 JSON
 	payload := LoginPayload{
-		LoginName: "FMU006",
-		Password:  "5583ae167cad1dbfb0b9f3d5312bebdf",
+		LoginName: config.FWH_USER,
+		Password:  config.FWH_PASSWORD,
 		Type:      1,
 		Lang:      "ZH_CN",
 	}
 
-	body, err := FwhHttpPostWithoutToken("https://oss.franklinwh.com/api-user/user/login", payload)
+	url := fmt.Sprintf("https://%s/api-user/user/login", config.FWH_OSS)
+	response, err := FwhHttpPostWithoutToken(url, payload)
 	if err != nil {
 		return err
 	}
@@ -64,7 +66,7 @@ func Login() error {
 
 	var loginRes LoginResponse
 	// 反序列化(unmarshal)
-	jsonErr := json.Unmarshal(body, &loginRes)
+	jsonErr := json.Unmarshal(response.Body, &loginRes)
 	if jsonErr != nil {
 		return jsonErr
 	}

+ 7 - 1
service/repositories/api/fwh_pro/fwh_mqttLog.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -53,7 +54,12 @@ func queryMqttLog(param QueryMqttLogParam) ([]byte, error) {
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	return FwhHttpGet("https://energy.franklinwh.com/hes-gateway/manage/iotMqttCommlogs", params)
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/iotMqttCommlogs", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 func queryMqttLogListOnePage(sn string, cmdType domain.MqttCmdType, startTime, endTime time.Time) ([]domain.MqttLogItem, error) {

+ 8 - 1
service/repositories/api/fwh_pro/fwh_powerFlow.go

@@ -3,6 +3,8 @@ package fwh_pro
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/dto"
 	"net/url"
 	"time"
@@ -15,7 +17,12 @@ func querySitePowerFlow(sn string, day time.Time) ([]byte, error) {
 	params.Add("gatewayId", sn)
 	params.Add("dayTime", day.Format(time.DateOnly)) // 会自动 URL 编码
 
-	return FwhHttpGet("https://energy.franklinwh.com/api-energy/power/getFhpPowerByDayOld", params)
+	reqUrl := fmt.Sprintf("https://%s/api-energy/power/getFhpPowerByDayOld", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 从fwh_pro获取站点功率流

+ 8 - 1
service/repositories/api/fwh_pro/fwh_realtimeAalrm.go

@@ -3,6 +3,8 @@ package fwh_pro
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/dto"
 	"net/url"
 )
@@ -14,7 +16,12 @@ func querySiteAlarmList(sn string) ([]byte, error) {
 	params.Add("gatewayId", sn)
 	params.Add("land", "ZH_CN")
 
-	return FwhHttpGet("https://energy.franklinwh.com/hes-gateway/manage/getEquipmentAlarm", params)
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/getEquipmentAlarm", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 从fwh_pro获取站点告警列表

+ 8 - 1
service/repositories/api/fwh_pro/fwh_shutdownList.go

@@ -2,6 +2,8 @@ package fwh_pro
 
 import (
 	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -36,7 +38,12 @@ func querySiteShutdownListOnePage(date string, country int, page int) ([]byte, e
 	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("land", "ZH_CN")
 
-	return FwhHttpGet("https://energy.franklinwh.com/api-inspect/fault/listShutdownGateway", params)
+	reqUrl := fmt.Sprintf("https://%s/api-inspect/fault/listShutdownGateway", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 查询Shutdown站点列表

+ 68 - 3
service/repositories/api/fwh_pro/fwh_siteInfo.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/dto"
 	"net/url"
 	"strconv"
@@ -59,7 +60,12 @@ func querySiteBaseInfoList(sn []string, pageSize uint, pageNum uint) ([]byte, er
 	form.Set("pageNum", fmt.Sprintf("%d", payload.PageNum))
 	form.Set("lang", payload.Lang)
 
-	return FwhHttpPost("https://energy.franklinwh.com/hes-gateway/manage/selectIotFirmwareGatewayList", form)
+	url := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotFirmwareGatewayList", config.FWH_ENERGY)
+	response, err := FwhHttpPost(url, form)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 从fwh_pro获取站点基本信息表
@@ -95,7 +101,12 @@ func querySiteInfoList(sn *string, pageSize uint, pageNum uint) ([]byte, error)
 	params.Add("pageNum", fmt.Sprintf("%d", pageNum))
 	params.Add("lang", "ZH_CN")
 
-	return FwhHttpGet("https://energy.franklinwh.com/hes-gateway/manage/selectIotGatewayList", params)
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotGatewayList", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 从fwh_pro获取站点基本信息表
@@ -119,6 +130,55 @@ func QuerySiteInfoList(para dto.SiteInfoPara) (dto.SiteInfoResponse, error) {
 	return siteInfoRes, nil
 }
 
+// 查询站点列表(通用)
+func querySiteList(
+	search string,
+	searchFlag int,
+	pageSize uint,
+	pageNum uint,
+) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("search", search)
+	params.Add("searchFlag", fmt.Sprintf("%d", searchFlag)) // 0-全部 1-aGate名称 11-aGate序列号 2-aPower序列号 7-EMS_MAIN版本 8-EMS_DSP版本 10-aPower子版本号 13-协议版本 14-硬件版本 16-备注
+	params.Add("pageSize", fmt.Sprintf("%d", pageSize))
+	params.Add("pageNum", fmt.Sprintf("%d", pageNum))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotGatewayList", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 从fwh_pro获取站点列表
+func QuerySiteList(
+	search string,
+	searchFlag int,
+	pageSize uint,
+	pageNum uint,
+) (dto.SiteInfoResponse, error) {
+	resByte, err := querySiteList(search, searchFlag, pageSize, pageNum)
+	if err != nil {
+		return dto.SiteInfoResponse{}, err
+	}
+	var siteInfoRes dto.SiteInfoResponse
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &siteInfoRes)
+	if jsonErr != nil {
+		return dto.SiteInfoResponse{}, jsonErr
+	}
+
+	if siteInfoRes.Code == 401 {
+		// TODO 尝试重新登录
+		return dto.SiteInfoResponse{}, errors.New("certification expired")
+	}
+
+	return siteInfoRes, nil
+}
+
 // 查询站点工作模式信息
 func querySiteModeInfo(sn string) ([]byte, error) {
 	// 要发送的 JSON
@@ -134,7 +194,12 @@ func querySiteModeInfo(sn string) ([]byte, error) {
 	form.Set("showType", strconv.Itoa(payload.ShowType))
 	form.Set("lang", payload.Lang)
 
-	return FwhHttpPost("https://energy.franklinwh.com/hes-gateway/manage/tou/getGatewayTouListV2", form)
+	url := fmt.Sprintf("https://%s/hes-gateway/manage/tou/getGatewayTouListV2", config.FWH_ENERGY)
+	response, err := FwhHttpPost(url, form)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
 }
 
 // 从fwh_pro获取站点工作模式信息

+ 3 - 0
service/repositories/api/fwh_pro/fwh_sttings.go

@@ -0,0 +1,3 @@
+package fwh_pro
+
+// TODO 暂不实现

+ 124 - 0
service/repositories/api/fwh_pro/fwh_update.go

@@ -0,0 +1,124 @@
+package fwh_pro
+
+import (
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/config"
+	"fwh-toolbox-server/domain"
+	"net/url"
+	"strconv"
+)
+
+// 查询系统升级记录
+func querySystemUpdateLog(sn string) ([]byte, error) {
+	// 要发送的表单数据
+	form := url.Values{}
+	form.Set("optType", "1")  // 1-系统升级记录 2-下载记录(分步升级相关)
+	form.Set("gatewayId", sn) // SN
+	form.Set("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	form.Set("pageNum", "1")
+
+	url := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotFirmwareOrderList", config.FWH_ENERGY)
+	response, err := FwhHttpPost(url, form)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询系统升级记录
+func QuerySystemUpdateLog(sn string) ([]domain.SystemUpdateRecordInfo, error) {
+	resByte, err := querySystemUpdateLog(sn)
+	if err != nil {
+		return nil, fmt.Errorf("[QuerySystemUpdateLog] %s", err.Error())
+	}
+
+	var systemUpdateLog domain.FwhPageResponse[[]domain.SystemUpdateRecordInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &systemUpdateLog)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QuerySystemUpdateLog] %s", jsonErr)
+	}
+
+	if systemUpdateLog.Code != 200 {
+		return nil, fmt.Errorf("[QuerySystemUpdateLog] 查询失败 code: %d, message: %s", systemUpdateLog.Code, systemUpdateLog.Message)
+	}
+
+	return systemUpdateLog.Result, nil
+}
+
+// 查询系统升级记录详细信息
+func querySystemUpdateLogDetail(orderId int64) ([]byte, error) {
+	// 要发送的表单数据
+	form := url.Values{}
+	form.Set("optType", "1")                            // 1-系统升级记录 2-下载记录(分步升级相关)
+	form.Set("orderId", strconv.FormatInt(orderId, 10)) // 升级单ID
+	form.Set("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	form.Set("pageNum", "1")
+
+	url := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotFirmwareOrderDetailList", config.FWH_ENERGY)
+	response, err := FwhHttpPost(url, form)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询系统升级记录详细信息
+func QuerySystemUpdateLogDetail(orderId int64) ([]domain.SystemUpdateDetailInfo, error) {
+	resByte, err := querySystemUpdateLogDetail(orderId)
+	if err != nil {
+		return nil, fmt.Errorf("[QuerySystemUpdateLogDetail] %s", err.Error())
+	}
+
+	var systemUpdateLogDetail domain.FwhPageResponse[[]domain.SystemUpdateDetailInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &systemUpdateLogDetail)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QuerySystemUpdateLogDetail] %s", jsonErr)
+	}
+
+	if systemUpdateLogDetail.Code != 200 {
+		return nil, fmt.Errorf("[QuerySystemUpdateLogDetail] 查询失败 code: %d, message: %s", systemUpdateLogDetail.Code, systemUpdateLogDetail.Message)
+	}
+
+	return systemUpdateLogDetail.Result, nil
+}
+
+// 查询aHub升级记录
+func queryAHubUpdateLog(sn string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("gatewayId", sn) // SN
+	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("lang", "ZH_CN")
+
+	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/listAhubFirmwareOrder", config.FWH_ENERGY)
+	response, err := FwhHttpGet(reqUrl, params)
+	if err != nil {
+		return nil, err
+	}
+	return response.Body, nil
+}
+
+// 查询aHub升级记录
+func QueryAHubUpdateLog(sn string) ([]domain.AHubUpdateRecordInfo, error) {
+	resByte, err := queryAHubUpdateLog(sn)
+	if err != nil {
+		return nil, fmt.Errorf("[QueryAHubUpdateLog] %s", err.Error())
+	}
+
+	var aHubUpdateLog domain.FwhPageResponse[[]domain.AHubUpdateRecordInfo]
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &aHubUpdateLog)
+	if jsonErr != nil {
+		return nil, fmt.Errorf("[QueryAHubUpdateLog] %s", jsonErr)
+	}
+
+	if aHubUpdateLog.Code != 200 {
+		return nil, fmt.Errorf("[QueryAHubUpdateLog] 查询失败 code: %d, message: %s", aHubUpdateLog.Code, aHubUpdateLog.Message)
+	}
+
+	return aHubUpdateLog.Result, nil
+}

+ 430 - 0
service/repositories/mongo/batchDownloadRepo.go

@@ -0,0 +1,430 @@
+package mongo
+
+import (
+	"context"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// 从数据库获取批量下载数据列表
+func GetBatchDownloadManageList(page, size uint, sn, progress *string) (*dto.ResPage[domain.BatchDownloadInfo], error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	filter := bson.M{}
+	// 条件
+	if sn != nil {
+		filter["sn"] = *sn
+	}
+	if progress != nil {
+		filter["progress"] = *progress
+	}
+
+	findOptions := options.Find().
+		SetProjection(bson.M{
+			"_id":         1,
+			"sn":          1,
+			"description": 1,
+			"timePeriod":  1,
+			"options":     1,
+			"progress":    1,
+			"failReasons": 1,
+		}).
+		SetSkip(int64((page - 1) * size)).
+		SetLimit(int64(size))
+
+	cursor, err := collection.Find(ctx, filter, findOptions)
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+
+	var configList []domain.BatchDownloadInfo
+	for cursor.Next(ctx) {
+		var cfg domain.BatchDownloadInfo
+		if err := cursor.Decode(&cfg); err != nil {
+			return nil, err
+		}
+		configList = append(configList, cfg)
+	}
+
+	// 查询总数
+	total, err := collection.CountDocuments(ctx, filter)
+	if err != nil {
+		return nil, err
+	}
+
+	res := dto.ResPage[domain.BatchDownloadInfo]{
+		Data:     configList,
+		PageSize: size,
+		PageNum:  page,
+		Total:    uint(total),
+	}
+
+	return &res, nil
+}
+
+// 从数据库获取一条批量下载数据配置
+func GetBatchDownloadConfig(id primitive.ObjectID) (*domain.BatchDownloadConfig, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+	var cfg domain.BatchDownloadConfig
+	err := collection.FindOne(ctx, bson.M{"_id": id}).Decode(&cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	return &cfg, nil
+}
+
+// 插入一条批量下载配置到数据库
+func InsertBatchDownloadConfig(writeConfig domain.BatchDownloadConfigWrite) (primitive.ObjectID, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	config := domain.BatchDownloadConfig{
+		Sn:          writeConfig.Sn,
+		Description: writeConfig.Description,
+		TimePeriod:  writeConfig.TimePeriod,
+		Options:     writeConfig.Options,
+		Progress:    domain.ProgressPending,
+		Data:        map[string]domain.BatchDownloadData{},
+		FailReasons: []string{},
+	}
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	res, err := collection.InsertOne(ctx, config)
+	if err != nil {
+		return primitive.ObjectID{}, err
+	}
+
+	return res.InsertedID.(primitive.ObjectID), nil
+}
+
+// 更新一条批量下载描述描述到数据库
+func UpdateBatchDownloadDescription(id primitive.ObjectID, desc string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新全部字段
+		"$set": bson.M{
+			"description": desc,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 更新一条批量下载结果到数据库
+func UpdateBatchDownloadResult(id primitive.ObjectID, result domain.Progress, reasons []string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新非progress字段
+		"$set": bson.M{
+			"progress":    result,
+			"failReasons": reasons,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 删除一条批量下载配置
+func DeleteBatchDownloadConfig(id primitive.ObjectID) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	filter := bson.M{"_id": id} // 删除条件
+	collection := database.GetCollection("BatchDownloadConfig")
+	_, err := collection.DeleteOne(ctx, filter)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据
+func InsertBatchDownloadData(id primitive.ObjectID, data domain.BatchDownloadData) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data": data,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 获取下载数据包路径
+func GetBatchDownloadData(id primitive.ObjectID) (map[string]domain.BatchDownloadData, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+	var cfg domain.BatchDownloadConfig
+	err := collection.FindOne(ctx, bson.M{"_id": id}).Decode(&cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	return cfg.Data, nil
+}
+
+// 插入批量下载结果数据 - PowerFlow
+func InsertBatchDownloadPathPowerFlow(id primitive.ObjectID, sn string, date string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".powerFlow." + date: path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - MqttLogs
+func InsertBatchDownloadPathMqttLogs(id primitive.ObjectID, sn string, date string, logType string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".mqttLogs." + date + "." + logType: path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - EventAlarm
+func InsertBatchDownloadPathEventAlarm(id primitive.ObjectID, sn string, date string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".eventAlarm." + date: path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - AlarmSnapshot
+func InsertBatchDownloadPathAlarmSnapshot(id primitive.ObjectID, sn string, date string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".alarmSnapshot." + date: path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - EmsLogs
+func InsertBatchDownloadPathEmsLogs(id primitive.ObjectID, sn string, date string, logType string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".emsLogs." + date + "." + logType: path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - FaultRecord
+func InsertBatchDownloadPathFaultRecord(id primitive.ObjectID, sn string, fileName string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".faultRecord." + fileName: path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - UpdateLog
+func InsertBatchDownloadPathUpdateLog(id primitive.ObjectID, sn string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".updateLog": path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 插入批量下载结果数据 - Settings
+func InsertBatchDownloadPathSettings(id primitive.ObjectID, sn string, path string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("BatchDownloadConfig")
+
+	// _, err := collection.InsertOne(ctx, config)
+	filter := bson.M{"_id": id} // 按ID匹配
+	update := bson.M{
+		// 如果存在就更新data字段
+		"$set": bson.M{
+			"data." + sn + ".settings": path,
+		},
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 22 - 0
service/repositories/pgsql/alarmSnapshot/siteAlarmSnapshotData.go

@@ -12,6 +12,28 @@ import (
 
 type SnapshotData = model_alarm_snapshot.SiteAlarmSnapshotData
 
+func QuerySnapshotData(c context.Context, sn string, logTime string) ([]*SnapshotData, error) {
+	ctx, cancel := context.WithTimeout(c, 5*time.Second)
+	defer cancel()
+
+	start, err := time.Parse(time.DateOnly, logTime)
+	if err != nil {
+		return nil, err
+	}
+	end := start.Add(24 * time.Hour)
+
+	SnapshotDataModel := query_alarm_snapshot.SiteAlarmSnapshotData
+	data, err := SnapshotDataModel.WithContext(ctx).
+		Where(
+			SnapshotDataModel.Sn.Eq(sn),
+			SnapshotDataModel.LogTime.Gte(start),
+			SnapshotDataModel.LogTime.Lt(end),
+		).
+		Find()
+
+	return data, err
+}
+
 func InsertSnapshotData(c context.Context, data *SnapshotData) error {
 	ctx, cancel := context.WithTimeout(c, 5*time.Second)
 	defer cancel()

+ 11 - 9
service/repositories/redis/authRepo.go

@@ -3,17 +3,18 @@ package redis
 import (
 	"context"
 	"errors"
-	"fmt"
 	"fwh-toolbox-server/cache"
 	"fwh-toolbox-server/domain"
 	"strconv"
 	"time"
 
+	"go.uber.org/zap"
 	"golang.org/x/oauth2"
 )
 
 var (
-	ctx = context.Background()
+	authCtx       = context.Background()
+	authKeyPrefix = "fwhtoolbox:session:"
 )
 
 type SessionInfo struct {
@@ -23,7 +24,7 @@ type SessionInfo struct {
 }
 
 func StoreSession(sid string, token *oauth2.Token) error {
-	key := fmt.Sprintf("fwhtoolbox:session:%s", sid)
+	key := authKeyPrefix + sid
 	exp := time.Now().Add(domain.SessionTTL)
 	accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
 	fields := map[string]interface{}{
@@ -31,16 +32,17 @@ func StoreSession(sid string, token *oauth2.Token) error {
 		"access_token":  token.AccessToken,
 		"access_exp":    accessExp,
 	}
-	if err := cache.HSet(ctx, key, fields); err != nil {
+	if err := cache.HSet(authCtx, key, fields); err != nil {
+		zap.L().Error("HSet failed", zap.String("key", key), zap.Any("fields", fields), zap.String("err", err.Error()))
 		return err
 	}
-	_, err := cache.ExpireAt(ctx, key, exp)
+	_, err := cache.ExpireAt(authCtx, key, exp)
 	return err
 }
 
 func GetSession(sid string) (*SessionInfo, error) {
-	key := fmt.Sprintf("session:%s", sid)
-	res, err := cache.HGetAll(ctx, key)
+	key := authKeyPrefix + sid
+	res, err := cache.HGetAll(authCtx, key)
 	if err != nil {
 		return nil, err
 	}
@@ -65,6 +67,6 @@ func GetSession(sid string) (*SessionInfo, error) {
 }
 
 func DeleteSession(sid string) error {
-	key := fmt.Sprintf("session:%s", sid)
-	return cache.Delete(ctx, key)
+	key := authKeyPrefix + sid
+	return cache.Delete(authCtx, key)
 }

+ 182 - 0
service/repositories/redis/batchDownloadRepo.go

@@ -0,0 +1,182 @@
+package redis
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"strconv"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+var (
+	bachDownloadCtx       = context.Background()
+	bachDownloadKeyPrefix = "fwhtoolbox:batchDownload:"
+
+	callFileKeyPrefix = bachDownloadKeyPrefix + "callFile:"
+)
+
+func CreateCallFileTaskList(id string, list map[string]domain.BatchDownloadFileTaskInfo) error {
+	// 保存数据
+	var keyList []string
+	for key, val := range list {
+		rdsKey := callFileKeyPrefix + id + ":" + key
+		exp := time.Now().Add(7 * 24 * time.Hour)
+		fields := map[string]interface{}{
+			"sn":          val.Sn,
+			"date":        val.Date,
+			"fileType":    val.FileType,
+			"fileSubType": val.FileSupType,
+			"reason":      val.Reason,
+		}
+		if err := cache.HSet(bachDownloadCtx, rdsKey, fields); err != nil {
+			return err
+		}
+		_, err := cache.ExpireAt(bachDownloadCtx, rdsKey, exp)
+		if err != nil {
+			return err
+		}
+		keyList = append(keyList, key)
+	}
+
+	// 保存key list到standby列表
+	rdsStandbyKey := callFileKeyPrefix + id + ":standby"
+	members := make([]interface{}, len(keyList))
+	for i, v := range keyList {
+		members[i] = v
+	}
+	if err := cache.SAdd(bachDownloadCtx, rdsStandbyKey, members); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func getCallFileTaskList(rdsKey string, listName string) (map[string]domain.BatchDownloadFileTaskInfo, error) {
+	// 提取key list
+	subKeySet, err := cache.SMembers(bachDownloadCtx, rdsKey+":"+listName)
+	if err != nil {
+		return nil, err
+	}
+
+	// 提取数据
+	dataList := map[string]domain.BatchDownloadFileTaskInfo{}
+	for _, key := range subKeySet {
+		subRdsKey := rdsKey + ":" + key
+		res, err := cache.HGetAll(bachDownloadCtx, subRdsKey)
+		if err != nil {
+			return nil, err
+		}
+		if len(res) == 0 {
+			return nil, fmt.Errorf("key:%s - No Data", subRdsKey)
+		}
+		devTypeStr := res["devType"]
+		var devType int
+		if devTypeStr != "" {
+			devType, err = strconv.Atoi(devTypeStr)
+			if err != nil {
+				return nil, err
+			}
+		}
+		sn := res["sn"]
+		date := res["date"]
+		fileType := res["fileType"]
+		fileSubType := res["fileSubType"]
+		resultStr := res["result"]
+		reason := res["reason"]
+		var result int
+		if resultStr != "" {
+			result, err = strconv.Atoi(resultStr)
+			if err != nil {
+				return nil, err
+			}
+		}
+		dataList[key] = domain.BatchDownloadFileTaskInfo{
+			DevType:     devType,
+			Sn:          sn,
+			Date:        date,
+			FileType:    fileType,
+			FileSupType: fileSubType,
+			Result:      result,
+			Reason:      reason,
+		}
+	}
+
+	return dataList, nil
+}
+
+func GetCallFileTaskStandbyList(id string) (map[string]domain.BatchDownloadFileTaskInfo, error) {
+	// 提取key list
+	rdsKey := callFileKeyPrefix + id
+	return getCallFileTaskList(rdsKey, "standby")
+}
+
+func GetCallFileTaskFailedList(id string) (map[string]domain.BatchDownloadFileTaskInfo, error) {
+	// 提取key list
+	rdsKey := callFileKeyPrefix + id
+	return getCallFileTaskList(rdsKey, "failed")
+}
+
+func DeleteCallFileTaskList(id string) {
+	// 提取failed key list
+	rdsKey := callFileKeyPrefix + id + ":failed"
+	subKeySet, err := cache.SMembers(bachDownloadCtx, rdsKey)
+	if err != nil {
+		zap.L().Error("[DeleteBatchDownloadTaskList]回去key list失败", zap.String("key", rdsKey), zap.String("err", err.Error()))
+		return
+	}
+
+	for _, key := range subKeySet {
+		if err := cache.Delete(bachDownloadCtx, key); err != nil {
+			zap.L().Error("DeleteBatchDownloadTaskList失败", zap.String("key", key), zap.String("err", err.Error()))
+		}
+	}
+
+	cache.Delete(bachDownloadCtx, rdsKey)
+}
+
+// result 1-成功 -1-失败
+func UpdateCallFileResult(id string, taskId string, result int, reason string) error {
+	rdsKey := callFileKeyPrefix + id
+	subRdsKey := rdsKey + ":" + taskId
+	rdsStandbyKey := rdsKey + ":standby"
+	rdsFailedKey := rdsKey + ":failed"
+	exists, err := cache.SIsMember(bachDownloadCtx, rdsStandbyKey, taskId)
+	if err != nil {
+		return err
+	}
+	if !exists {
+		return fmt.Errorf("[UpdateBatchDownloadResult]%s中不存在%s", rdsStandbyKey, taskId)
+	}
+
+	// 如果成功,则删除list, 否则转移至failed list
+	if result == 1 {
+		if err := cache.Delete(bachDownloadCtx, subRdsKey); err != nil {
+			zap.L().Error("[UpdateCallFileResult] 删除成功list失败", zap.String("key", subRdsKey), zap.String("err", err.Error()))
+			return err
+		}
+	} else {
+		// 添加到Failed key
+		if err := cache.SAdd(bachDownloadCtx, rdsFailedKey, taskId); err != nil {
+			zap.L().Error("[UpdateCallFileResult] 添加failed key失败", zap.String("key", rdsFailedKey), zap.String("err", err.Error()))
+			return err
+		}
+
+		if err := cache.HSet(bachDownloadCtx, subRdsKey,
+			"result", result,
+			"reason", reason,
+		); err != nil {
+			return err
+		}
+	}
+
+	// 删除Standby key
+	if err := cache.SRem(bachDownloadCtx, rdsStandbyKey, taskId); err != nil {
+		zap.L().Error("[UpdateCallFileResult] 删除成功key失败", zap.String("key", rdsKey), zap.String("err", err.Error()))
+		return err
+	}
+
+	return nil
+}

+ 48 - 2
service/repositories/repositories.go

@@ -16,14 +16,16 @@ import (
 type EventAlarmType = fwh_pro.EventAlarmType
 
 const (
-	Event EventAlarmType = fwh_pro.Event // 0
-	Alarm EventAlarmType = fwh_pro.Alarm // 1
+	EventAndAlarm EventAlarmType = fwh_pro.All   // 0
+	Event         EventAlarmType = fwh_pro.Event // 1
+	Alarm         EventAlarmType = fwh_pro.Alarm // 2
 )
 
 var FwhHttpInit = fwh_pro.FwhHttpInit
 
 var QuerySiteBaseInfoList = fwh_pro.QuerySiteBaseInfoList
 var QuerySiteInfoList = fwh_pro.QuerySiteInfoList
+var QuerySiteList = fwh_pro.QuerySiteList
 var QuerySiteModeInfo = fwh_pro.QuerySiteModeInfo
 var QuerySitePowerFlow = fwh_pro.QuerySitePowerFlow
 var QuerySiteAlarmList = fwh_pro.QuerySiteAlarmList
@@ -35,6 +37,24 @@ var QueryMqttLogListOnTimeRange = fwh_pro.QueryMqttLogListOnTimeRange
 
 var QuerySiteShutdownList = fwh_pro.QuerySiteShutdownList
 
+// fwh_emsLog
+var QueryCallEmsLog = fwh_pro.QueryCallEmsLog                 // 召唤EMS日志
+var QueryEmsLogCallRecord = fwh_pro.QueryEmsLogCallRecord     // 查询EMS日志下载地址
+var QueryDownloadEmsLogFile = fwh_pro.QueryDownloadEmsLogFile // 请求下载EMS日志文件
+
+// fwh_faultRecord
+var QueryFaultRecordList = fwh_pro.QueryFaultRecordList                     // 查询故障录波日志
+var QueryFaultRecordCallableList = fwh_pro.QueryFaultRecordCallableList     // 查询设备可召唤数据列表 - 仅自动录播
+var QueryCellFaultRecord = fwh_pro.QueryCellFaultRecord                     // 召唤故障录波日志
+var QueryFaultRecordCallRecordList = fwh_pro.QueryFaultRecordCallRecordList // 查询故障录波召唤记录
+var QueryFaultRecordSubList = fwh_pro.QueryFaultRecordSubList               // 查询故障录波子记录
+var QueryDownloadFaultRecordData = fwh_pro.QueryDownloadFaultRecordData     // 请求下载录波数据
+
+// fwh_update
+var QuerySystemUpdateLog = fwh_pro.QuerySystemUpdateLog             // 查询系统升级记录
+var QuerySystemUpdateLogDetail = fwh_pro.QuerySystemUpdateLogDetail // 查询系统升级记录详细信息
+var QueryAHubUpdateLog = fwh_pro.QueryAHubUpdateLog                 // 查询aHub升级记录
+
 /*************** MONGO ***************/
 // Xlog
 var GetXlogManageList = mongo.GetXlogManageList
@@ -67,11 +87,30 @@ var GetLatestStatisticsShutdown = mongo.GetLatestStatisticsShutdown
 var GetStatisticsShutdownByDateRange = mongo.GetStatisticsShutdownByDateRange
 var InsertStatisticsShutdown = mongo.InsertStatisticsShutdown
 
+// BatchDownload
+var GetBatchDownloadManageList = mongo.GetBatchDownloadManageList
+var GetBatchDownloadConfig = mongo.GetBatchDownloadConfig
+var InsertBatchDownloadConfig = mongo.InsertBatchDownloadConfig
+var UpdateBatchDownloadDescription = mongo.UpdateBatchDownloadDescription
+var UpdateBatchDownloadResult = mongo.UpdateBatchDownloadResult
+var DeleteBatchDownloadConfig = mongo.DeleteBatchDownloadConfig
+var InsertBatchDownloadData = mongo.InsertBatchDownloadData
+var GetBatchDownloadData = mongo.GetBatchDownloadData
+var InsertBatchDownloadPathPowerFlow = mongo.InsertBatchDownloadPathPowerFlow
+var InsertBatchDownloadPathMqttLogs = mongo.InsertBatchDownloadPathMqttLogs
+var InsertBatchDownloadPathEventAlarm = mongo.InsertBatchDownloadPathEventAlarm
+var InsertBatchDownloadPathEmsLogs = mongo.InsertBatchDownloadPathEmsLogs
+var InsertBatchDownloadPathFaultRecord = mongo.InsertBatchDownloadPathFaultRecord
+var InsertBatchDownloadPathAlarmSnapshot = mongo.InsertBatchDownloadPathAlarmSnapshot
+var InsertBatchDownloadPathUpdateLog = mongo.InsertBatchDownloadPathUpdateLog
+var InsertBatchDownloadPathSettings = mongo.InsertBatchDownloadPathSettings
+
 /*************** PGSQL ***************/
 
 // Alarm Snapshot
 var QuerySnapshotFileRecord = pgsql_alarmSnapshot.QuerySnapshotFileRecord
 var InsertSnapshotFileRecord = pgsql_alarmSnapshot.InsertSnapshotFileRecord
+var QuerySnapshotData = pgsql_alarmSnapshot.QuerySnapshotData
 var InsertSnapshotData = pgsql_alarmSnapshot.InsertSnapshotData
 
 // Original
@@ -89,3 +128,10 @@ type SessionInfo = redis.SessionInfo
 var StoreSession = redis.StoreSession
 var GetSession = redis.GetSession
 var DeleteSession = redis.DeleteSession
+
+// BatchDownload
+var CreateBatchDownloadCallFileTaskList = redis.CreateCallFileTaskList
+var GetBatchDownloadCallFileTaskStandbyList = redis.GetCallFileTaskStandbyList
+var GetBatchDownloadCallFileTaskFailedList = redis.GetCallFileTaskFailedList
+var DeleteBatchDownloadCallFileTaskList = redis.DeleteCallFileTaskList
+var UpdateBatchDownloadCallFileResult = redis.UpdateCallFileResult

+ 32 - 0
service/routes/batchDownloadRoutes.go

@@ -0,0 +1,32 @@
+/*
+ * Batch Download API List
+ * /batchDownload/manage/list - GET
+ * /batchDownload/manage/config - POST 创建新配置
+ * /batchDownload/manage/description/{id} - POST 更新任务说明
+ * /batchDownload/manage/config/{id} - DELETE 删除配置
+ * /batchDownload/data/{id} - GET 获取指定ID的数据包下载地址
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func BatchDownloadSetup(api fiber.Router) {
+	// 获取批量数据下载管理表
+	api.Get("/batchDownload/manage/list", controllers.GetBatchDownloadList)
+
+	// 创建一个批量数据下载管任务
+	api.Post("/batchDownload/manage/config", controllers.CreateBatchDownload)
+
+	// 更新一个批量数据下载说明
+	api.Post("/batchDownload/manage/description/:id", controllers.UpdateBatchDownloadDescription)
+
+	// 删除一个批量数据下载数据
+	api.Delete("/batchDownload/manage/config/:id", controllers.DeleteBatchDownload)
+
+	// 获取指定ID的批量数据下载数据包地址
+	api.Get("/batchDownload/data/:id", controllers.GetBatchDownloadData)
+}

+ 1 - 0
service/routes/routes.go

@@ -24,6 +24,7 @@ func Setup(app *fiber.App) {
 	ShutdownManageSetup(api)
 	SitesMonitorSetup(api)
 	SitesAnalysisSetup(api)
+	BatchDownloadSetup(api)
 	BackgroundTaskSetup(api)
 	XlogSetup(api)
 

+ 1 - 0
service/routes/sitesAnalysisRoutes.go

@@ -2,6 +2,7 @@
  * Sites Analysis API List
  * /sitesAnalysis/manage/analysisList - GET
  * /sitesAnalysis/manage/config - POST 创建新配置
+ * /sitesAnalysis/manage/description/{id} - POST 更新任务说明
  * /sitesAnalysis/manage/config/{id} - GET & POST & DELETE 获取、更新、删除配置
  * /sitesAnalysis/data/{id} - GET 获取指定ID的分析数据
  */

+ 1 - 0
service/services/authServices.go

@@ -23,6 +23,7 @@ func Login(param dto.LoginParam) (*domain.AuthInfo, error) {
 	// 生成 sid 并存 Redis(只存 refresh_token,access_token 可以缓存)
 	sid := uuid.NewString()
 	if err := repositories.StoreSession(sid, auth); err != nil {
+		zap.L().Error("store session err", zap.String("AccessToken", auth.AccessToken), zap.String("RefreshToken", auth.RefreshToken), zap.String("err", err.Error()))
 		return nil, fmt.Errorf("store session err: %w", err)
 	}
 

+ 159 - 0
service/services/batchDownloadServices.go

@@ -0,0 +1,159 @@
+package services
+
+import (
+	"archive/zip"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/taskQueue"
+	"fwh-toolbox-server/utils/minio"
+	"io"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+// 从数据库获取批量数据下载列表
+func GetBatchDownloadManageList(page, size uint, sn, progress *string) (*dto.ResPage[domain.BatchDownloadInfo], error) {
+	return repositories.GetBatchDownloadManageList(page, size, sn, progress)
+}
+
+// 插入一条批量数据下载配置到数据库
+func InsertBatchDownloadConfig(writeConfig domain.BatchDownloadConfigWrite) error {
+	id, err := repositories.InsertBatchDownloadConfig(writeConfig)
+	if err != nil {
+		return err
+	}
+
+	taskQueue.EnqueueBatchDownloadTask(domain.TaskBatchDownload{
+		Id:         id,
+		Sn:         writeConfig.Sn,
+		TimePeriod: writeConfig.TimePeriod,
+		Options:    writeConfig.Options,
+	})
+
+	return nil
+}
+
+// 更新一条批量数据下载配置到数据库
+func UpdateBatchDownloadDescription(id primitive.ObjectID, desc string) error {
+	err := repositories.UpdateBatchDownloadDescription(id, desc)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 删除一条批量数据下载配置
+func DeleteBatchDownloadConfig(id primitive.ObjectID) error {
+	err := repositories.DeleteBatchDownloadConfig(id)
+	if err != nil {
+		return err
+	}
+
+	// TODO 需删除相关数据
+
+	return nil
+}
+
+// 从数据库获取批量数据下载文件地址并打包成zip文件
+func GetBatchDownloadData(id primitive.ObjectID) (*io.PipeReader, error) {
+	dataPathMap, err := repositories.GetBatchDownloadData(id)
+	if err != nil {
+		return nil, err
+	}
+
+	pr, pw := io.Pipe()
+	zw := zip.NewWriter(pw)
+
+	go func() {
+		defer zw.Close()
+		defer pw.Close()
+		var InsertZipFile = func(path string) error {
+			obj, err := minio.GetObject(path)
+			if err != nil {
+				zap.L().Error("获取对象失败", zap.String("objName", path), zap.String("err", err.Error()))
+				return err
+			}
+
+			zipFileWriter, err := zw.Create(path)
+			if err != nil {
+				zap.L().Error("创建zip条目失败", zap.String("path", path), zap.String("err", err.Error()))
+				return err
+			}
+
+			if _, err := io.Copy(zipFileWriter, obj); err != nil {
+				zap.L().Error("写入zip失败", zap.String("path", path), zap.String("err", err.Error()))
+				return err
+			}
+			return nil
+		}
+
+		// map[sn]batchData
+		for _, batchData := range dataPathMap {
+			// PowerFlow
+			// map[date]path
+			for _, path := range batchData.PowerFlow {
+				if err := InsertZipFile(path); err != nil {
+					continue
+				}
+			}
+
+			// MqttLogs
+			// map[date]logs
+			for _, logs := range batchData.MqttLogs {
+				// map[logType]path
+				for _, path := range logs {
+					if err := InsertZipFile(path); err != nil {
+						continue
+					}
+				}
+			}
+
+			// EventAlarm
+			// map[date]path
+			for _, path := range batchData.EventAlarm {
+				if err := InsertZipFile(path); err != nil {
+					continue
+				}
+			}
+
+			// EmsLogs
+			// map[date]logs
+			for _, logs := range batchData.EmsLogs {
+				// map[logType]path
+				for _, path := range logs {
+					if err := InsertZipFile(path); err != nil {
+						continue
+					}
+				}
+			}
+
+			// FaultRecord
+			// map[date]path
+			for _, path := range batchData.FaultRecord {
+				if err := InsertZipFile(path); err != nil {
+					continue
+				}
+			}
+
+			// UpdateLog
+			if batchData.UpdateLog != "" {
+				if err := InsertZipFile(batchData.UpdateLog); err != nil {
+					continue
+				}
+			}
+
+			// Settings
+			if batchData.Settings != "" {
+				if err := InsertZipFile(batchData.Settings); err != nil {
+					continue
+				}
+			}
+		}
+	}()
+
+	// 返回流给客户端
+	return pr, nil
+}

+ 554 - 0
service/taskQueue/taskBatchDownload.go

@@ -0,0 +1,554 @@
+package taskQueue
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/jobs"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+	"strconv"
+	"strings"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+var (
+	TaskTypeBatchDownload = "task_BatchDownload"
+)
+
+// type TaskBatchDownload = types.TaskBatchDownload
+
+func handleTaskBatchDownload(ctx context.Context, payload domain.TaskBatchDownload) error {
+	// 更新运行状态
+	fmt.Println("更新任务运行状态")
+	if repoErr := repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressRunning, []string{}); repoErr != nil {
+		zap.L().Error("更新批量数据下载结果状态失败(Running)", zap.String("id", payload.Id.String()))
+		return repoErr
+	}
+
+	fmt.Printf("开始执行任务 %v\n", payload)
+	reasons, err := executeTaskBatchDownload(ctx, payload)
+	// 记录任务执行结果
+	if err != nil {
+		fmt.Printf("任务执行失败 %s\n", err.Error())
+		if repoErr := repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressError, []string{err.Error()}); repoErr != nil {
+			zap.L().Error("更新批量数据下载结果状态失败(Error)", zap.String("id", payload.Id.String()))
+			return repoErr
+		}
+	} else {
+		if len(reasons) == 0 {
+			fmt.Println("任务执行成功")
+			if repoErr := repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressComplete, []string{}); repoErr != nil {
+				zap.L().Error("更新批量数据下载结果状态失败(Complete)", zap.String("id", payload.Id.String()), zap.String("err", repoErr.Error()))
+				return repoErr
+			}
+		} else {
+			fmt.Println("任务执行失败")
+			if repoErr := repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressFailed, reasons); repoErr != nil {
+				zap.L().Error("更新批量数据下载结果状态失败(Complete)", zap.String("id", payload.Id.String()), zap.String("err", repoErr.Error()))
+				return repoErr
+			}
+		}
+	}
+
+	return err
+}
+
+var executeTaskBatchDownload = func(ctx context.Context, payload domain.TaskBatchDownload) ([]string, error) {
+	// 转换时间范围,只取用日期部分
+	startTime, err := time.Parse(time.DateOnly, payload.TimePeriod[0])
+	if err != nil {
+		zap.L().Error("StartTime 格式错误", zap.String("time", payload.TimePeriod[0]), zap.String("err", err.Error()))
+		return nil, err
+	}
+	endTime, err := time.Parse(time.DateOnly, payload.TimePeriod[1])
+	if err != nil {
+		zap.L().Error("EndTime 格式错误", zap.String("time", payload.TimePeriod[1]), zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	// 将 endTime 调整到当天的 23:59:59
+	endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
+
+	// 保证 startTime <= endTime
+	if startTime.After(endTime) {
+		return nil, errors.New("startTime > endTime")
+	}
+
+	// 按天拆分时间段
+	var periods []struct{ Start, End time.Time }
+	currTime := startTime
+	for currTime.Before(endTime) {
+		// 当天结束时间
+		dayEnd := time.Date(currTime.Year(), currTime.Month(), currTime.Day(), 23, 59, 59, 0, currTime.Location())
+
+		// 如果当天结束大于 endTime,使用 endTime
+		if dayEnd.After(endTime) {
+			dayEnd = endTime
+		}
+
+		periods = append(periods, struct{ Start, End time.Time }{Start: currTime, End: dayEnd})
+
+		// 下一天开始
+		currTime = dayEnd.Add(time.Second).Truncate(24 * time.Hour)
+		currTime = time.Date(currTime.Year(), currTime.Month(), currTime.Day(), 0, 0, 0, 0, currTime.Location())
+	}
+
+	fmt.Println(payload.Options)
+
+	// 优先处理文件下载任务(并行处理缩短时间)
+	downloadList := []domain.BatchDownloadFileTaskInfo{}
+	for _, sn := range payload.Sn {
+		for _, period := range periods {
+			dateStr := period.Start.Format(time.DateOnly)
+			// 捞取EMS日志
+			for _, logType := range payload.Options.EmsLogs {
+				downloadList = append(downloadList, domain.BatchDownloadFileTaskInfo{
+					DevType:     -1,
+					Sn:          sn,
+					Date:        dateStr,
+					FileType:    jobs.FileTypeEmsLog,
+					FileSupType: logType,
+					Result:      0,
+				})
+			}
+		}
+	}
+
+	for _, sn := range payload.Sn {
+		// 捞取故障录波数据
+		if payload.Options.FaultRecord {
+			// 召唤aGate故障录波
+			err := requestCallFaultRecord(&downloadList, 1, sn, startTime, endTime)
+			if err != nil {
+				return nil, err
+			}
+
+			// 提取设备的子设备SN - 暂时不支持MPPT和aHub
+			siteBaseInfoList, err := repositories.QuerySiteBaseInfoList(dto.SiteBaseInfoPara{
+				SiteList: []string{sn},
+				PageSize: 10,
+				PageNum:  1,
+			})
+			if err != nil {
+				zap.L().Error("查询站点基本信息失败", zap.String("sn", sn), zap.String("err", err.Error()))
+				return nil, err
+			}
+
+			if len(siteBaseInfoList.Result) > 0 {
+				aPowerSnList := strings.Split(siteBaseInfoList.Result[0].FhpSn, ",")
+				for i := range aPowerSnList {
+					aPowerSn := strings.TrimSpace(aPowerSnList[i])
+
+					// 召唤aGate故障录波
+					err := requestCallFaultRecord(&downloadList, 0, aPowerSn, startTime, endTime)
+					if err != nil {
+						return nil, err
+					}
+				}
+			}
+		}
+	}
+
+	// 插入文件下载任务
+	var isDownloadFile bool = false
+	if len(downloadList) > 0 {
+		isDownloadFile = true
+		err := jobs.CreateCallFwhFileTask(payload.Id, downloadList)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// 再处理其他数据下载任务
+	for _, sn := range payload.Sn {
+		// 生成功率流图文件
+		if payload.Options.PowerFlow {
+			for _, period := range periods {
+				// 查询功率流数据
+				fmt.Printf("查询功率流数据 - %s\n", period.Start.Format(time.DateOnly))
+				res, err := repositories.QuerySitePowerFlow(sn, period.Start)
+				if err != nil {
+					zap.L().Error("查询功率流数据失败", zap.String("sn", sn), zap.String("date", period.Start.Format(time.DateOnly)), zap.String("err", err.Error()))
+					return nil, err
+				}
+				dateStr := period.Start.Format(time.DateOnly)
+				objName, err := jobs.SaveBatchDownloadPowerFlow(sn, dateStr, res.Result)
+				if err != nil {
+					zap.L().Error("站点运行时数据保存失败", zap.String("sn", sn), zap.String("err", err.Error()))
+					return nil, err
+				}
+				if err := repositories.InsertBatchDownloadPathPowerFlow(payload.Id, sn, dateStr, objName); err != nil {
+					zap.L().Error("BatchDownload - PowerFlow文件地址写入失败", zap.String("sn", sn), zap.String("date", dateStr), zap.String("objName", objName), zap.String("err", err.Error()))
+					return nil, err
+				}
+			}
+		}
+
+		// 生成Mqtt日志数据文件
+		for _, logType := range payload.Options.MqttLogs {
+			// 查询MQTT日志
+			fmt.Printf("查询MQTT日志%s\n", logType)
+			for _, period := range periods {
+				logList, err := repositories.QueryMqttLogListOnTimeRange(sn, domain.MqttCmdType(logType), period.Start, period.End)
+				if err != nil {
+					zap.L().Error("查询MQTT日志失败", zap.String("type", logType), zap.String("err", err.Error()))
+					return nil, err
+				}
+
+				// 将结果保存到文件服务器
+				dateStr := period.Start.Format(time.DateOnly)
+				objName, err := jobs.SaveBatchDownloadMqttLog(sn, logType, dateStr, logList)
+				if err != nil {
+					zap.L().Error("站点运行时数据保存失败", zap.String("sn", sn), zap.String("err", err.Error()))
+					return nil, err
+				}
+				if err := repositories.InsertBatchDownloadPathMqttLogs(payload.Id, sn, logType, dateStr, objName); err != nil {
+					zap.L().Error("BatchDownload - MqttLog文件地址写入失败", zap.String("sn", sn), zap.String("date", dateStr), zap.String("objName", objName), zap.String("err", err.Error()))
+					return nil, err
+				}
+			}
+		}
+
+		// 生成事件&告警日志文件
+		if payload.Options.EventAlarm {
+			// 查询事件&告警日志
+			fmt.Printf("查询事件&告警日志\n")
+			for _, period := range periods {
+				eventAlarmList, err := repositories.QueryEventOrAlarmListOnTimeRange(sn, period.Start, period.End, repositories.EventAndAlarm)
+				if err != nil {
+					zap.L().Error("查询事件&告警日志失败", zap.String("sn", sn), zap.String("startTime", period.Start.Format(time.DateTime)), zap.String("endTime", period.End.Format(time.DateTime)), zap.String("err", err.Error()))
+					return nil, err
+				}
+
+				// 将结果保存到文件服务器
+				dateStr := period.Start.Format(time.DateOnly)
+				objName, err := jobs.SaveBatchDownloadEventAlarm(sn, dateStr, eventAlarmList)
+				if err != nil {
+					zap.L().Error("站点事件&告警数据保存失败", zap.String("sn", sn), zap.String("err", err.Error()))
+					return nil, err
+				}
+				if err := repositories.InsertBatchDownloadPathEventAlarm(payload.Id, sn, dateStr, objName); err != nil {
+					zap.L().Error("BatchDownload - EventAlarm文件地址写入失败", zap.String("sn", sn), zap.String("date", dateStr), zap.String("objName", objName), zap.String("err", err.Error()))
+					return nil, err
+				}
+			}
+		}
+
+		// 生成告警快照文件
+		if payload.Options.AlarmSnapshot {
+			// 查询告警快照日志
+			fmt.Printf("查询告警快照日志\n")
+			for _, period := range periods {
+				dateStr := period.Start.Format(time.DateOnly)
+				alarmSnapshotList, err := jobs.GetAlarmSnapshotData(ctx, sn, dateStr)
+				if err != nil {
+					zap.L().Error("查询告警快照日志失败", zap.String("sn", sn), zap.String("date", dateStr), zap.String("err", err.Error()))
+					return nil, err
+				}
+
+				// 将结果保存到文件服务器
+				objName, err := jobs.SaveBatchDownloadAlarmSnapshot(sn, dateStr, alarmSnapshotList)
+				if err != nil {
+					zap.L().Error("站点告警快照数据保存失败", zap.String("sn", sn), zap.String("err", err.Error()))
+					return nil, err
+				}
+				if err := repositories.InsertBatchDownloadPathAlarmSnapshot(payload.Id, sn, dateStr, objName); err != nil {
+					zap.L().Error("BatchDownload - AlarmSnapshot文件地址写入失败", zap.String("sn", sn), zap.String("date", dateStr), zap.String("objName", objName), zap.String("err", err.Error()))
+					return nil, err
+				}
+			}
+		}
+
+		// 生成站点升级日志数据文件
+		if payload.Options.UpdateLog {
+			fmt.Printf("查询站点升级日志\n")
+			updateLogList := []domain.BatchDownloadSystemUpdateLogInfo{}
+
+			// 查询系统升级日志
+			systemUpdateLogList, err := repositories.QuerySystemUpdateLog(sn)
+			if err != nil {
+				zap.L().Error("查询站点系统升级日志失败", zap.String("sn", sn), zap.String("err", err.Error()))
+				return nil, err
+			}
+
+			// 查询系统升级日志详细信息
+			for _, logList := range systemUpdateLogList {
+				var updateLogInfo domain.BatchDownloadSystemUpdateLogInfo
+				updateLogInfo.OrderNo = logList.OrderNo
+				updateLogInfo.GateSn = sn
+				updateLogInfo.FileName = logList.Name
+
+				// 解析 UTC 时间
+				upgradeTime, err := time.ParseInLocation(time.DateTime, logList.UpgradeTime, time.UTC)
+				if err != nil {
+					panic(err)
+				}
+
+				// 转换成北京时间
+				beijing, _ := time.LoadLocation("Asia/Shanghai")
+				bt := upgradeTime.In(beijing)
+				updateLogInfo.UpdateTime = bt.Format(time.DateTime)
+
+				// 判断升级触发类型
+				if strings.HasPrefix(logList.OrderNo, "CH") {
+					// 自动升级
+					updateLogInfo.TriggerType = "Auto"
+				} else if strings.HasPrefix(logList.OrderNo, "DH") {
+					// 手动升级
+					updateLogInfo.TriggerType = "Manual"
+				} else if strings.HasPrefix(logList.OrderNo, "SH") {
+					// SPAN自动升级
+					updateLogInfo.TriggerType = "Span Auto"
+				} else {
+					// 其他未知
+					updateLogInfo.TriggerType = "unknown"
+				}
+
+				// 判断升级结果
+				switch logList.Result {
+				case 1: // 升级中
+					updateLogInfo.Result = "Updating"
+				case 2: // 成功
+					updateLogInfo.Result = "Success"
+				case 3: // 失败
+					updateLogInfo.Result = "Fault"
+				default:
+					updateLogInfo.Result = fmt.Sprintf("unknown(%d)", logList.Result)
+				}
+
+				systemUpdateLogListDetail, err := repositories.QuerySystemUpdateLogDetail(logList.Id)
+				if err != nil {
+					zap.L().Error("查询站点系统升级详细日志失败", zap.String("sn", sn), zap.String("err", err.Error()))
+					return nil, err
+				}
+
+				// 提取对应SN数据,并提取版本数据
+				for _, logDetail := range systemUpdateLogListDetail {
+					if logDetail.GatewayId == sn {
+						// 提取Gate版本数据
+						for _, ibgVer := range logDetail.IbgVersionNew {
+							if ibgVer.Children != nil {
+								for _, childVerInfo := range ibgVer.Children {
+									var verInfo domain.BatchDownloadUpdateLogVersionInfo
+									verInfo.Name = childVerInfo.Type
+									verInfo.OldVer = childVerInfo.OldVersion
+									verInfo.NewVer = childVerInfo.Version
+									updateLogInfo.GateVerList = append(updateLogInfo.GateVerList, verInfo)
+								}
+							} else {
+								var verInfo domain.BatchDownloadUpdateLogVersionInfo
+								verInfo.Name = ibgVer.Type
+								verInfo.OldVer = ibgVer.OldVersion
+								verInfo.NewVer = ibgVer.Version
+								updateLogInfo.GateVerList = append(updateLogInfo.GateVerList, verInfo)
+							}
+						}
+
+						// 提取子设备版本数据
+						for _, subInfo := range logDetail.SonNewDevices {
+							var subDevInfo domain.BatchDownloadUpdateLogSubDevInfo
+							subDevInfo.DevSn = subInfo.ApowerSn
+							for _, firstItem := range subInfo.FirstList {
+								for _, secondItem := range firstItem.Secondlist {
+									for _, thirdItem := range secondItem.Thirdlist {
+										var verInfo domain.BatchDownloadUpdateLogVersionInfo
+										verInfo.Name = thirdItem.SubType
+										verInfo.OldVer = thirdItem.OldVersion
+										verInfo.NewVer = thirdItem.Version
+										subDevInfo.DevVerList = append(subDevInfo.DevVerList, verInfo)
+									}
+								}
+							}
+							updateLogInfo.SubDevList = append(updateLogInfo.SubDevList, subDevInfo)
+						}
+
+						break
+					}
+				}
+
+				updateLogList = append(updateLogList, updateLogInfo)
+			}
+
+			// 查询aHub升级日志
+			aHubUpdateLogList, err := repositories.QueryAHubUpdateLog(sn)
+			if err != nil {
+				zap.L().Error("查询站点aHub升级日志失败", zap.String("sn", sn), zap.String("err", err.Error()))
+				return nil, err
+			}
+
+			var aHubUpdateLogData []domain.BatchDownloadAHubUpdateLogInfo
+			for _, aHubVerInfo := range aHubUpdateLogList {
+				var updateLogInfo domain.BatchDownloadAHubUpdateLogInfo
+				updateLogInfo.OrderNo = aHubVerInfo.OrderNo
+				updateLogInfo.GateSn = sn
+				updateLogInfo.AHubSn = aHubVerInfo.AHubSn
+
+				// 判断升级触发类型
+				if strings.HasPrefix(aHubVerInfo.OrderNo, "CH") {
+					// 自动升级
+					updateLogInfo.TriggerType = "Auto"
+				} else if strings.HasPrefix(aHubVerInfo.OrderNo, "DH") {
+					// 手动升级
+					updateLogInfo.TriggerType = "Manual"
+				} else if strings.HasPrefix(aHubVerInfo.OrderNo, "SH") {
+					// SPAN自动升级
+					updateLogInfo.TriggerType = "Span Auto"
+				} else {
+					// 其他未知
+					updateLogInfo.TriggerType = "unknown"
+				}
+
+				// 判断升级结果
+				switch aHubVerInfo.UpgradeResult {
+				case 0: // 成功
+					updateLogInfo.Result = "Success"
+				case -1: // 失败
+					updateLogInfo.Result = "Fault"
+				default:
+					updateLogInfo.Result = fmt.Sprintf("unknown(%d)", aHubVerInfo.UpgradeResult)
+				}
+
+				for _, detailInfo := range aHubVerInfo.DetailList {
+					var aHubDetailInfo domain.BatchDownloadUpdateLogAHubVersionInfo
+					aHubDetailInfo.FileName = detailInfo.UpgradeFileName
+					aHubDetailInfo.FileOrder = detailInfo.UpgradeFileOrder
+					aHubDetailInfo.Result = detailInfo.UpgradeResult
+					aHubDetailInfo.ResultDesc = detailInfo.UpgradeResultDesc
+					aHubDetailInfo.OldVer = detailInfo.OldVersion
+					aHubDetailInfo.NewVer = detailInfo.NewVersion
+					updateLogInfo.DetailList = append(updateLogInfo.DetailList, aHubDetailInfo)
+				}
+
+				aHubUpdateLogData = append(aHubUpdateLogData, updateLogInfo)
+			}
+
+			// 将结果保存到文件服务器
+			objName, err := jobs.SaveBatchDownloadUpdateLog(sn, updateLogList, aHubUpdateLogData)
+			if err != nil {
+				zap.L().Error("站点升级记录数据保存失败", zap.String("sn", sn), zap.String("err", err.Error()))
+				return nil, err
+			}
+			if err := repositories.InsertBatchDownloadPathUpdateLog(payload.Id, sn, objName); err != nil {
+				zap.L().Error("BatchDownload - UpdateLog文件地址写入失败", zap.String("sn", sn), zap.String("objName", objName), zap.String("err", err.Error()))
+				return nil, err
+			}
+
+		}
+
+		// TODO 生成站点设置数据文件(仅当前配置,和选择时间范围无关)
+		// if payload.Options.Settings {
+
+		// }
+	}
+
+	if isDownloadFile {
+		for {
+			// 等待文件下载完成,持续判断文件下载进度
+			standbyList, err := repositories.GetBatchDownloadCallFileTaskStandbyList(payload.Id.String())
+			if err != nil {
+				zap.L().Error("BatchDownload - 读取CallFileTaskStandbyList失败", zap.String("id", payload.Id.String()), zap.String("err", err.Error()))
+				return nil, err
+			}
+
+			if len(standbyList) == 0 {
+				break
+			}
+
+			// 等待1分钟后再判断
+			time.Sleep(time.Minute)
+		}
+
+		// 处理失败列表
+		failedList, err := repositories.GetBatchDownloadCallFileTaskFailedList(payload.Id.String())
+		if err != nil {
+			zap.L().Error("BatchDownload - 读取CallFileTaskFailedList失败", zap.String("id", payload.Id.String()), zap.String("err", err.Error()))
+			return nil, err
+		}
+
+		// 删除task list
+		repositories.DeleteBatchDownloadCallFileTaskList(payload.Id.String())
+
+		var failReasons []string = []string{}
+		for _, f := range failedList {
+			failReasons = append(failReasons, f.Reason)
+		}
+
+		return failReasons, nil
+	}
+
+	return nil, nil
+}
+
+// 插入批量数据下载队列
+// payload - 任务参数
+func EnqueueBatchDownloadTask(payload domain.TaskBatchDownload) error {
+	p := TaskPayload[domain.TaskBatchDownload]{
+		TaskPayloadBase: TaskPayloadBase{
+			Type: TaskTypeBatchDownload,
+		},
+		Payload: payload,
+	}
+	if err := EnqueueTask[domain.TaskBatchDownload](p); err != nil {
+		logger.Std.Printf("EnqueueBatchDownloadTask - %s : %s\n", payload.Id, err.Error())
+		return err
+	}
+	return nil
+}
+
+func requestCallFaultRecord(downloadList *[]domain.BatchDownloadFileTaskInfo, devType int, sn string, startTime time.Time, endTime time.Time) error {
+	// 召唤aGate、aPower故障录波
+	// 查询要召唤的选项
+	callInfoList, err := repositories.QueryFaultRecordCallableList(devType, sn)
+	if err != nil {
+		zap.L().Error("查询站点故障录波召唤选项失败", zap.String("sn", sn), zap.String("err", err.Error()))
+		return err
+	}
+
+	// 提取符合日期范围的选项
+	var indexList string = ""
+	for _, info := range callInfoList {
+		// 转换成标准时间格式
+		parts := strings.Split(info.FailureTime, " ")
+		date := parts[0]
+		timeStr := parts[1]
+
+		d := strings.Split(date, "-")
+		t2 := strings.Split(timeStr, ":")
+
+		failureStandardTime := fmt.Sprintf("%04s-%02s-%02s %02s:%02s:%02s",
+			d[0], d[1], d[2], t2[0], t2[1], t2[2])
+
+		failureTime, err := time.Parse(time.DateTime, failureStandardTime)
+		if err != nil {
+			zap.L().Error("故障录波召唤选项格式错误", zap.String("sn", sn), zap.String("FailureTime", info.FailureTime), zap.String("err", err.Error()))
+			return err
+		}
+
+		// 判断是否在范围内(包含边界)
+		inRange := (failureTime.Equal(startTime) || failureTime.After(startTime)) &&
+			(failureTime.Equal(endTime) || failureTime.Before(endTime))
+		if inRange {
+			if indexList == "" {
+				indexList = indexList + strconv.Itoa(info.Idx)
+			} else {
+				indexList = indexList + "," + strconv.Itoa(info.Idx)
+			}
+		}
+	}
+
+	*downloadList = append(*downloadList, domain.BatchDownloadFileTaskInfo{
+		DevType:     devType,
+		Sn:          sn,
+		Date:        startTime.Format(time.DateTime) + "," + endTime.Format(time.DateTime), // 故障录波的日期是一个范围,用","隔开
+		FileType:    jobs.FileTypeFaultRecord,
+		FileSupType: indexList,
+		Result:      0,
+	})
+
+	return nil
+}

+ 21 - 0
service/taskQueue/taskQueue.go

@@ -182,6 +182,27 @@ func handleTaskQueueMsg(message redis.XMessage) {
 			}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
 			return
 		}
+	case TaskTypeBatchDownload: // 批量数据下载任务
+		var payload TaskPayload[domain.TaskBatchDownload]
+		if err := json.Unmarshal([]byte(rawPayload), &payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("unmarshal payload error - %s", err.Error()))
+			return
+		}
+		logger.Std.Printf("Processing task %s: %+v\n", id, payload)
+		if err := handleTaskBatchDownload(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("handle task BatchDownload error - %s", err.Error()))
+			return
+		}
 	default:
 		logger.Std.Println("Unknown task type:", base.Type)
 	}

+ 62 - 0
service/test/echart/echartTest.go

@@ -0,0 +1,62 @@
+package main
+
+import (
+	"fwh-toolbox-server/utils/echart"
+	"math/rand"
+	"os"
+	"time"
+
+	"github.com/go-echarts/go-echarts/v2/opts"
+)
+
+// 生成时间-数据序列
+func generateLineItems() []opts.LineData {
+	items := make([]opts.LineData, 0)
+	now := time.Now()
+
+	for i := 0; i < 10; i++ {
+		// 随机值范围 -300 ~ +300
+		value := rand.Intn(600) - 300
+
+		items = append(items, opts.LineData{
+			Value: []interface{}{
+				now.Add(time.Duration(i) * time.Hour).Format(time.DateTime),
+				value,
+			},
+		})
+	}
+
+	return items
+}
+
+func main() {
+	var data []echart.LineChartData = []echart.LineChartData{}
+	data = append(data, echart.LineChartData{
+		Legend: "A",
+		Data:   generateLineItems(),
+	})
+	data = append(data, echart.LineChartData{
+		Legend: "B",
+		Data:   generateLineItems(),
+	})
+	data = append(data, echart.LineChartData{
+		Legend: "C",
+		Data:   generateLineItems(),
+	})
+	data = append(data, echart.LineChartData{
+		Legend: "D",
+		Data:   generateLineItems(),
+	})
+
+	lineOpt := echart.LineChartOpt{
+		Global: echart.ChartOpt{
+			Title: "功率流图",
+		},
+		Smooth: true,
+	}
+
+	line := echart.LineSmoothArea(lineOpt, data)
+	f, _ := os.Create("line.html")
+	defer f.Close()
+	line.Render(f)
+}

+ 84 - 0
service/test/echart/powerFlow/powerFlowEchartTest.go

@@ -0,0 +1,84 @@
+package main
+
+import (
+	"fwh-toolbox-server/utils/echart"
+	"math/rand"
+	"os"
+	"time"
+
+	"github.com/go-echarts/go-echarts/v2/opts"
+)
+
+// 生成时间-数据序列
+func generateLineItems() []opts.LineData {
+	items := make([]opts.LineData, 0)
+	now := time.Now()
+
+	for i := 0; i < 10; i++ {
+		// 随机值范围 -300 ~ +300
+		value := rand.Intn(600) - 300
+
+		items = append(items, opts.LineData{
+			Value: []interface{}{
+				now.Add(time.Duration(i) * time.Hour).Format(time.DateTime),
+				value,
+			},
+		})
+	}
+
+	return items
+}
+
+func generateSocItems() []opts.LineData {
+	items := make([]opts.LineData, 0)
+	now := time.Now()
+
+	for i := 0; i < 10; i++ {
+		// 随机值范围 0 ~ 100
+		value := rand.Intn(100)
+
+		items = append(items, opts.LineData{
+			Value: []interface{}{
+				now.Add(time.Duration(i) * time.Hour).Format(time.DateTime),
+				value,
+			},
+		})
+	}
+
+	return items
+}
+
+func main() {
+	var powerFlowData []echart.PowerFlowChartData = []echart.PowerFlowChartData{}
+	powerFlowData = append(powerFlowData, echart.PowerFlowChartData{
+		Legend: "A",
+		Data:   generateLineItems(),
+	})
+	powerFlowData = append(powerFlowData, echart.PowerFlowChartData{
+		Legend: "B",
+		Data:   generateLineItems(),
+	})
+	powerFlowData = append(powerFlowData, echart.PowerFlowChartData{
+		Legend: "C",
+		Data:   generateLineItems(),
+	})
+	powerFlowData = append(powerFlowData, echart.PowerFlowChartData{
+		Legend: "D",
+		Data:   generateLineItems(),
+	})
+
+	var socData echart.SocChartData = echart.SocChartData{
+		Data: generateSocItems(),
+	}
+
+	lineOpt := echart.LineChartOpt{
+		Global: echart.ChartOpt{
+			Title: "功率流图",
+		},
+	}
+
+	line := echart.CreatePowerFlowChart(lineOpt, powerFlowData, socData)
+	f, _ := os.Create("line.html")
+	defer f.Close()
+	line.Render(f)
+}

+ 17 - 0
service/test/getApowerSn/getApowerSn.go

@@ -0,0 +1,17 @@
+package main
+
+import (
+	"fmt"
+	"fwh-toolbox-server/repositories"
+)
+
+func main() {
+	res, err := repositories.QuerySiteList("10050002", 2, 1000, 1)
+	if err != nil {
+		panic(err)
+	}
+
+	for _, siteInfo := range res.Result {
+		fmt.Println(siteInfo.Id)
+	}
+}

+ 6 - 0
service/utils/echart/echart.go

@@ -0,0 +1,6 @@
+package echart
+
+type ChartOpt struct {
+	Title    string
+	Subtitle string
+}

+ 65 - 0
service/utils/echart/line.go

@@ -0,0 +1,65 @@
+package echart
+
+import (
+	"github.com/go-echarts/go-echarts/v2/charts"
+	"github.com/go-echarts/go-echarts/v2/opts"
+	"github.com/go-echarts/go-echarts/v2/types"
+)
+
+type LineChartOpt struct {
+	Global ChartOpt
+	Smooth bool // 是否平滑过度
+}
+
+type LineChartData struct {
+	Legend string  // 图例名称
+	Color  *string // 图例颜色
+	Stack  *string // 图例堆叠名称,相同的名称会堆叠到一起
+	Data   []opts.LineData
+}
+
+func LineSmoothArea(opt LineChartOpt, data []LineChartData) *charts.Line {
+	line := charts.NewLine()
+	line.SetGlobalOptions(
+		charts.WithInitializationOpts(opts.Initialization{
+			Theme:  types.ThemeShine,
+			Width:  "1280px",
+			Height: "500px",
+		}),
+		charts.WithTitleOpts(opts.Title{
+			Title:    opt.Global.Title,
+			Subtitle: opt.Global.Subtitle,
+		}),
+		charts.WithTooltipOpts(opts.Tooltip{
+			Trigger: "axis",
+		}),
+		charts.WithXAxisOpts(opts.XAxis{
+			Type: "time", // ⏰ 时间轴关键设置
+		}),
+		charts.WithLegendOpts(opts.Legend{
+			Show: opts.Bool(true),
+		}),
+	)
+
+	for _, d := range data {
+		lineOpt := []charts.SeriesOpts{}
+		lineChartOpt := opts.LineChart{}
+		lineChartOpt.Smooth = opts.Bool(opt.Smooth)
+		if d.Stack != nil {
+			lineChartOpt.Stack = *d.Stack
+		}
+		lineOpt = append(lineOpt, charts.WithLineChartOpts(lineChartOpt))
+		lineOpt = append(lineOpt, charts.WithAreaStyleOpts(opts.AreaStyle{
+			Opacity: opts.Float(0.4),
+		}))
+		if d.Color != nil {
+			lineOpt = append(lineOpt, charts.WithItemStyleOpts(opts.ItemStyle{
+				Color: *d.Color, // 自定义每个系列颜色
+			}))
+		}
+
+		line.AddSeries(d.Legend, d.Data, lineOpt...)
+	}
+
+	return line
+}

+ 119 - 0
service/utils/echart/powerFlow.go

@@ -0,0 +1,119 @@
+package echart
+
+import (
+	"github.com/go-echarts/go-echarts/v2/charts"
+	"github.com/go-echarts/go-echarts/v2/opts"
+	"github.com/go-echarts/go-echarts/v2/types"
+)
+
+type PowerFlowChartOpt struct {
+	Global ChartOpt
+}
+
+type PowerFlowChartData struct {
+	Legend string  // 图例名称
+	Color  *string // 图例颜色
+	Stack  *string // 图例堆叠名称,相同的名称会堆叠到一起
+	Data   []opts.LineData
+}
+
+type SocChartData struct {
+	Color *string // 图例颜色
+	Data  []opts.LineData
+}
+
+func CreatePowerFlowChart(opt LineChartOpt, powerFlowData []PowerFlowChartData, socData SocChartData) *charts.Line {
+	pfChart := charts.NewLine()
+	pfChart.SetGlobalOptions(
+		charts.WithInitializationOpts(opts.Initialization{
+			Theme:  types.ThemeShine,
+			Width:  "1280px",
+			Height: "500px",
+		}),
+		charts.WithGridOpts(
+			opts.Grid{Top: "60", Height: "35%"},  // 上图 grid
+			opts.Grid{Top: "55%", Height: "35%"}, // 下图 grid
+		),
+		charts.WithTooltipOpts(opts.Tooltip{
+			Trigger:         "axis",
+			AxisPointer:     &opts.AxisPointer{Type: "cross"},
+			BackgroundColor: "#ffffff", // 白色背景,不透明
+			BorderColor:     "#333333",
+		}),
+		charts.WithXAxisOpts(opts.XAxis{
+			Type:      "time",           // ⏰ 时间轴关键设置
+			Show:      opts.Bool(false), // 上图隐藏 X 轴标签
+			GridIndex: 0,
+		}),
+		charts.WithYAxisOpts(opts.YAxis{
+			Name:      "功率(kw)",
+			GridIndex: 0,
+		}),
+		charts.WithLegendOpts(opts.Legend{
+			Show: opts.Bool(true),
+		}),
+		charts.WithDataZoomOpts(
+			opts.DataZoom{
+				Type:       "slider",
+				XAxisIndex: []int{0, 1},
+				Start:      0,
+				End:        100,
+			},
+			opts.DataZoom{
+				Type:       "inside",
+				XAxisIndex: []int{0, 1},
+				Start:      0,
+				End:        100,
+			},
+		),
+	)
+	pfChart.ExtendXAxis(opts.XAxis{
+		Type:      "time",
+		GridIndex: 1,
+	})
+	pfChart.ExtendYAxis(opts.YAxis{
+		Name:      "SOC(%)",
+		GridIndex: 1,
+	})
+
+	// 插入上图(功率)数据
+	for _, d := range powerFlowData {
+		areaOpt := []charts.SeriesOpts{}
+		areaChartOpt := opts.LineChart{}
+		areaChartOpt.Smooth = opts.Bool(true)
+		areaChartOpt.Symbol = "none"
+		if d.Stack != nil {
+			areaChartOpt.Stack = *d.Stack
+		}
+		areaChartOpt.YAxisIndex = 0
+		areaChartOpt.XAxisIndex = 0
+		areaOpt = append(areaOpt, charts.WithLineChartOpts(areaChartOpt))
+		areaOpt = append(areaOpt, charts.WithAreaStyleOpts(opts.AreaStyle{
+			Opacity: opts.Float(0.4),
+		}))
+		if d.Color != nil {
+			areaOpt = append(areaOpt, charts.WithItemStyleOpts(opts.ItemStyle{
+				Color: *d.Color, // 自定义每个系列颜色
+			}))
+		}
+
+		pfChart.AddSeries(d.Legend, d.Data, areaOpt...)
+	}
+
+	// 插入下图(SOC)数据
+	lineOpt := []charts.SeriesOpts{}
+	lineOpt = append(lineOpt, charts.WithLineChartOpts(opts.LineChart{
+		Smooth:     opts.Bool(true),
+		Symbol:     "none",
+		YAxisIndex: 1,
+		XAxisIndex: 1,
+	}))
+	if socData.Color != nil {
+		lineOpt = append(lineOpt, charts.WithItemStyleOpts(opts.ItemStyle{
+			Color: *socData.Color, // 自定义每个系列颜色
+		}))
+	}
+	pfChart.AddSeries("SOC", socData.Data, lineOpt...)
+
+	return pfChart
+}

+ 62 - 16
service/utils/httpClient/httpClient.go

@@ -32,35 +32,61 @@ type ResponseData struct {
 	Code int `json:"code"`
 }
 
+// 封装模块的响应结构数据
+type Response struct {
+	Status     string // e.g. "200 OK"
+	StatusCode int    // e.g. 200
+
+	// Header maps header keys to values. If the response had multiple
+	// headers with the same key, they may be concatenated, with comma
+	// delimiters.  (RFC 7230, section 3.2.2 requires that multiple headers
+	// be semantically equivalent to a comma-delimited sequence.) When
+	// Header values are duplicated by other fields in this struct (e.g.,
+	// ContentLength, TransferEncoding, Trailer), the field values are
+	// authoritative.
+	//
+	// Keys in the map are canonicalized (see CanonicalHeaderKey).
+	Header http.Header
+
+	// Body represents the response body.
+	Body []byte
+
+	// ContentLength records the length of the associated content. The
+	// value -1 indicates that the length is unknown. Unless Request.Method
+	// is "HEAD", values >= 0 indicate that the given number of bytes may
+	// be read from Body.
+	ContentLength int64
+}
+
 var reLogin = func() string {
 	// empty function
 	return ""
 }
 
-func responseInterceptor(body []byte, retry func(*string) ([]byte, error)) []byte {
+func responseInterceptor(response *Response, retry func(*string) (*Response, error)) *Response {
 	// 判断是否回复code 401
 	var data ResponseData
-	if err := json.Unmarshal(body, &data); err != nil {
-		zap.L().Warn("ResponseData 解析失败", zap.String("body", string(body)), zap.String("err", err.Error()))
-		return body
+	if err := json.Unmarshal(response.Body, &data); err != nil {
+		zap.L().Warn("ResponseData 解析失败", zap.String("body", string(response.Body)), zap.String("err", err.Error()))
+		return response
 	}
 	if data.Code == 401 {
 		tokenStr := reLogin()
 		retryBody, err := retry(&tokenStr)
 		if err != nil {
 			zap.L().Warn("http retry 失败", zap.String("token", tokenStr), zap.String("err", err.Error()))
-			return body
+			return response
 		}
 		return retryBody
 	}
-	return body
+	return response
 }
 
 func RegisterLogin(login func() string) {
 	reLogin = login
 }
 
-func HttpGet(url string, params url.Values, opt *HttpClientOptions) ([]byte, error) {
+func HttpGet(url string, params url.Values, opt *HttpClientOptions) (*Response, error) {
 	// 拼接参数到 URL
 	finalURL := url + "?" + params.Encode()
 
@@ -90,7 +116,7 @@ func HttpGet(url string, params url.Values, opt *HttpClientOptions) ([]byte, err
 	}
 
 	// 发送请求 - 封装成函数用于重试
-	var reqFunc = func(token *string) ([]byte, error) {
+	var reqFunc = func(token *string) (*Response, error) {
 		client := &http.Client{Timeout: timeout * time.Second}
 		resp, err := client.Do(req)
 		if err != nil {
@@ -99,15 +125,25 @@ func HttpGet(url string, params url.Values, opt *HttpClientOptions) ([]byte, err
 		}
 		defer resp.Body.Close()
 
-		return io.ReadAll(resp.Body)
+		respBody, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return nil, err
+		}
+
+		return &Response{
+			Status:        resp.Status,
+			StatusCode:    resp.StatusCode,
+			Body:          respBody,
+			ContentLength: resp.ContentLength,
+		}, nil
 	}
 
-	body, err := reqFunc(nil)
+	response, err := reqFunc(nil)
 	if err != nil {
 		return nil, err
 	}
 
-	return responseInterceptor(body, reqFunc), nil
+	return responseInterceptor(response, reqFunc), nil
 }
 
 // toPostBody 将任意 body 转换为 http POST 可用的 io.Reader
@@ -149,7 +185,7 @@ func toPostBody(body any) (*PostBody, error) {
 	}
 }
 
-func HttpPost(url string, body any, opt *HttpClientOptions) ([]byte, error) {
+func HttpPost(url string, body any, opt *HttpClientOptions) (*Response, error) {
 	// 转换body数据
 	payload, err := toPostBody(body)
 	if err != nil {
@@ -186,7 +222,7 @@ func HttpPost(url string, body any, opt *HttpClientOptions) ([]byte, error) {
 	}
 
 	// 发送请求 - 封装成函数用于重试
-	var reqFunc = func(token *string) ([]byte, error) {
+	var reqFunc = func(token *string) (*Response, error) {
 		client := &http.Client{Timeout: timeout * time.Second}
 		resp, err := client.Do(req)
 		if err != nil {
@@ -195,12 +231,22 @@ func HttpPost(url string, body any, opt *HttpClientOptions) ([]byte, error) {
 		}
 		defer resp.Body.Close()
 
-		return io.ReadAll(resp.Body)
+		respBody, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return nil, err
+		}
+
+		return &Response{
+			Status:        resp.Status,
+			StatusCode:    resp.StatusCode,
+			Body:          respBody,
+			ContentLength: resp.ContentLength,
+		}, nil
 	}
-	respBody, err := reqFunc(nil)
+	response, err := reqFunc(nil)
 	if err != nil {
 		return nil, err
 	}
 
-	return responseInterceptor(respBody, reqFunc), nil
+	return responseInterceptor(response, reqFunc), nil
 }

+ 114 - 0
service/utils/minio/minio.go

@@ -0,0 +1,114 @@
+package minio
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/config"
+	"io"
+	"log"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/minio/minio-go/v7"
+	"github.com/minio/minio-go/v7/pkg/credentials"
+	"go.uber.org/zap"
+)
+
+var Client *minio.Client
+var BucketName string
+
+func InitClient(cfg *config.Config) {
+	var err error
+	Client, err = minio.New(cfg.MinIoEndpoint, &minio.Options{
+		Creds:  credentials.NewStaticV4(cfg.MinioAccessKey, cfg.MinioSecretKey, ""),
+		Secure: strings.HasPrefix(strings.ToLower(cfg.MinIoEndpoint), "https://"),
+	})
+	if err != nil {
+		log.Fatalln("❌ Failed to init MinIO client")
+		panic(err)
+	}
+	BucketName = cfg.MinioBucketName
+
+	ctx := context.Background()
+	bucketInfo, err := Client.ListBuckets(ctx)
+	if err != nil {
+		log.Fatalln("❌ List Buckets failed")
+		panic(err)
+	}
+	log.Println("Exists Bucket:")
+	for _, info := range bucketInfo {
+		log.Printf("%s\n", info.Name)
+	}
+	exists, err := Client.BucketExists(ctx, BucketName)
+	if err != nil {
+		log.Fatalf("❌ Bucket check failed: %s\n", BucketName)
+		panic(err)
+	}
+	if !exists {
+		err = Client.MakeBucket(ctx, BucketName, minio.MakeBucketOptions{})
+		if err != nil {
+			log.Fatalln("❌ Failed to create bucket")
+			panic(err)
+		}
+		log.Println("✅ Created bucket:", BucketName)
+	} else {
+		log.Println("✅ Using existing bucket:", BucketName)
+	}
+}
+
+func UploadFile(localPath, objectName string) error {
+	ctx := context.Background()
+	info, err := Client.FPutObject(ctx, BucketName, objectName, localPath, minio.PutObjectOptions{})
+	if err != nil {
+		return err
+	}
+	zap.L().Info(fmt.Sprintf("✅ Uploaded %s (%d bytes)", info.Key, info.Size))
+	return nil
+}
+
+func DownloadFile(objectName, localPath string) error {
+	ctx := context.Background()
+	return Client.FGetObject(ctx, BucketName, objectName, localPath, minio.GetObjectOptions{})
+}
+
+func ListFiles() ([]string, error) {
+	ctx := context.Background()
+	var result []string
+	for obj := range Client.ListObjects(ctx, BucketName, minio.ListObjectsOptions{Recursive: true}) {
+		if obj.Err != nil {
+			return nil, obj.Err
+		}
+		result = append(result, obj.Key)
+	}
+	return result, nil
+}
+
+func DeleteFile(objectName string) error {
+	ctx := context.Background()
+	return Client.RemoveObject(ctx, BucketName, objectName, minio.RemoveObjectOptions{})
+}
+
+func GetObject(objectName string) (*minio.Object, error) {
+	ctx := context.Background()
+	return Client.GetObject(ctx, BucketName, objectName, minio.GetObjectOptions{})
+}
+
+// 生成临时下载链接
+func GetPresignedURL(objectName string, expire time.Duration) (*url.URL, error) {
+	ctx := context.Background()
+	return Client.PresignedGetObject(ctx, BucketName, objectName, expire, nil)
+}
+
+// 从流上传(用于HTTP上传)
+func UploadFromReader(file io.Reader, size int64, objectName, contentType string) error {
+	ctx := context.Background()
+	info, err := Client.PutObject(ctx, BucketName, objectName, file, size, minio.PutObjectOptions{
+		ContentType: contentType,
+	})
+	if err != nil {
+		return err
+	}
+	zap.L().Info(fmt.Sprintf("✅ Uploaded %s (%d bytes)", info.Key, info.Size))
+	return nil
+}

+ 188 - 0
src/assets/data/mqttLogNameList.json

@@ -0,0 +1,188 @@
+{
+  "cloudLog": {
+    "logType": {
+      "0": "设备连接中断 - 0",
+      "101": "登录上行命令 - 101",
+      "102": "登录应答下行命令 - 102",
+      "103": "设备绑定解绑上行命令 - 103",
+      "104": "设备绑定解绑应答下行命令 - 104",
+      "105": "设备心跳上行数据 - 105",
+      "106": "应答云平台心跳下行数据 - 106",
+      "107": "设备参数更新上行数据 - 107",
+      "108": "应答设备参数更新下行数据 - 108",
+      "109": "云平台更新设备参数下行数据 - 109",
+      "110": "应答云平台更新设备参数上行数据 - 110",
+      "201": "用户实时数据 - 201",
+      "202": "厂商实时数据上传通知 - 202",
+      "203": "实时数据召唤 - 203",
+      "204": "实时数据召唤应答 - 204",
+      "205": "当前告警上传 - 205",
+      "206": "当前告警上传应答 - 206",
+      "207": "告警日志上传 - 207",
+      "208": "告警日志上传应答 - 208",
+      "209": "用户实时数据离线后补传命令 - 209",
+      "210": "用户实时数据离线后补传应答 - 210",
+      "211": "设备实时数据召唤下行数据 - 211",
+      "212": "设备实时数据召唤上行数据 - 212",
+      "213": "告警日志文件上传通知 - 213",
+      "214": "当前告警集合上行数据 - 214",
+      "215": "应答当前告警集合下行数据 - 215",
+      "216": "当前告警集合查询下行数据 - 216",
+      "217": "应答当前告警集合查询上行数据 - 217",
+      "218": "告警监控快照文件上传通知 - 218",
+      "219": "应答告警监控快照文件上传 - 219",
+      "220": "系统资源监控数据上行数据 - 220",
+      "221": "设备数据主动上传 - 221",
+      "223": "设备数据补传通知上行数据 - 223",
+      "224": "应答设备数据补传下行数据 - 224",
+      "227": "设备实时上传数据 - 227",
+      "229": "实时数据补传上行 - 229",
+      "230": "实时数据补传下行 - 230",
+      "301": "参数设置 - 301",
+      "302": "参数设置应答 - 302",
+      "303": "参数查询 - 303",
+      "304": "参数查询应答 - 304",
+      "305": "预警模式 - 305",
+      "306": "预警模式应答 - 306",
+      "307": "设备端解除预警模式 - 307",
+      "308": "设备端解除预警模式应答 - 308",
+      "309": "设备事件推送 - 309",
+      "310": "设备事件推送应答 - 310",
+      "311": "设备控制指令设置查询 - 311",
+      "312": "设备控制指令设置查询应答 - 312",
+      "313": "TOU参数配置查询指令 - 313",
+      "314": "TOU参数配置查询指令应答 - 314",
+      "315": "系统设置下行数据 - 315",
+      "316": "系统设置上行数据 - 316",
+      "317": "全局参数配置指令 - 317",
+      "318": "全局参数配置指令应答 - 318",
+      "319": "TOU策略配置查询下行数据 - 319",
+      "320": "应答TOU策略配置查询上行数据 - 320",
+      "321": "Wi-Fi设置查询下行数据 - 321",
+      "322": "应答Wi-Fi设置查询上行数据 - 322",
+      "323": "发电机参数设置查询下行数据 - 323",
+      "324": "应答发电机参数设置查询上行数据 - 324",
+      "325": "光伏参数设置查询下行数据 - 325",
+      "326": "应答光伏参数设置查询上行数据 - 326",
+      "327": "aPower灯开关控制下行数据 - 327",
+      "328": "应答aPower灯开关控制上行数据 - 328",
+      "329": "自动编址下行数据 - 329",
+      "330": "应答自动编址上行数据 - 330",
+      "331": "自动编址结果查询下行数据 - 331",
+      "332": "应答自动编址结果查询上行数据 - 332",
+      "333": "调试模式下行数据 - 333",
+      "334": "应答调试模式上行数据 - 334",
+      "335": "Wi-Fi热点扫描下行数据 - 335",
+      "336": "应答Wi-Fi热点扫描上行数据 - 336",
+      "337": "Wi-Fi热点账号查询设置下行数据 - 337",
+      "338": "应答Wi-Fi热点账号查询设置上行数据 - 338",
+      "339": "连接状态查询下行数据 - 339",
+      "340": "应答连接状态查询上行数据 - 340",
+      "341": "网终开关查询设置下行数据 - 341",
+      "342": "应答网终开关查询设置上行数据 - 342",
+      "343": "电池控制下行数据 - 343",
+      "344": "应答电池控制上行数据 - 344",
+      "345": "电网设置下行数据 - 345",
+      "346": "应答电网设置上行数据 - 346",
+      "347": "电网事件结果通知上行数据 - 347",
+      "348": "应答电网事件结果下行数据 - 348",
+      "349": "主动离网数据下行 - 349",
+      "350": "应答主动离网数据上行 - 350",
+      "351": "主动离网结果通知上行数据 - 351",
+      "352": "应答主动离网结果通知下行数据 - 352",
+      "353": "系统参数查询设置下行数据 - 353",
+      "354": "系统参数查询设置上行数据 - 354",
+      "355": "能量调度策略查询 - 355",
+      "356": "能量调度策略查询应答 - 356",
+      "357": "能量调度策略增加和删除设置 - 357",
+      "358": "能量调度策略增加和删除设置应答 - 358",
+      "359": "系统参数查询设置 - 359",
+      "360": "系统参数查询设置应答 - 360",
+      "361": "网络通道检测设置 - 361",
+      "362": "网络通道检测设置应答 - 362",
+      "363": "网络通道检测结果查询 - 363",
+      "364": "网络通道检测结果查询应答 - 364",
+      "365": "Swell注册/注销设置 - 365",
+      "366": "Swell注册/注销设置应答 - 366",
+      "367": "Swell应用程序状态查询 - 367",
+      "368": "Swell应用程序状态查询应答 - 368",
+      "369": "Modbus参数权限的查询设置 - 369",
+      "370": "Modbus参数权限的查询设置应答 - 370",
+      "371": "同步点表配置请求 - 371",
+      "372": "同步点表配置请求应答 - 372",
+      "373": "点表配置表增删查命令 - 373",
+      "374": "点表配置表增删查命令应答 - 374",
+      "375": "点表响应设置命令 - 375",
+      "376": "点表响应设置应答 - 376",
+      "377": "故障录波实时数据获取 - 377",
+      "378": "故障录波信息应答 - 378",
+      "379": "故障录波信息上传 - 379",
+      "380": "故障录波信息上传应答 - 380",
+      "381": "设备休眠配置命令 - 381",
+      "382": "设备休眠命令应答 - 382",
+      "383": "休眠/退出休眠结果上报 - 383",
+      "384": "休眠/退出休眠结果上报应答 - 384",
+      "385": "设备退出休眠/取消充电休眠配置命令 - 385",
+      "386": "设备退出休眠/取消充电休眠配置命令应答 - 386",
+      "387": "智能负载开关查询设置 - 387",
+      "388": "智能负载开关查询设置应答 - 388",
+      "389": "智能负载开关实时数据查询命令 - 389",
+      "390": "智能负载开关实时数据查询应答 - 390",
+      "401": "日功率曲线电量召唤 - 401",
+      "402": "日功率曲线电量召唤应答 - 402",
+      "403": "电量历史报表召唤(月,年) - 403",
+      "404": "电量历史报表召唤应答 - 404",
+      "405": "备电日志上传 - 405",
+      "406": "备电日志上传应答 - 406",
+      "407": "历史日功率曲线电量报表上传通知 - 407",
+      "408": "历史日功率曲线电量报表上传应答 - 408",
+      "409": "电量历史报表上传通知(月,年) - 409",
+      "410": "电量历史报表上传应答 - 410",
+      "411": "日电量统计上传上行数据 - 411",
+      "412": "应答日电量统计上传下行数据 - 412",
+      "413": "设备模式变化时上传 - 413",
+      "414": "应答设备模式变化下行数据 - 414",
+      "501": "IBG设备安装信息主动上传 - 501",
+      "502": "IBG设备安装信息主动上传应答 - 502",
+      "503": "IBG设备安装信息召唤 - 503",
+      "504": "IBG设备安装信息上报 - 504",
+      "505": "设备升级指令 - 505",
+      "506": "设备升级应答 - 506",
+      "507": "设备升级结果 - 507",
+      "508": "设备升级结果应答 - 508",
+      "509": "设备安装信息主动上传上行数据 - 509",
+      "510": "设备安装信息主动上传下行数据 - 510",
+      "511": "设备升级文件下载信息下发 - 511",
+      "512": "设备升级文件下载信息响应上行数据 - 512",
+      "513": "设备升级文件下载完成上行数据 - 513",
+      "514": "应答设备升级文件下载完成下行数据 - 514",
+      "515": "特殊升级启动信息上报上行数据 - 515",
+      "516": "特殊升级启动信息上报应答下行数据 - 516",
+      "517": "设备编址后参数更新上行数据 - 517",
+      "518": "应答设备编址后参数下行数据 - 518",
+      "601": "调试参数下发 - 601",
+      "602": "调试参数下发结果 - 602",
+      "603": "设备日志召唤 - 603",
+      "604": "设备日志召唤应答 - 604",
+      "605": "设备日志上传结果 - 605",
+      "606": "设备日志上传结果应答 - 606",
+      "607": "故障录波召唤下行数据 - 607",
+      "608": "故障录波召唤应答上行数据 - 608",
+      "609": "故障录波文件上传结果 - 609",
+      "610": "故障录波文件上传结果应答 - 610",
+      "611": "模块日志召唤 - 611",
+      "612": "模块日志召唤应答 - 612",
+      "613": "模块日志文件上传结果 - 613",
+      "614": "模块日志文件上传结果应答 - 614",
+      "701": "设备数据主动上传通知上行数据 - 701",
+      "702": "应答设备数据补传下行数据 - 702",
+      "703": "设备数据主动上传通知上行数据 - 703",
+      "704": "应答设备数据补传下行数据 - 704",
+      "705": "设备数据主动上传通知下行数据 - 705",
+      "706": "应答设备数据补传下行数据 - 706",
+      "801": "sunnova数据周期上传 - 801",
+      "802": "sunnova数据主动上传使能按钮 - 802",
+      "803": "应答sunnova数据设置查询 - 803"
+    }
+  }
+}

+ 38 - 5
src/service/batchDownload/index.ts

@@ -1,14 +1,22 @@
 // API List
-// /batchDownload/list - GET
+// /batchDownload/manage/list - GET
+// /batchDownload/manage/config - POST 创建新配置
+// /batchDownload/manage/description/{id} - POST 更新任务说明
+// /batchDownload/manage/config/{id} - DELETE 删除配置
+// /batchDownload/data/{id} - GET 获取指定ID的数据包下载地址
 
 import http from '@/service/serverReq'
-import type { BatchDownloadFilter, BatchDownloadItemPage } from './interface'
+import type { BatchDownloadFilter, BatchDownloadItemPage, BatchDownloadConfig } from './interface'
 
 const API_List = {
-  batchDownloadList: '/batchDownload/list',
+  batchDownloadList: '/batchDownload/manage/list',
+  createBatchDownload: '/batchDownload/manage/config',
+  updateBatchDownloadDescription: '/batchDownload/manage/description/{id}',
+  deleteBatchDownload: '/batchDownload/manage/config/{id}',
+  getBatchDownloadPackage: '/batchDownload/data/{id}',
 }
 
-// GET 后台任务信息
+// GET 批量数据下载管理表
 export const getBatchDownloadList = async (
   pageSize: number,
   pageNum: number,
@@ -19,8 +27,33 @@ export const getBatchDownloadList = async (
       pageSize,
       pageNum,
       sn: filter ? (filter.sn ?? '') : '',
-      type: filter ? (filter.type ?? '') : '',
+      progress: filter ? (filter.progress ?? '') : '',
     },
   })
   return res
 }
+
+// POST 创建新配置
+export const createBatchDownload = async (config: BatchDownloadConfig) => {
+  const url = API_List.createBatchDownload
+  const res = await http.post(url, config)
+}
+
+// POST 更新指定id配置说明
+export const updateBatchDownloadDescription = async (id: string, description: string) => {
+  const url = API_List.updateBatchDownloadDescription.replace('{id}', encodeURIComponent(id))
+  const res = await http.post(url, description)
+}
+
+// DELETE 删除指定id配置
+export const deleteBatchDownload = async (id: string) => {
+  const url = API_List.deleteBatchDownload.replace('{id}', encodeURIComponent(id))
+  const res = await http.delete(url)
+}
+
+// GET 获取批量下载数据地址
+export const getBatchDownloadPackage = async (id: string): Promise<string> => {
+  const url = API_List.getBatchDownloadPackage.replace('{id}', encodeURIComponent(id))
+  const res = await http.get<string>(url)
+  return res
+}

+ 72 - 43
src/service/batchDownload/interface.ts

@@ -2,56 +2,85 @@ import type { ResPage } from '../interface'
 
 export interface BatchDownloadFilter {
   sn?: string
-  type?: string
+  progress?: string
 }
 
-export interface BatchDownloadItemPage extends ResPage<BatchDownloadItem[]> {}
+export interface BatchDownloadConfig {
+  sn: string[]
+  description: string
+  timePeriod: [string, string] | null
 
-export type BatchDownloadItem = BatchDownloadRealtimeData | BatchDownloadHistoryData
-
-export interface BatchDownloadData {
-  id: string
-  snList: string[]
-  result: string // 任务处理结果
+  options: BatchDownloadOptions
 }
 
-export interface BatchDownloadRealtimeData extends BatchDownloadData {
-  type: 'realtime'
-  options: BatchDownloadRealtimeOptions
+export interface BatchDownloadOptions {
+  powerFlow: boolean
+  mqttLogs: string[]
+  eventAlarm: boolean
+  alarmSnapshot: boolean
+  emsLogs: string[]
+  faultRecord: boolean
+  updateLog: boolean
+  settings: boolean
 }
 
-export interface BatchDownloadRealtimeOptions {
-  statusInfo: boolean // 站点当前状态
-  powerFlow: boolean // 当前状态功率流截图
-  eventAlarm: boolean // 当前状态事件和告警
-  configInfo: {
-    // 站点设置数据
-    gridProfile: boolean // 并网合规配置
-    accessories: boolean // 附件配置
-    systemParam: boolean // 系统参数
-    devPara: {
-      // 设备参数VID/PID/CID
-      emsVid: number[]
-      emsPid: number[]
-      peCid: number[]
-      bmsVid: number[]
-      bmsPid: number[]
-    }
-  }
-}
+export interface BatchDownloadItemPage extends ResPage<BatchDownloadInfo[]> {}
 
-export interface BatchDownloadHistoryData extends BatchDownloadData {
-  type: 'history'
-  options: BatchDownloadHistoryOptions
+export interface BatchDownloadInfo {
+  id: string
+  sn: string[]
+  description: string
+  timePeriod: [string, string]
+  options: BatchDownloadOptions
+  progress: string
+  failReasons: string[]
 }
 
-export interface BatchDownloadHistoryOptions {
-  powerFlow: boolean // 功率流截图
-  energyStatistics: boolean // 能量数据统计
-  mpttLog: string[] // 运行通讯日志
-  eventAlarm: boolean // 事件和告警数据
-  emsLog: string[] // EMS日志
-  emsModuleLog: string[] // EMS模块日志
-  faultRecord: boolean // 故障录波
-  updateRecord: boolean // 升级记录
-}
+// export type BatchDownloadItem = BatchDownloadRealtimeData | BatchDownloadHistoryData
+
+// export interface BatchDownloadData {
+//   id: string
+//   snList: string[]
+//   result: string // 任务处理结果
+// }
+
+// export interface BatchDownloadRealtimeData extends BatchDownloadData {
+//   type: 'realtime'
+//   options: BatchDownloadRealtimeOptions
+// }
+
+// export interface BatchDownloadRealtimeOptions {
+//   statusInfo: boolean // 站点当前状态
+//   powerFlow: boolean // 当前状态功率流截图
+//   eventAlarm: boolean // 当前状态事件和告警
+//   configInfo: {
+//     // 站点设置数据
+//     gridProfile: boolean // 并网合规配置
+//     accessories: boolean // 附件配置
+//     systemParam: boolean // 系统参数
+//     devPara: {
+//       // 设备参数VID/PID/CID
+//       emsVid: number[]
+//       emsPid: number[]
+//       peCid: number[]
+//       bmsVid: number[]
+//       bmsPid: number[]
+//     }
+//   }
+// }
+
+// export interface BatchDownloadHistoryData extends BatchDownloadData {
+//   type: 'history'
+//   options: BatchDownloadHistoryOptions
+// }
+
+// export interface BatchDownloadHistoryOptions {
+//   powerFlow: boolean // 功率流截图
+//   energyStatistics: boolean // 能量数据统计
+//   mpttLog: string[] // 运行通讯日志
+//   eventAlarm: boolean // 事件和告警数据
+//   emsLog: string[] // EMS日志
+//   emsModuleLog: string[] // EMS模块日志
+//   faultRecord: boolean // 故障录波
+//   updateRecord: boolean // 升级记录
+// }

+ 1 - 1
src/service/sitesAnalysis/index.ts

@@ -57,7 +57,7 @@ export const updateManageConfig = async (id: string, config: AnalysisConfig) =>
   const res = await http.post(url, config)
 }
 
-// POST 更新指定id配置
+// POST 更新指定id配置说明
 export const updateManageConfigDescription = async (id: string, description: string) => {
   const url = API_List.manageConfigDescriptionWithID.replace('{id}', encodeURIComponent(id))
   const res = await http.post(url, description)

+ 202 - 0
src/views/batch-download/ModalData.vue

@@ -0,0 +1,202 @@
+<template>
+  <div class="p-2">
+    <NForm :model="form" :rules="rules" label-placement="left" style="width: 100%" ref="formRef" label-width="88px"
+      size="small">
+      <NFormItem label="SN" path="snList">
+        <NInput type="textarea" v-model:value="form.snList" :autosize="{ minRows: 1, maxRows: 4 }" />
+      </NFormItem>
+      <NFormItem label=" 描述" path="description">
+        <NInput type="textarea" v-model:value="form.description" />
+      </NFormItem>
+      <NFormItem label="时间" path="timePeriod">
+        <NDatePicker v-model:formatted-value="form.timePeriod" value-format="yyyy-MM-dd" type="daterange" clearable />
+      </NFormItem>
+      <NDivider title-placement="left">
+        配置选项
+      </NDivider>
+      <NFormItem label="功率流图" path="powerFlow">
+        <NSwitch v-model:value="form.options.powerFlow">
+          <template #checked-icon>
+            <i class="i-ph:check-bold" />
+          </template>
+          <template #unchecked-icon>
+            <i class="i-ph:x-bold" />
+          </template>
+        </NSwitch>
+      </NFormItem>
+      <NFormItem label="MQTT日志" path="mqttLogs">
+        <NSelect v-model:value="form.options.mqttLogs" multiple filterable :options="optionsMqttLogs" />
+      </NFormItem>
+      <NFormItem label="事件&告警" path="eventAlarm">
+        <NSwitch v-model:value="form.options.eventAlarm">
+          <template #checked-icon>
+            <i class="i-ph:check-bold" />
+          </template>
+          <template #unchecked-icon>
+            <i class="i-ph:x-bold" />
+          </template>
+        </NSwitch>
+      </NFormItem>
+      <NFormItem label="告警快照" path="alarmSnapshot">
+        <NSwitch v-model:value="form.options.alarmSnapshot">
+          <template #checked-icon>
+            <i class="i-ph:check-bold" />
+          </template>
+          <template #unchecked-icon>
+            <i class="i-ph:x-bold" />
+          </template>
+        </NSwitch>
+      </NFormItem>
+      <NFormItem label="EMS日志" path="emsLogs">
+        <NSelect v-model:value="form.options.emsLogs" multiple :options="optionsEmsLogs" />
+      </NFormItem>
+      <NFormItem label="故障录波" path="faultRecord">
+        <NSwitch v-model:value="form.options.faultRecord">
+          <template #checked-icon>
+            <i class="i-ph:check-bold" />
+          </template>
+          <template #unchecked-icon>
+            <i class="i-ph:x-bold" />
+          </template>
+        </NSwitch>
+      </NFormItem>
+      <NFormItem label="升级记录" path="updateLog">
+        <NSwitch v-model:value="form.options.updateLog">
+          <template #checked-icon>
+            <i class="i-ph:check-bold" />
+          </template>
+          <template #unchecked-icon>
+            <i class="i-ph:x-bold" />
+          </template>
+        </NSwitch>
+      </NFormItem>
+      <NFormItem label="站点设置" path="settings">
+        <NSwitch v-model:value="form.options.settings" disabled>
+          <template #checked-icon>
+            <i class="i-ph:check-bold" />
+          </template>
+          <template #unchecked-icon>
+            <i class="i-ph:x-bold" />
+          </template>
+        </NSwitch>
+      </NFormItem>
+    </NForm>
+    <div class="flex justify-end gap-x-4">
+      <NButton secondary @click="emits('cancel')">
+        取消
+      </NButton>
+      <NButton secondary type="warning" @click="reset">
+        重置
+      </NButton>
+      <NButton type="success" @click="handleSubmitClick">
+        确定
+      </NButton>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, useTemplateRef } from 'vue'
+import { NForm, NFormItem, NInput, NButton, NSwitch, NSelect, NDatePicker, NDivider } from 'naive-ui'
+import type { FormRules, SelectOption } from 'naive-ui'
+
+import { useResettableReactive } from '@/composable/useResettable'
+
+import type { BatchDownloadConfig } from '@/service/batchDownload/interface'
+import mqttLogNameList from '@/assets/data/mqttLogNameList.json'
+
+interface BatchDownloadConfigForm extends Omit<BatchDownloadConfig, "sn"> {
+  snList: string // SN列表,分隔符可以是换行、‘,’、空格
+}
+
+const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
+
+const emits = defineEmits<{
+  (e: 'submit', value: BatchDownloadConfig): void
+  (e: 'cancel'): void
+}>()
+
+const [form, setUserForm, reset, updateInitialState] = useResettableReactive<BatchDownloadConfigForm>({
+  snList: '',
+  description: '',
+  timePeriod: null,
+
+  options: {
+    powerFlow: false,
+    mqttLogs: [],
+    eventAlarm: false,
+    alarmSnapshot: false,
+    emsLogs: [],
+    faultRecord: false,
+    updateLog: false,
+    settings: false
+  }
+})
+
+const optionsMqttLogs = Object.entries(mqttLogNameList.cloudLog.logType).map<SelectOption>(([key, value]) => {
+  return {
+    label: value,
+    value: key
+  }
+})
+// 4-IBG运行日志 7-电池日志 9-设备系统资源监控日志 10-4G日志 11-AWS日志 12-主进程日志
+const optionsEmsLogs = [
+  {label: "IBG运行日志", value: 'EmsRunLog'},
+  {label: "电池日志", value: 'EmsBattLog'},
+  {label: "设备系统资源监控日志", value: 'EmsMonitorLog'},
+  {label: "4G日志", value: 'Ems4gLog'},
+  {label: "AWS日志", value: 'EmsAwsLog'},
+  {label: "主进程日志", value: 'EmsMainLog'},
+]
+
+const handleAllowInput = (value: string) => {
+  // 如果输入不匹配 URL 允许的字符,去掉不合法字符
+  const res = /\W/.test(value)
+  return !res
+};
+
+const rules: FormRules = {
+  sn: { required: true, message: '请输入站点SN', trigger: ['input'] },
+  timePeriod: [
+    // { required: true, message: '请选择时间范围', trigger: ['change', 'blur'] },
+    {
+      validator: (rule, value: [string, string] | null) => {
+        // ✅ 先判断是否有值
+        if (!value || !value[0] || !value[1]) {
+          return Promise.reject('请选择时间范围')
+        }
+        // ✅ 再判断时间先后
+        const [start, end] = value
+        if (!start || !end) return false
+        // 将字符串转成 Date 再比较
+        const startTime = new Date(start.replace(/\./g, '-')).getTime()
+        const endTime = new Date(end.replace(/\./g, '-')).getTime()
+        if (endTime < startTime) {
+          return Promise.reject('结束时间必须晚于开始时间')
+        }
+        return Promise.resolve()
+      },
+      trigger: ['change', 'blur']
+    }
+  ],
+}
+
+const handleSubmitClick = () => {
+  formRef.value?.validate().then((valid) => {
+    if (valid) {
+      const { snList, ...otherObj } = form
+      const sn = form.snList
+        .split(/[\s,]+/) // 按空格、换行符、逗号等分割
+        .map(s => s.trim()) // 去除首尾空格
+        .filter(Boolean) // 过滤空字符串
+      console.log({ sn, snList: form.snList })
+      emits('submit', {
+        sn,
+        ...otherObj
+      })
+    }
+  })
+}
+
+onMounted(async () => { })
+</script>

+ 85 - 24
src/views/batch-download/index.vue

@@ -6,12 +6,12 @@
           <NFormItem label="站点" path="sn">
             <NInput v-model:value="form.sn" style="width: 250px" clearable />
           </NFormItem>
-          <NFormItem label="类型" path="type">
-            <NSelect v-model:value="form.type" :options="dataTypeOptions" style="width: 120px" clearable />
+          <NFormItem label="进度" path="progress">
+            <NSelect v-model:value="form.progress" :options="progressOptions" style="width: 120px" clearable />
           </NFormItem>
         </NForm>
         <div class="flex gap-2">
-          <NButton type="success" disabled>
+          <NButton type="success" @click="createBatchDownload">
             <template #icon>
               <span class="i-ph:plus-circle" />
             </template>
@@ -57,12 +57,13 @@
 
 <script setup lang="tsx">
 import { useRouter } from 'vue-router'
-import { NButton, NDataTable, NForm, NNumberAnimation, NInput } from 'naive-ui'
+import { NButton, NDataTable, NForm, NNumberAnimation, NInput, NEllipsis } from 'naive-ui'
 import type { DataTableColumns, PaginationProps, DropdownProps } from 'naive-ui'
 import { useDataTable } from '@/composable/comp/useDataTable'
 import { useResettableReactive } from '@/composable/useResettable'
-import { getBatchDownloadList } from '@/service/batchDownload'
-import type { BatchDownloadFilter, BatchDownloadItem } from '@/service/batchDownload/interface'
+import { getBatchDownloadList, createBatchDownload as createBatchDownloadTask } from '@/service/batchDownload'
+import type { BatchDownloadFilter, BatchDownloadInfo, BatchDownloadConfig } from '@/service/batchDownload/interface'
+import ModalData from './ModalData.vue'
 
 defineOptions({
   name: 'BatchDownload',
@@ -71,6 +72,7 @@ defineOptions({
 const router = useRouter()
 
 const message = useMessage()
+const modal = useModal()
 const dialog = useDialog()
 
 const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
@@ -80,7 +82,7 @@ const { maxHeight } = useDataTable(dataTableRef)
 const isRequestLoading = ref(false)
 const showDropdown = ref(false)
 const checkedRowKeys = ref<Array<number | string>>([])
-const contextmenu = ref<BatchDownloadItem | undefined>(undefined)
+const contextmenu = ref<BatchDownloadInfo | undefined>(undefined)
 const isSelectMultiple = ref(false)
 
 type FilterForm = BatchDownloadFilter
@@ -89,9 +91,12 @@ const [form, , resetForm] = useResettableReactive<FilterForm>({
   sn: undefined,
 })
 
-const dataTypeOptions = [
-  { label: '实时数据', value: 'realtime' },
-  { label: '历史数据', value: 'history' },
+const progressOptions = [
+  { label: 'pending', value: 'pending' },
+  { label: 'running', value: 'running' },
+  { label: 'error', value: 'error' },
+  { label: 'failed', value: 'failed' }
+  { label: 'complete', value: 'complete' },
 ]
 
 const handleQueryClick = () => {
@@ -150,11 +155,50 @@ const dropdownOptions = reactive<DropdownProps>({
   },
 })
 
-const tableRowKey = (rowData: BatchDownloadItem) => {
+function createBatchDownload() {
+  const title = '新增数据批量下载'
+
+  const handleSubmitClick = async (data: BatchDownloadConfig) => {
+    let promise: Promise<void> | undefined
+    promise = createBatchDownloadTask(data)
+    const result = await promise?.then(async () => {
+      handleListQuery()
+      message.success('创建成功')
+      return true
+    }).catch((err) => {
+      message.error(`请求失败。\n${err}`)
+      return false
+    })
+
+    if (!result) return
+    m.destroy()
+  }
+
+  function handleCancelClick() {
+    m.destroy()
+  }
+
+  const m = modal.create({
+    title,
+    preset: 'card',
+    draggable: true,
+    style: {
+      width: '500px',
+    },
+    content: () => (
+      <ModalData
+        onSubmit={handleSubmitClick}
+        onCancel={handleCancelClick}
+      />
+    ),
+  })
+}
+
+const tableRowKey = (rowData: BatchDownloadInfo) => {
   return rowData.id
 }
 
-function createColumns(): DataTableColumns<BatchDownloadItem> {
+function createColumns(): DataTableColumns<BatchDownloadInfo> {
   return [
     {
       type: 'selection',
@@ -162,18 +206,35 @@ function createColumns(): DataTableColumns<BatchDownloadItem> {
     },
     {
       title: 'SN',
-      key: 'snList',
+      key: 'sn',
       width: 200,
-      fixed: 'left'
+      fixed: 'left',
+      render(rowData, rowIndex) {
+        return h(NEllipsis, {
+          lineClamp: 3,
+          tooltip: {
+            placement: "right-start",   // ⬅️ Tooltip 显示在右侧
+            style: {
+              maxHeight: "200px",       // ⬅️ 多行时滚动
+              overflowY: "auto",
+              whiteSpace: "pre-line"    // ⬅️ \n 正常换行
+            }
+          }
+        },
+          {
+            default: () =>
+              h("span", { style: { whiteSpace: "pre-line" } }, rowData.sn.join("\n"))
+          })
+      },
     },
     {
-      title: '类型',
-      key: 'type',
-      width: 50,
+      title: '时间范围',
+      key: 'timeRange',
+      width: 100,
     },
     {
-      title: '选项',
-      key: 'type',
+      title: '进度',
+      key: 'progress',
     },
     {
       title: '任务结果',
@@ -190,7 +251,7 @@ function createColumns(): DataTableColumns<BatchDownloadItem> {
             type: "success",
             strong: true,
             size: 'small',
-            disabled: true,
+            disabled: row.progress === "pending" || row.progress === "running",
             onClick: () => itemDownload(row.id)
           }, {
             default: () => '下载'
@@ -199,7 +260,7 @@ function createColumns(): DataTableColumns<BatchDownloadItem> {
             type: "error",
             strong: true,
             size: 'small',
-            disabled: true,
+            disabled: row.progress === "pending" || row.progress === "running",
             onClick: () => itemDelete(row.id)
           }, {
             default: () => '删除'
@@ -211,7 +272,7 @@ function createColumns(): DataTableColumns<BatchDownloadItem> {
 }
 
 // 表格右键菜单
-function rowProps(row: BatchDownloadItem) {
+function rowProps(row: BatchDownloadInfo) {
   return {
     onContextmenu: (e: MouseEvent) => {
       e.preventDefault()
@@ -226,7 +287,7 @@ function rowProps(row: BatchDownloadItem) {
   }
 }
 
-const tableData = ref<BatchDownloadItem[]>([])
+const tableData = ref<BatchDownloadInfo[]>([])
 const tableColumns = createColumns()
 const tablePagination = reactive<PaginationProps>({
   page: 1,
@@ -282,7 +343,7 @@ const handleListQuery = async () => {
   // 处理过滤条件
   const filter: FilterForm = {
     sn: form.sn,
-    type: form.type,
+    progress: form.progress,
   }
 
   await queryBatchDownloadList(pageSize, page, filter)

Някои файлове не бяха показани, защото твърде много файлове са промени