ソースを参照

refactor:重构fwhTask,并修复故障录波参数写入报错BUG

Jerry Liang 3 ヶ月 前
親
コミット
b5a1815664

+ 2 - 1
service/.vscode/settings.json

@@ -35,7 +35,8 @@
     "asynqmon",
     "gofiber",
     "hibiken",
-    "cronspec"
+    "cronspec",
+    "decr"
   ],
   "editor.tabSize": 2
 }

+ 4 - 0
service/cache/cache.go

@@ -82,6 +82,10 @@ func Incr(ctx context.Context, key string) (int64, error) {
 	return redisx.Incr(ctx, key)
 }
 
+func Decr(ctx context.Context, key string) (int64, error) {
+	return redisx.Decr(ctx, key)
+}
+
 /********* 哈希操作 *********/
 
 func HSet(ctx context.Context, key string, values ...interface{}) error {

+ 4 - 0
service/cache/redisx/stringx.go

@@ -26,3 +26,7 @@ func MGet(ctx context.Context, keys ...string) ([]interface{}, error) {
 func Incr(ctx context.Context, key string) (int64, error) {
 	return getClient().Incr(ctx, key).Result()
 }
+
+func Decr(ctx context.Context, key string) (int64, error) {
+	return getClient().Decr(ctx, key).Result()
+}

+ 55 - 40
service/jobs/callFwhFile.go

@@ -3,6 +3,7 @@ package jobs
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fwh-toolbox-server/cache"
 	"fwh-toolbox-server/domain"
@@ -120,20 +121,42 @@ func CreateCallFwhFileTask(mainTaskId primitive.ObjectID, taskList []domain.Batc
 }
 
 func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.BatchDownloadFileTaskInfo) {
-	var reason string
+	// var reason string
 	streamName := streamNamePrefix + info.Sn
+	var success bool
+	var failReason string
+
+	// ✅ defer 确保无论走哪条路径,最终结果一定被更新
+	defer func() {
+		if success {
+			repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, 1, "")
+			// ack stream
+			ackTask(CallFileTaskStreamInfo{
+				Ctx:        callFwhFileCtx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         taskId,
+			}, true, nil)
+		} else {
+			repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, -1, failReason)
+			// ack stream
+			ackTask(CallFileTaskStreamInfo{
+				Ctx:        callFwhFileCtx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         taskId,
+			}, false, errors.New(failReason))
+		}
+	}()
+
 	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()))
+				success = false
+				failReason = fmt.Sprintf("handle task CallEmsLog error - %s", err.Error())
 				return
 			}
 
@@ -205,11 +228,13 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 				return false, fmt.Errorf("读取EMS日志地址请求失败")
 			}, time.Minute, 30*time.Minute) // 每分钟查看,半小时超时
 			if err != nil {
-				reason = err.Error() // 保存失败原因
+				success = false
+				failReason = err.Error() // 保存失败原因
 			}
 			if ok {
 				// 召唤成功
-				repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, 1, "")
+				success = true
+				failReason = ""
 				return
 			} else {
 				zap.L().Warn("Call FWH file失败, 再次尝试", zap.String("count", strconv.Itoa(count)))
@@ -217,12 +242,9 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 		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))
+				// 召唤失败
+				success = false
+				failReason = fmt.Sprintf("handle task CallFaultRecord timeRange error - timeRange: %s", info.Date)
 				return
 			}
 
@@ -232,12 +254,9 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 			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()))
+					// 召唤失败
+					success = false
+					failReason = fmt.Sprintf("handle task CallFaultRecord indexStrList error - indexStrList: %s - err: %s", info.FileSupType, err.Error())
 					return
 				}
 				indexList = append(indexList, idx)
@@ -246,12 +265,9 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 			// 召唤故障录波数据
 			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()))
+				// 召唤失败
+				success = false
+				failReason = fmt.Sprintf("handle task CallFaultRecord error - err: %s", err.Error())
 				return
 			}
 
@@ -311,11 +327,13 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 				return false, fmt.Errorf("读取故障录波地址请求失败")
 			}, time.Minute, 30*time.Minute) // 每分钟查看,半小时超时
 			if err != nil {
-				reason = err.Error() // 保存失败原因
+				success = false
+				failReason = err.Error() // 保存失败原因
 			}
 			if ok {
 				// 召唤成功
-				repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, 1, "")
+				success = true
+				failReason = ""
 				return
 			} else {
 				zap.L().Warn("Call FWH file失败, 再次尝试", zap.String("count", strconv.Itoa(count)))
@@ -325,9 +343,6 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 			logger.Std.Println("Unknown call file type:", info.FileType)
 		}
 	}
-
-	// 召唤失败
-	repositories.UpdateBatchDownloadCallFileResult(mainTaskId.Hex(), taskId, -1, reason)
 }
 
 /************** 任务轮询 **************/
@@ -476,18 +491,18 @@ func handleTaskQueueMsg(sn string, message redis.XMessage) {
 	// }
 
 	// 任务完成
-	ackTask(CallFileTaskStreamInfo{
-		Ctx:        callFwhFileCtx,
-		StreamName: streamName,
-		GroupName:  groupName,
-		ID:         id,
-	}, true, nil)
+	// 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 delErr := cache.XAckDel(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); delErr != nil {
+		logger.Std.Println("XAck error:", delErr)
 	}
 
 	if success {

+ 92 - 125
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go

@@ -101,151 +101,118 @@ func InitFwhMqttClient(cfg *config.FwhMqttConfig) error {
 }
 
 func (c *FwhMqttClient) fwhMqttWorker() {
+	idleTimer := time.NewTimer(sleepTimeout)
+	defer idleTimer.Stop()
+
 	for {
 		select {
 		case task, ok := <-c.TaskCh:
 			if !ok {
 				zap.L().Error("Channel closed, worker exiting, recreate.")
 				c.TaskCh = make(chan MqttMsg, 1000)
-				break
+				continue
 			}
 
-			if err := c.Client.Connect(); err != nil {
-				zap.L().Error("FWH MQTT connect failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-				task.ResultCh <- FwhMqttResult{
-					Err:  fmt.Errorf("FWH MQTT connect failed."),
-					Data: nil,
+			// 重置空闲计时器
+			if !idleTimer.Stop() {
+				select {
+				case <-idleTimer.C:
+				default:
 				}
-				break
 			}
+			idleTimer.Reset(sleepTimeout)
 
-			// 订阅响应消息 - 请求响应无法一一对应,不支持并发请求,改成简单的队列请求
-			// if _, ok := resultChListMap[task.subTopic]; !ok {
-			// 	resultChListMap[task.subTopic] = []*ResultChan{}
-			// 	resultChListMap[task.subTopic] = append(resultChListMap[task.subTopic], &ResultChan{ch: task.resultCh, ctx: task.ctx})
-			// 	if err := client.SubscribeMessage(task.subTopic, func(payload []byte) {
-			// 		var newList []*ResultChan
-			// 		for _, ch := range resultChListMap[task.subTopic] {
-			// 			if err := ch.ctx.Err(); err != nil {
-			// 				switch err {
-			// 				case context.DeadlineExceeded:
-			// 					zap.L().Warn("Waiting for FWH MQTT message timeout.", zap.String("topic", task.topic))
-			// 				case context.Canceled:
-			// 					zap.L().Warn("Waiting for FWH MQTT message cancel.", zap.String("topic", task.topic))
-			// 				}
-			// 			} else {
-			// 				var fwhMqttMsg FwhMqttMsg
-			// 				err := json.Unmarshal(payload, &fwhMqttMsg)
-			// 				if err != nil {
-			// 					zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)", zap.String("topic", task.topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
-			// 					newList = append(newList, ch)
-			// 					continue
-			// 				}
-
-			// 				if task.msg.Snno != fwhMqttMsg.Snno {
-			// 					newList = append(newList, ch)
-			// 					continue
-			// 				}
-
-			// 				ch.ch <- fwhMqttMsg
-			// 			}
-			// 		}
-			// 		resultChListMap[task.subTopic] = newList
-			// 	}); err != nil {
-			// 		zap.L().Error("FWH MQTT subscribe message failed.", zap.String("msg", fmt.Sprintf("%+v", task)))
-			// 		break
-			// 	}
-			// } else {
-			// 	resultChListMap[task.subTopic] = append(resultChListMap[task.subTopic], &ResultChan{ch: task.resultCh, ctx: task.ctx})
-			// }
-
-			var resultCh chan FwhMqttResult
-			if task.ResTopic != nil {
-				resultCh = make(chan FwhMqttResult)
-				if err := c.Client.SubscribeMessage(*task.ResTopic, func(payload []byte) {
-					var fwhMqttMsg FwhMqttMsg
-					if err := json.Unmarshal(payload, &fwhMqttMsg); err != nil {
-						zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)", zap.String("topic", task.Topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
-						// resultCh <- FwhMqttResult{
-						// 	Err:  err,
-						// 	Data: nil,
-						// }
-						return
-					}
-
-					if task.ResCmdType != nil {
-						if fwhMqttMsg.CmdType == *task.ResCmdType {
-							resultCh <- FwhMqttResult{
-								Err:  nil,
-								Data: &fwhMqttMsg,
-							}
-						}
-					} else {
-						resultCh <- FwhMqttResult{
-							Err:  nil,
-							Data: &fwhMqttMsg,
-						}
-					}
-				}); err != nil {
-					zap.L().Error("FWH MQTT subscribe message failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-					task.ResultCh <- FwhMqttResult{
-						Err:  err,
-						Data: nil,
-					}
-					break
-				}
-			}
+			// 处理任务
+			c.handleTask(task)
 
-			msgByte, err := json.Marshal(task.Msg)
-			if err != nil {
-				zap.L().Error("FWH MQTT message marshal failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-				task.ResultCh <- FwhMqttResult{
-					Err:  err,
-					Data: nil,
-				}
-				break
+		case <-idleTimer.C:
+			if c.Client.IsConnected() {
+				zap.L().Info("FWH MQTT no task received within timeout, disconnect.")
+				c.Client.Disconnect()
 			}
+			idleTimer.Reset(sleepTimeout)
+		}
+	}
+}
 
-			if err := c.Client.SendMessageOnce(task.Topic, msgByte); err != nil {
-				zap.L().Error("FWH MQTT send message failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-				task.ResultCh <- FwhMqttResult{
-					Err:  err,
-					Data: nil,
-				}
-				break
+func (c *FwhMqttClient) handleTask(task MqttMsg) {
+	// 1. 建立连接
+	if err := c.Client.Connect(); err != nil {
+		zap.L().Error("FWH MQTT connect failed.",
+			zap.String("status", c.Client.GetClientStatus()),
+			zap.String("err", err.Error()),
+		)
+		task.ResultCh <- FwhMqttResult{Err: fmt.Errorf("FWH MQTT connect failed: %w", err)}
+		return
+	}
+
+	// 2. 如果需要响应,订阅响应 topic
+	var resultCh chan FwhMqttResult
+	if task.ResTopic != nil {
+		resultCh = make(chan FwhMqttResult, 1)
+		if err := c.Client.SubscribeMessage(*task.ResTopic, func(payload []byte) {
+			var fwhMqttMsg FwhMqttMsg
+			if err := json.Unmarshal(payload, &fwhMqttMsg); err != nil {
+				zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)",
+					zap.String("topic", task.Topic),
+					zap.String("payload", string(payload)),
+					zap.String("err", err.Error()),
+				)
+				return
 			}
 
-			if resultCh != nil {
-				select {
-				case res, ok := <-resultCh:
-					if !ok {
-						task.ResultCh <- FwhMqttResult{
-							Err:  fmt.Errorf("FWH MQTT received message channel closed."),
-							Data: nil,
-						}
-						break
-					}
-
-					task.ResultCh <- res
-				case <-task.Ctx.Done():
-					task.ResultCh <- FwhMqttResult{
-						Err:  fmt.Errorf("FWH MQTT received message timeout."),
-						Data: nil,
-					}
-				}
-			} else {
-				task.ResultCh <- FwhMqttResult{
-					Err:  nil,
-					Data: nil,
-				}
+			// 过滤 CmdType
+			if task.ResCmdType != nil && fwhMqttMsg.CmdType != *task.ResCmdType {
+				return
 			}
 
-		case <-time.After(sleepTimeout):
-			if c.Client.IsConnected() {
-				zap.L().Info("FWH MQTT no task received within timeout, disconnect.")
-				c.Client.Disconnect()
+			// 非阻塞写入,防止回调卡死
+			select {
+			case resultCh <- FwhMqttResult{Err: nil, Data: &fwhMqttMsg}:
+			default:
+				zap.L().Warn("[MQTT] resultCh full, dropping duplicate response")
 			}
+		}); err != nil {
+			zap.L().Error("FWH MQTT subscribe failed.", zap.String("err", err.Error()), zap.String("topic", *task.ResTopic))
+			task.ResultCh <- FwhMqttResult{Err: fmt.Errorf("subscribe failed: %w", err)}
+			return
 		}
+
+		// 确保退出时取消订阅
+		defer func() {
+			if err := c.Client.UnSubscribeMessage(*task.ResTopic); err != nil {
+				zap.L().Error("FWH MQTT unsubscribe failed.", zap.String("err", err.Error()), zap.String("topic", *task.ResTopic))
+			}
+		}()
+	}
+
+	// 3. 发送消息
+	msgByte, err := json.Marshal(task.Msg)
+	if err != nil {
+		zap.L().Error("FWH MQTT message marshal failed.", zap.String("err", err.Error()))
+		task.ResultCh <- FwhMqttResult{Err: err}
+		return
+	}
+
+	if err := c.Client.SendMessageOnce(task.Topic, msgByte); err != nil {
+		zap.L().Error("FWH MQTT send message failed.", zap.String("err", err.Error()))
+		task.ResultCh <- FwhMqttResult{Err: err}
+		return
+	}
+
+	// 4. 等待响应 或 直接返回
+	if resultCh == nil {
+		// 只发送,不等响应
+		task.ResultCh <- FwhMqttResult{Err: nil, Data: nil}
+		return
+	}
+
+	// 一问一答:等待响应或超时
+	select {
+	case res := <-resultCh:
+		task.ResultCh <- res
+	case <-task.Ctx.Done():
+		task.ResultCh <- FwhMqttResult{Err: fmt.Errorf("FWH MQTT received message timeout")}
 	}
 }
 

+ 3 - 1
service/services/siteInfoManageServices.go

@@ -48,7 +48,9 @@ func QuerySiteInfoDataList(para dto.SiteInfoRequest) (*dto.ResPage[dto.SiteInfoD
 
 	idList := []string{}
 	for _, data := range res.Data {
-		idList = append(idList, *data.ExtTags...)
+		if data.ExtTags != nil {
+			idList = append(idList, *data.ExtTags...)
+		}
 	}
 	tagMap, err := repositories.GetSiteInfoTagsFromId(idList)
 	if err != nil {

+ 2 - 2
service/services/sitesAnalysisServices.go

@@ -34,7 +34,7 @@ func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite) error
 			EnableTimeline: writeConfig.Options.EnableTimeline,
 			EnableRuntime:  writeConfig.Options.EnableRuntime,
 		},
-	}, false)
+	})
 
 	return nil
 }
@@ -54,7 +54,7 @@ func UpdateSiteAnalysisConfig(id primitive.ObjectID, writeConfig domain.SiteAnal
 			EnableTimeline: writeConfig.Options.EnableTimeline,
 			EnableRuntime:  writeConfig.Options.EnableRuntime,
 		},
-	}, false)
+	})
 
 	return nil
 }

+ 2 - 0
service/task/core/taskCore.go

@@ -16,6 +16,7 @@ type TaskCore struct {
 	client   *asynq.Client
 
 	Scheduler *task_pg.SchedulerManager
+	Inspector *task_pg.InspectorManager
 }
 
 type TaskConfig struct {
@@ -31,6 +32,7 @@ func NewTaskCore(redisOpt asynq.RedisClientOpt) *TaskCore {
 		redisOpt:  redisOpt,
 		client:    asynq.NewClient(redisOpt),
 		Scheduler: task_pg.NewSchedulerManager(redisOpt),
+		Inspector: task_pg.NewInspectorManager(redisOpt),
 	}
 }
 

+ 22 - 0
service/task/plugin/inspector.go

@@ -0,0 +1,22 @@
+package task_pg
+
+import (
+	"github.com/hibiken/asynq"
+)
+
+type InspectorManager struct {
+	redisOpt  asynq.RedisClientOpt
+	inspector *asynq.Inspector
+}
+
+func NewInspectorManager(redisOpt asynq.RedisClientOpt) *InspectorManager {
+	return &InspectorManager{
+		redisOpt:  redisOpt,
+		inspector: asynq.NewInspector(redisOpt),
+	}
+}
+
+// GetInspector 获取底层 Asynq Inspector
+func (im *InspectorManager) GetInspector() *asynq.Inspector {
+	return im.inspector
+}

+ 225 - 0
service/task/queue/fwhQueue/DESIGN.md

@@ -0,0 +1,225 @@
+# fwhQueueNew 设计文档
+
+## 概览
+
+`fwhQueueNew` 是使用 [Asynq](https://github.com/hibiken/asynq) 框架重写的 FWH 任务队列,替代原有基于 Redis Stream 手动实现的 `fwhQueue`。
+
+### 重写目标
+
+1. **消除手动状态管理带来的 bug**(如 `.String()` vs `.Hex()` 导致任务状态不一致)
+2. **统一任务监控**(所有任务可在 Asynqmon Web UI 中查看)
+3. **简化重试逻辑**(利用 Asynq 内置重试机制替代手动 `for` 循环)
+4. **提高可维护性**(声明式任务配置,减少状态同步代码)
+
+---
+
+## 目录结构
+
+```
+task/queue/fwhQueueNew/
+├── fwhTaskQueue.go              # 初始化:Server、Client、Inspector、常量定义
+├── fwhTaskBatchDownload.go      # BatchDownload 主任务 handler
+├── fwhTaskCallFile.go           # CallFile 子任务 handler(EmsLog + FaultRecord)
+├── fwhTaskSiteAnalysis.go       # SiteAnalysis 任务 handler
+├── fwhTaskShutdownStatistics.go # ShutdownStatistics 任务 handler
+├── fwhTaskSiteInfoUpdate.go     # SiteInfoUpdate 任务 handler
+├── fwhEnqueue.go                # 对外暴露的入队函数
+└── DESIGN.md                    # 本文档
+```
+
+---
+
+## 架构设计
+
+### 队列拓扑
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                      Asynq Server                        │
+│                     Concurrency: 6                        │
+│                                                          │
+│  ┌──────────────────┐    ┌───────────────────────────┐  │
+│  │  fwh:main (2)    │    │  fwh:download (4)         │  │
+│  │                  │    │                           │  │
+│  │ • BatchDownload  │    │ • CallFile (EmsLog)       │  │
+│  │ • SiteAnalysis   │    │ • CallFile (FaultRecord)  │  │
+│  │ • ShutdownStats  │    │                           │  │
+│  │ • SiteInfoUpdate │    │                           │  │
+│  └──────────────────┘    └───────────────────────────┘  │
+└─────────────────────────────────────────────────────────┘
+```
+
+- **`fwh:main`**:主队列,2 个 worker,处理业务编排类任务
+- **`fwh:download`**:下载队列,4 个 worker,处理文件下载类任务(可并发)
+
+### 任务类型
+
+| 任务类型       | 常量                       | 队列     | 重试 | 超时   |
+| -------------- | -------------------------- | -------- | ---- | ------ |
+| 批量数据下载   | `fwh:BatchDownload`      | main     | 0    | 120min |
+| 文件下载子任务 | `fwh:CallFile`           | download | 3    | 35min  |
+| 站点分析       | `fwh:SiteAnalysis`       | main     | 0    | 60min  |
+| Shutdown统计   | `fwh:ShutdownStatistics` | main     | 0    | 30min  |
+| 站点信息更新   | `fwh:SiteInfoUpdate`     | main     | 0    | 60min  |
+
+---
+
+## BatchDownload 子任务协调机制
+
+### 原有方案(问题)
+
+```
+旧方案:Redis Set (standby/failed) + Redis Stream per SN
+问题:
+1. mainTaskId 使用 .String() vs .Hex() 不一致导致 key 不匹配
+2. 提前 return 的路径忘记更新 standby → 主任务永远等待
+3. 手动 goroutine per SN 的生命周期管理复杂
+```
+
+### 新方案
+
+```
+新方案:Redis 计数器 + 失败原因列表
+
+入队时:
+  SET fwh:callfile:counter:{mainTaskId} = N (子任务总数)
+
+子任务完成时(无论成功/失败):
+  DECR fwh:callfile:counter:{mainTaskId}
+  如果失败:LPUSH fwh:callfile:failures:{mainTaskId} "失败原因"
+
+主任务轮询:
+  GET fwh:callfile:counter:{mainTaskId}
+  当 counter <= 0 时:
+    LRANGE fwh:callfile:failures:{mainTaskId} 0 -1 → 收集失败原因
+    DEL counter, failures → 清理
+```
+
+**优势**:
+
+- 原子操作(DECR/LPUSH),无竞态
+- 不依赖子任务的执行路径,无论 handler 怎么 return/panic,Asynq 都会管理任务生命周期
+- 结构简单,只有 2 个 Redis key
+
+---
+
+## 重试策略
+
+### CallFile 子任务
+
+```
+Asynq 外层重试(MaxRetry=3):
+  - 第1次失败 → 等待 30s 重试
+  - 第2次失败 → 等待 2min 重试
+  - 第3次失败 → 标记为最终失败,报告失败原因
+
+内部下载重试(maxCallRetry=3):
+  - 针对 HTTP 下载的短暂网络错误
+  - 不涉及重新召唤,只重试下载动作
+```
+
+### 其他任务
+
+业务错误(数据格式错误、API 返回失败等)不重试,handler 返回 `nil` 避免 Asynq 重试。只有基础设施错误(Redis 断连等)才返回 `error` 触发重试。
+
+---
+
+## 与旧 fwhQueue 的对比
+
+| 维度       | fwhQueue (旧)                               | fwhQueueNew (新)                       |
+| ---------- | ------------------------------------------- | -------------------------------------- |
+| 底层       | 手动 Redis Stream                           | Asynq(内部用 Redis List + SortedSet) |
+| 并发控制   | 单 worker 顺序 + 手动 goroutine per SN      | `Concurrency` + `Queues` 声明式    |
+| 重试       | `XAutoClaim` + `XPendingExt` 手动计数   | `MaxRetry(N)` + 指数退避             |
+| 任务去重   | 手动 Redis 分布式锁                         | `asynq.Unique(duration)`             |
+| 任务状态   | 内存变量`runningID`/`failedTaskList`    | Asynq 自动管理                         |
+| 子任务协调 | Redis Set (standby/failed) 手动移动         | Redis 计数器 DECR                      |
+| 监控       | 手动`GetTaskInfoList`                     | Asynqmon Web UI                        |
+| Bug 风险   | 高(多种状态同步路径)                      | 低(结构化保证)                       |
+| 代码量     | ~800 行(fwhTaskQueue.go + callFwhFile.go) | ~500 行(分布在 7 个文件)             |
+
+---
+
+## 迁移指南
+
+### 需要修改的文件
+
+#### 1. `task/queue/taskQueue.go`
+
+```go
+package taskQueue
+
+import (
+    task_core "fwh-toolbox-server/task/core"
+    "fwh-toolbox-server/task/queue/fwhQueueNew"  // 替换 fwhQueue
+    "fwh-toolbox-server/task/queue/simCardQueue"
+    "github.com/hibiken/asynq"
+)
+
+func InitTaskQueue(tc *task_core.TaskCore, redisOpt asynq.RedisClientOpt) {
+    if tc == nil {
+        panic("InitTaskQueue failed, TaskCore is nil.")
+    }
+
+    fwhQueueNew.InitFwhQueueNew(tc, redisOpt)  // 替换 fwhQueue.InitFwhTaskQueue()
+    simCardQueue.InitSimCardTaskQueue(tc)
+}
+```
+
+#### 2. `task/task.go`
+
+```go
+package task
+
+import (
+    task_core "fwh-toolbox-server/task/core"
+    taskQueue "fwh-toolbox-server/task/queue"
+    "github.com/hibiken/asynq"
+)
+
+func InitTask(redisOpt TaskRedisOpt) {
+    redisClientOpt := asynq.RedisClientOpt{
+        Addr:     redisOpt.Addr,
+        DB:       redisOpt.DB,
+        Username: redisOpt.Username,
+        Password: redisOpt.Password,
+    }
+
+    tc := task_core.NewTaskCore(redisClientOpt)
+    taskQueue.InitTaskQueue(tc, redisClientOpt)  // 传入 redisOpt
+}
+```
+
+#### 3. `services/batchDownloadServices.go`
+
+```go
+// 将:
+import "fwh-toolbox-server/task/queue/fwhQueue"
+fwhQueue.EnqueueBatchDownloadTask(...)
+
+// 替换为:
+import "fwh-toolbox-server/task/queue/fwhQueueNew"
+fwhQueueNew.EnqueueBatchDownloadTask(...)
+```
+
+#### 4. 其他引用 `fwhQueue.EnqueueXxx` 的地方
+
+全局搜索 `fwhQueue.Enqueue` 替换为 `fwhQueueNew.Enqueue`:
+
+- `services/sitesAnalysisServices.go`
+- `scheduler/scheduler.go`(如果有定时触发 ShutdownStatistics)
+- `controllers/` 中直接调用入队的地方
+
+#### 5. `main.go` 中的 Asynqmon 配置
+
+无需改动,Asynqmon 已经指向同一个 Redis DB,新队列的任务会自动出现在 UI 中。
+
+---
+
+## 注意事项
+
+1. **Redis DB**:新队列使用与 Asynq Server 相同的 Redis DB(当前为 DB 12),计数器 key 也在同一个 DB 中
+2. **并行安全**:`fwh:download` 队列的 4 个 worker 可能同时处理不同 SN 的文件下载,这是安全的(每个 SN 独立)
+3. **超时保护**:所有任务都设置了 Timeout,防止 handler 卡死
+4. **历史保留**:`Retention(7*24*time.Hour)` 保留已完成任务 7 天,方便在 Asynqmon 中查看历史
+5. **平滑迁移**:新旧队列可以共存一段时间,逐步将调用切换到新队列

+ 144 - 0
service/task/queue/fwhQueue/fwhEnqueue.go

@@ -0,0 +1,144 @@
+package fwhQueue
+
+import (
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"time"
+
+	"github.com/hibiken/asynq"
+	"go.uber.org/zap"
+)
+
+// ==================== 对外暴露的入队函数 ====================
+
+// EnqueueBatchDownloadTask 入队批量数据下载任务
+func EnqueueBatchDownloadTask(payload domain.TaskBatchDownload) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("marshal BatchDownload payload: %w", err)
+	}
+
+	taskId := newTaskID(TaskTypeBatchDownload, payload.Id.Hex())
+	task := asynq.NewTask(TaskTypeBatchDownload, data,
+		asynq.MaxRetry(0),               // 主任务不重试(内部自行管理状态)
+		asynq.Timeout(120*time.Minute),  // 2 小时超时
+		asynq.Queue(QueueMain),          // 主队列
+		asynq.TaskID(taskId),            // 固定 ID 防止重复提交
+		asynq.Retention(7*24*time.Hour), // 完成后保留 7 天
+	)
+
+	info, err := TaskClient.Enqueue(task)
+	if err != nil {
+		zap.L().Error("[Enqueue] BatchDownload 入队失败",
+			zap.String("id", payload.Id.Hex()),
+			zap.String("err", err.Error()),
+		)
+		return err
+	}
+
+	zap.L().Info("[Enqueue] BatchDownload 入队成功",
+		zap.String("id", payload.Id.Hex()),
+		zap.String("taskId", info.ID),
+		zap.String("queue", info.Queue),
+	)
+	return nil
+}
+
+// EnqueueSiteAnalysisTask 入队站点分析任务
+func EnqueueSiteAnalysisTask(payload domain.TaskSiteAnalysis) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("marshal SiteAnalysis payload: %w", err)
+	}
+
+	taskId := newTaskID(TaskTypeSiteAnalysis, payload.Id.Hex())
+	task := asynq.NewTask(TaskTypeSiteAnalysis, data,
+		asynq.MaxRetry(0),             // 不重试
+		asynq.Timeout(60*time.Minute), // 1 小时超时
+		asynq.Queue(QueueMain),
+		asynq.TaskID(taskId),
+		asynq.Retention(7*24*time.Hour),
+	)
+
+	info, err := TaskClient.Enqueue(task)
+	if err != nil {
+		zap.L().Error("[Enqueue] SiteAnalysis 入队失败",
+			zap.String("id", payload.Id.Hex()),
+			zap.String("err", err.Error()),
+		)
+		return err
+	}
+
+	zap.L().Info("[Enqueue] SiteAnalysis 入队成功",
+		zap.String("id", payload.Id.Hex()),
+		zap.String("taskId", info.ID),
+	)
+	return nil
+}
+
+// EnqueueShutdownStatisticsTask 入队 Shutdown 统计任务
+func EnqueueShutdownStatisticsTask(payload domain.TaskShutdownStatistics) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("marshal ShutdownStatistics payload: %w", err)
+	}
+
+	taskId := newTaskID(TaskTypeShutdownStats, payload.Date)
+	task := asynq.NewTask(TaskTypeShutdownStats, data,
+		asynq.MaxRetry(0),
+		asynq.Timeout(30*time.Minute),
+		asynq.Queue(QueueMain),
+		asynq.TaskID(taskId),
+		asynq.Unique(10*time.Minute), // 10 分钟内去重
+		asynq.Retention(7*24*time.Hour),
+	)
+
+	info, err := TaskClient.Enqueue(task)
+	if err != nil {
+		zap.L().Error("[Enqueue] ShutdownStatistics 入队失败",
+			zap.String("date", payload.Date),
+			zap.String("err", err.Error()),
+		)
+		return err
+	}
+
+	zap.L().Info("[Enqueue] ShutdownStatistics 入队成功",
+		zap.String("date", payload.Date),
+		zap.String("taskId", info.ID),
+	)
+	return nil
+}
+
+// EnqueueSiteInfoUpdateTask 入队站点信息更新任务(唯一任务)
+func EnqueueSiteInfoUpdateTask(payload domain.TaskSiteInfoUpdate) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("marshal SiteInfoUpdate payload: %w", err)
+	}
+
+	taskId := newTaskID(TaskTypeSiteInfoUpdate, payload.Id.Hex())
+	task := asynq.NewTask(TaskTypeSiteInfoUpdate, data,
+		asynq.MaxRetry(0),
+		asynq.Timeout(60*time.Minute),
+		asynq.Queue(QueueMain),
+		asynq.TaskID(taskId),
+		asynq.Unique(30*time.Minute), // 30 分钟内去重(保证唯一性)
+		asynq.Retention(7*24*time.Hour),
+	)
+
+	info, err := TaskClient.Enqueue(task)
+	if err != nil {
+		zap.L().Error("[Enqueue] SiteInfoUpdate 入队失败",
+			zap.String("id", payload.Id.Hex()),
+			zap.String("err", err.Error()),
+		)
+		return err
+	}
+
+	zap.L().Info("[Enqueue] SiteInfoUpdate 入队成功",
+		zap.String("id", payload.Id.Hex()),
+		zap.String("taskId", info.ID),
+	)
+	return nil
+}

+ 385 - 393
service/task/queue/fwhQueue/fwhTaskBatchDownload.go

@@ -2,558 +2,550 @@ package fwhQueue
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
+	"fwh-toolbox-server/cache"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
 	"fwh-toolbox-server/jobs"
-	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
 	"strconv"
 	"strings"
 	"time"
 
+	"github.com/hibiken/asynq"
 	"go.uber.org/zap"
 )
 
-var (
-	TaskTypeBatchDownload = "task_BatchDownload"
-)
+// handleBatchDownload 是 BatchDownload 主任务的 Asynq handler
+func handleBatchDownload(ctx context.Context, task *asynq.Task) error {
+	var payload domain.TaskBatchDownload
+	if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+		return fmt.Errorf("unmarshal BatchDownload payload: %w", err)
+	}
 
-// type TaskBatchDownload = types.TaskBatchDownload
+	mainTaskId := payload.Id.Hex()
 
-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
+	if err := repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressRunning, []string{}); err != nil {
+		zap.L().Error("[BatchDownload] 更新状态失败(Running)", zap.String("id", mainTaskId))
+		return err
 	}
 
-	fmt.Printf("开始执行任务 %v\n", payload)
-	reasons, err := executeTaskBatchDownload(ctx, payload)
-	// 记录任务执行结果
+	// 执行主逻辑
+	reasons, err := executeBatchDownload(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
-		}
+		zap.L().Error("[BatchDownload] 任务执行失败", zap.String("id", mainTaskId), zap.String("err", err.Error()))
+		repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressError, []string{err.Error()})
+		return err
+	}
+
+	if len(reasons) == 0 {
+		zap.L().Info("[BatchDownload] 任务执行成功", zap.String("id", mainTaskId))
+		repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressComplete, []string{})
 	} 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
-			}
-		}
+		zap.L().Warn("[BatchDownload] 任务部分失败", zap.String("id", mainTaskId), zap.Strings("reasons", reasons))
+		repositories.UpdateBatchDownloadResult(payload.Id, domain.ProgressFailed, reasons)
 	}
 
-	return err
+	return nil // 返回 nil 让 Asynq 标记任务为 completed,不触发重试
 }
 
-var executeTaskBatchDownload = func(ctx context.Context, payload domain.TaskBatchDownload) ([]string, error) {
-	// 转换时间范围,只取用日期部分
+// executeBatchDownload 执行批量下载逻辑
+func executeBatchDownload(ctx context.Context, payload domain.TaskBatchDownload) ([]string, error) {
+	mainTaskId := payload.Id.Hex()
+
+	// 解析时间范围
 	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
+		return nil, fmt.Errorf("startTime 格式错误: %w", 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
+		return nil, fmt.Errorf("endTime 格式错误: %w", 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 := splitTimePeriods(startTime, endTime)
+
+	// ==================== 阶段1:创建文件下载子任务 ====================
+	callFileTasks := buildCallFileTasks(payload, periods, startTime, endTime)
+	totalSubTasks := len(callFileTasks)
+
+	if totalSubTasks > 0 {
+		// 初始化 Redis 计数器(待完成数)
+		counterKey := callFileCounterPrefix + mainTaskId
+		failuresKey := callFileFailuresPrefix + mainTaskId
+
+		cache.Set(ctx, counterKey, totalSubTasks, 2*time.Hour)
+		cache.Delete(ctx, failuresKey)
+
+		// 入队所有子任务
+		for i, cfTask := range callFileTasks {
+			taskPayload, _ := json.Marshal(cfTask)
+			subTaskID := fmt.Sprintf("callfile-%s-%d", mainTaskId, i)
+			t := asynq.NewTask(TaskTypeCallFile, taskPayload,
+				asynq.MaxRetry(3),
+				asynq.Timeout(35*time.Minute),
+				asynq.Queue(QueueDownload),
+				asynq.TaskID(subTaskID),
+			)
+			if _, err := TaskClient.Enqueue(t); err != nil {
+				zap.L().Error("[BatchDownload] 子任务入队失败",
+					zap.String("id", mainTaskId),
+					zap.Int("index", i),
+					zap.String("err", err.Error()),
+				)
+			}
 		}
+	}
 
-		periods = append(periods, struct{ Start, End time.Time }{Start: currTime, End: dayEnd})
+	// ==================== 阶段2:同步处理其他数据任务 ====================
+	if err := executeSyncTasks(ctx, payload, periods); err != nil {
+		return nil, err
+	}
 
-		// 下一天开始
-		currTime = dayEnd.Add(time.Second).Truncate(24 * time.Hour)
-		currTime = time.Date(currTime.Year(), currTime.Month(), currTime.Day(), 0, 0, 0, 0, currTime.Location())
+	// ==================== 阶段3:等待子任务完成 ====================
+	if totalSubTasks > 0 {
+		failReasons, err := waitForCallFileTasks(ctx, mainTaskId)
+		if err != nil {
+			return nil, err
+		}
+		return failReasons, nil
 	}
 
-	fmt.Println(payload.Options)
+	return nil, nil
+}
+
+// callFilePayload 是传递给子任务的 payload 结构
+type callFilePayload struct {
+	MainTaskId string                           `json:"mainTaskId"`
+	TaskInfo   domain.BatchDownloadFileTaskInfo `json:"taskInfo"`
+}
+
+// buildCallFileTasks 构建所有需要异步下载的文件任务列表
+func buildCallFileTasks(payload domain.TaskBatchDownload, periods []timePeriod, startTime, endTime time.Time) []callFilePayload {
+	mainTaskId := payload.Id.Hex()
+	var tasks []callFilePayload
 
-	// 优先处理文件下载任务(并行处理缩短时间)
-	downloadList := []domain.BatchDownloadFileTaskInfo{}
 	for _, sn := range payload.Sn {
+		// EmsLog 任务
 		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,
+				tasks = append(tasks, callFilePayload{
+					MainTaskId: mainTaskId,
+					TaskInfo: domain.BatchDownloadFileTaskInfo{
+						DevType:     -1,
+						Sn:          sn,
+						Date:        dateStr,
+						FileType:    jobs.FileTypeEmsLog,
+						FileSupType: logType,
+					},
 				})
 			}
 		}
-	}
 
-	for _, sn := range payload.Sn {
-		// 捞取故障录波数据
+		// FaultRecord 任务
 		if payload.Options.FaultRecord {
-			// 召唤aGate故障录波
-			err := requestCallFaultRecord(&downloadList, 1, sn, startTime, endTime)
-			if err != nil {
-				return nil, err
-			}
+			frTasks := buildFaultRecordTasks(mainTaskId, sn, startTime, endTime)
+			tasks = append(tasks, frTasks...)
+		}
+	}
 
-			// 提取设备的子设备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
-			}
+	return tasks
+}
 
-			if len(*siteBaseInfoList.Result) > 0 {
-				aPowerSnList := strings.Split((*siteBaseInfoList.Result)[0].FhpSn, ",")
-				for i := range aPowerSnList {
-					aPowerSn := strings.TrimSpace(aPowerSnList[i])
+// buildFaultRecordTasks 为单个 SN 构建故障录波下载任务
+func buildFaultRecordTasks(mainTaskId string, sn string, startTime, endTime time.Time) []callFilePayload {
+	var tasks []callFilePayload
 
-					// 召唤aGate故障录波
-					err := requestCallFaultRecord(&downloadList, 0, aPowerSn, startTime, endTime)
-					if err != nil {
-						return nil, err
-					}
-				}
+	// 查询 aGate 可召唤列表
+	aGateTasks := buildFaultRecordTasksForDev(mainTaskId, 1, sn, startTime, endTime)
+	tasks = append(tasks, aGateTasks...)
+
+	// 查询 aPower 子设备
+	siteBaseInfoList, err := repositories.QuerySiteBaseInfoList(dto.SiteBaseInfoPara{
+		SiteList: []string{sn},
+		PageSize: 10,
+		PageNum:  1,
+	})
+	if err != nil {
+		zap.L().Error("[BatchDownload] 查询站点基本信息失败", zap.String("sn", sn), zap.String("err", err.Error()))
+		return tasks
+	}
+
+	if siteBaseInfoList != nil && len(*siteBaseInfoList.Result) > 0 {
+		aPowerSnList := strings.Split((*siteBaseInfoList.Result)[0].FhpSn, ",")
+		for _, aPowerSn := range aPowerSnList {
+			aPowerSn = strings.TrimSpace(aPowerSn)
+			if aPowerSn == "" {
+				continue
 			}
+			aPowerTasks := buildFaultRecordTasksForDev(mainTaskId, 0, aPowerSn, startTime, endTime)
+			tasks = append(tasks, aPowerTasks...)
 		}
 	}
 
-	// 插入文件下载任务
-	var isDownloadFile bool = false
-	if len(downloadList) > 0 {
-		isDownloadFile = true
-		err := jobs.CreateCallFwhFileTask(payload.Id, downloadList)
+	return tasks
+}
+
+// buildFaultRecordTasksForDev 为指定设备构建故障录波下载任务
+func buildFaultRecordTasksForDev(mainTaskId string, devType int, sn string, startTime, endTime time.Time) []callFilePayload {
+	callInfoList, err := repositories.QueryFaultRecordCallableList(devType, sn)
+	if err != nil {
+		zap.L().Error("[BatchDownload] 查询故障录波召唤选项失败", zap.String("sn", sn), zap.String("err", err.Error()))
+		return nil
+	}
+	if len(callInfoList) == 0 {
+		return nil
+	}
+
+	// 筛选日期范围内的记录
+	var indexList string
+	for _, info := range callInfoList {
+		failureTime, err := parseFaultRecordTime(info.FailureTime)
 		if err != nil {
-			return nil, err
+			continue
+		}
+		inRange := (failureTime.Equal(startTime) || failureTime.After(startTime)) &&
+			(failureTime.Equal(endTime) || failureTime.Before(endTime))
+		if inRange {
+			if indexList == "" {
+				indexList = strconv.Itoa(info.Idx)
+			} else {
+				indexList = indexList + "," + strconv.Itoa(info.Idx)
+			}
 		}
 	}
 
-	// 再处理其他数据下载任务
+	// 没有符合日期范围的记录,不创建任务
+	if indexList == "" {
+		return nil
+	}
+
+	return []callFilePayload{{
+		MainTaskId: mainTaskId,
+		TaskInfo: domain.BatchDownloadFileTaskInfo{
+			DevType:     devType,
+			Sn:          sn,
+			Date:        startTime.Format(time.DateTime) + "," + endTime.Format(time.DateTime),
+			FileType:    jobs.FileTypeFaultRecord,
+			FileSupType: indexList,
+		},
+	}}
+}
+
+// executeSyncTasks 同步执行非文件下载类任务(PowerFlow、MqttLogs、EventAlarm 等)
+func executeSyncTasks(ctx context.Context, payload domain.TaskBatchDownload, periods []timePeriod) error {
 	for _, sn := range payload.Sn {
-		// 生成功率流图文件
+		// PowerFlow
 		if payload.Options.PowerFlow {
 			for _, period := range periods {
-				// 查询功率流数据
-				fmt.Printf("查询功率流数据 - %s\n", period.Start.Format(time.DateOnly))
+				dateStr := 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
+					zap.L().Error("[BatchDownload] 查询功率流数据失败", zap.String("sn", sn), zap.String("date", dateStr), zap.String("err", err.Error()))
+					return 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
+					return 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
+					return err
 				}
 			}
 		}
 
-		// 生成Mqtt日志数据文件
+		// MqttLogs
 		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
+					return 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
+					return 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
+					return err
 				}
 			}
 		}
 
-		// 生成事件&告警日志文件
+		// EventAlarm
 		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
+					return 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
+					return 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
+					return err
 				}
 			}
 		}
 
-		// 生成告警快照文件
+		// AlarmSnapshot
 		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
+					return 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
+					return 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
+					return err
 				}
 			}
 		}
 
-		// 生成站点升级日志数据文件
+		// UpdateLog
 		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
+			if err := executeSyncUpdateLog(sn, payload); err != nil {
+				return err
 			}
+		}
+	}
+	return nil
+}
 
-			// 查询系统升级日志详细信息
-			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)
-						}
+// waitForCallFileTasks 等待所有子任务完成,收集失败原因
+func waitForCallFileTasks(ctx context.Context, mainTaskId string) ([]string, error) {
+	counterKey := callFileCounterPrefix + mainTaskId
+	failuresKey := callFileFailuresPrefix + mainTaskId
 
-						break
-					}
-				}
+	ticker := time.NewTicker(30 * time.Second)
+	defer ticker.Stop()
 
-				updateLogList = append(updateLogList, updateLogInfo)
-			}
+	timeout := time.After(90 * time.Minute) // 90 分钟总超时
 
-			// 查询aHub升级日志
-			aHubUpdateLogList, err := repositories.QueryAHubUpdateLog(sn)
+	for {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-timeout:
+			return nil, fmt.Errorf("等待子任务完成超时(90分钟)")
+		case <-ticker.C:
+			val, err := cache.Get(ctx, counterKey)
 			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"
+				// key 不存在或读取失败
+				if err.Error() == "redis: nil" {
+					// key 不存在,所有子任务已完成
+					val = "0"
 				} 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)
+					zap.L().Error("[BatchDownload] 读取计数器失败", zap.String("key", counterKey), zap.String("err", err.Error()))
+					continue
 				}
+			}
 
-				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)
+			remaining, _ := strconv.Atoi(val)
+			if remaining <= 0 {
+				// 收集失败原因
+				failures, err := cache.LRange(ctx, failuresKey, 0, -1)
+				if err != nil {
+					return nil, fmt.Errorf("读取失败原因列表失败: %w", err)
 				}
 
-				aHubUpdateLogData = append(aHubUpdateLogData, updateLogInfo)
-			}
+				// 清理
+				cache.Delete(ctx, counterKey, failuresKey)
 
-			// 将结果保存到文件服务器
-			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
+				return failures, nil
 			}
-
 		}
-
-		// 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())
+type timePeriod struct {
+	Start time.Time
+	End   time.Time
+}
 
-		var failReasons []string = []string{}
-		for _, f := range failedList {
-			failReasons = append(failReasons, f.Reason)
+func splitTimePeriods(startTime, endTime time.Time) []timePeriod {
+	var periods []timePeriod
+	curr := startTime
+	for curr.Before(endTime) {
+		dayEnd := time.Date(curr.Year(), curr.Month(), curr.Day(), 23, 59, 59, 0, curr.Location())
+		if dayEnd.After(endTime) {
+			dayEnd = endTime
 		}
-
-		return failReasons, nil
+		periods = append(periods, timePeriod{Start: curr, End: dayEnd})
+		curr = time.Date(curr.Year(), curr.Month(), curr.Day()+1, 0, 0, 0, 0, curr.Location())
 	}
-
-	return nil, nil
+	return periods
 }
 
-// 插入批量数据下载队列
-// payload - 任务参数
-func EnqueueBatchDownloadTask(payload domain.TaskBatchDownload) error {
-	p := TaskPayload[domain.TaskBatchDownload]{
-		TaskPayloadBase: TaskPayloadBase{
-			Type: TaskTypeBatchDownload,
-		},
-		Payload: payload,
+func parseFaultRecordTime(failureTime string) (time.Time, error) {
+	parts := strings.Split(failureTime, " ")
+	if len(parts) < 2 {
+		return time.Time{}, fmt.Errorf("invalid failureTime format: %s", failureTime)
 	}
-	if err := EnqueueTask[domain.TaskBatchDownload](p); err != nil {
-		logger.Std.Printf("EnqueueBatchDownloadTask - %s : %s\n", payload.Id, err.Error())
-		return err
+	date := parts[0]
+	timeStr := parts[1]
+
+	d := strings.Split(date, "-")
+	t := strings.Split(timeStr, ":")
+	if len(d) < 3 || len(t) < 3 {
+		return time.Time{}, fmt.Errorf("invalid failureTime format: %s", failureTime)
 	}
-	return nil
+
+	standardTime := fmt.Sprintf("%04s-%02s-%02s %02s:%02s:%02s", d[0], d[1], d[2], t[0], t[1], t[2])
+	return time.Parse(time.DateTime, standardTime)
 }
 
-func requestCallFaultRecord(downloadList *[]domain.BatchDownloadFileTaskInfo, devType int, sn string, startTime time.Time, endTime time.Time) error {
-	// 召唤aGate、aPower故障录波
-	// 查询要召唤的选项
-	callInfoList, err := repositories.QueryFaultRecordCallableList(devType, sn)
+// executeSyncUpdateLog 同步执行升级日志任务(从原代码提取)
+func executeSyncUpdateLog(sn string, payload domain.TaskBatchDownload) error {
+	updateLogList := []domain.BatchDownloadSystemUpdateLogInfo{}
+
+	systemUpdateLogList, err := repositories.QuerySystemUpdateLog(sn)
 	if err != nil {
-		zap.L().Error("查询站点故障录波召唤选项失败", zap.String("sn", sn), zap.String("err", err.Error()))
 		return err
 	}
 
-	// 未召唤到记录,不添加任务
-	if len(callInfoList) == 0 {
-		return nil
-	}
-
-	// 提取符合日期范围的选项
-	var indexList string = ""
-	for _, info := range callInfoList {
-		// 转换成标准时间格式
-		parts := strings.Split(info.FailureTime, " ")
-		date := parts[0]
-		timeStr := parts[1]
+	for _, logList := range systemUpdateLogList {
+		var updateLogInfo domain.BatchDownloadSystemUpdateLogInfo
+		updateLogInfo.OrderNo = logList.OrderNo
+		updateLogInfo.GateSn = sn
+		updateLogInfo.FileName = logList.Name
 
-		d := strings.Split(date, "-")
-		t2 := strings.Split(timeStr, ":")
+		upgradeTime, err := time.ParseInLocation(time.DateTime, logList.UpgradeTime, time.UTC)
+		if err != nil {
+			zap.L().Warn("[BatchDownload] 升级时间解析失败", zap.String("time", logList.UpgradeTime))
+			continue
+		}
+		beijing, _ := time.LoadLocation("Asia/Shanghai")
+		updateLogInfo.UpdateTime = upgradeTime.In(beijing).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") {
+			updateLogInfo.TriggerType = "Span Auto"
+		} else {
+			updateLogInfo.TriggerType = "unknown"
+		}
 
-		failureStandardTime := fmt.Sprintf("%04s-%02s-%02s %02s:%02s:%02s",
-			d[0], d[1], d[2], t2[0], t2[1], t2[2])
+		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)
+		}
 
-		failureTime, err := time.Parse(time.DateTime, failureStandardTime)
+		systemUpdateLogListDetail, err := repositories.QuerySystemUpdateLogDetail(logList.Id)
 		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)
+		for _, logDetail := range systemUpdateLogListDetail {
+			if logDetail.GatewayId == sn {
+				for _, ibgVer := range logDetail.IbgVersionNew {
+					if ibgVer.Children != nil {
+						for _, childVerInfo := range ibgVer.Children {
+							updateLogInfo.GateVerList = append(updateLogInfo.GateVerList, domain.BatchDownloadUpdateLogVersionInfo{
+								Name: childVerInfo.Type, OldVer: childVerInfo.OldVersion, NewVer: childVerInfo.Version,
+							})
+						}
+					} else {
+						updateLogInfo.GateVerList = append(updateLogInfo.GateVerList, domain.BatchDownloadUpdateLogVersionInfo{
+							Name: ibgVer.Type, OldVer: ibgVer.OldVersion, NewVer: ibgVer.Version,
+						})
+					}
+				}
+				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 {
+								subDevInfo.DevVerList = append(subDevInfo.DevVerList, domain.BatchDownloadUpdateLogVersionInfo{
+									Name: thirdItem.SubType, OldVer: thirdItem.OldVersion, NewVer: thirdItem.Version,
+								})
+							}
+						}
+					}
+					updateLogInfo.SubDevList = append(updateLogInfo.SubDevList, subDevInfo)
+				}
+				break
 			}
 		}
+		updateLogList = append(updateLogList, updateLogInfo)
 	}
 
-	*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,
-	})
+	// aHub 升级日志
+	aHubUpdateLogList, err := repositories.QueryAHubUpdateLog(sn)
+	if err != nil {
+		return err
+	}
 
-	return nil
+	var aHubUpdateLogData []domain.BatchDownloadAHubUpdateLogInfo
+	for _, aHubVerInfo := range aHubUpdateLogList {
+		var info domain.BatchDownloadAHubUpdateLogInfo
+		info.OrderNo = aHubVerInfo.OrderNo
+		info.GateSn = sn
+		info.AHubSn = aHubVerInfo.AHubSn
+
+		if strings.HasPrefix(aHubVerInfo.OrderNo, "CH") {
+			info.TriggerType = "Auto"
+		} else if strings.HasPrefix(aHubVerInfo.OrderNo, "DH") {
+			info.TriggerType = "Manual"
+		} else if strings.HasPrefix(aHubVerInfo.OrderNo, "SH") {
+			info.TriggerType = "Span Auto"
+		} else {
+			info.TriggerType = "unknown"
+		}
+
+		switch aHubVerInfo.UpgradeResult {
+		case 0:
+			info.Result = "Success"
+		case -1:
+			info.Result = "Fault"
+		default:
+			info.Result = fmt.Sprintf("unknown(%d)", aHubVerInfo.UpgradeResult)
+		}
+
+		for _, detailInfo := range aHubVerInfo.DetailList {
+			info.DetailList = append(info.DetailList, domain.BatchDownloadUpdateLogAHubVersionInfo{
+				FileName:   detailInfo.UpgradeFileName,
+				FileOrder:  detailInfo.UpgradeFileOrder,
+				Result:     detailInfo.UpgradeResult,
+				ResultDesc: detailInfo.UpgradeResultDesc,
+				OldVer:     detailInfo.OldVersion,
+				NewVer:     detailInfo.NewVersion,
+			})
+		}
+		aHubUpdateLogData = append(aHubUpdateLogData, info)
+	}
+
+	objName, err := jobs.SaveBatchDownloadUpdateLog(sn, updateLogList, aHubUpdateLogData)
+	if err != nil {
+		return err
+	}
+	return repositories.InsertBatchDownloadPathUpdateLog(payload.Id, sn, objName)
 }

+ 441 - 0
service/task/queue/fwhQueue/fwhTaskCallFile.go

@@ -0,0 +1,441 @@
+package fwhQueue
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/jobs"
+	"fwh-toolbox-server/repositories"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/hibiken/asynq"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+const maxCallRetry = 3
+
+// handleCallFile 是文件下载子任务的 Asynq handler
+// Asynq 本身会做重试(MaxRetry=3),所以 handler 内部不再做外层重试循环
+// 返回 error 会触发 Asynq 重试,返回 nil 表示成功
+func handleCallFile(ctx context.Context, task *asynq.Task) error {
+	var payload callFilePayload
+	if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+		// payload 解析失败不重试
+		reportCallFileResult(ctx, payload.MainTaskId, fmt.Sprintf("unmarshal callFile payload: %s", err.Error()))
+		return nil
+	}
+
+	info := payload.TaskInfo
+	mainTaskId := payload.MainTaskId
+
+	var err error
+	switch info.FileType {
+	case jobs.FileTypeEmsLog:
+		err = handleEmsLogDownload(ctx, mainTaskId, info)
+	case jobs.FileTypeFaultRecord:
+		err = handleFaultRecordDownload(ctx, mainTaskId, info)
+	default:
+		err = fmt.Errorf("unknown file type: %s", info.FileType)
+	}
+
+	if err != nil {
+		// 判断是否还有重试机会
+		retryCount, _ := asynq.GetRetryCount(ctx)
+		maxRetry, _ := asynq.GetMaxRetry(ctx)
+		if retryCount >= maxRetry-1 {
+			// 最后一次重试仍失败,记录失败原因并完成计数
+			reportCallFileResult(ctx, mainTaskId, err.Error())
+			return nil // 返回 nil 不再重试
+		}
+		return err // 返回 error 触发 Asynq 重试
+	}
+
+	// 成功:递减计数器
+	reportCallFileResult(ctx, mainTaskId, "")
+	return nil
+}
+
+// reportCallFileResult 报告子任务完成结果
+// reason 为空表示成功,非空表示失败
+func reportCallFileResult(ctx context.Context, mainTaskId string, reason string) {
+	if mainTaskId == "" {
+		return
+	}
+
+	counterKey := callFileCounterPrefix + mainTaskId
+	failuresKey := callFileFailuresPrefix + mainTaskId
+
+	// 递减计数器
+	cache.Decr(ctx, counterKey)
+
+	// 如果失败,记录失败原因
+	if reason != "" {
+		cache.LPush(ctx, failuresKey, reason)
+		// 设置 failures key 过期时间
+		cache.Expire(ctx, failuresKey, 2*time.Hour)
+	}
+}
+
+// ==================== EmsLog 下载 ====================
+
+func handleEmsLogDownload(ctx context.Context, mainTaskId string, info domain.BatchDownloadFileTaskInfo) error {
+	// 1. 召唤 EmsLog
+	if err := callEmsLog(info.Sn, info.Date, info.FileSupType); err != nil {
+		return fmt.Errorf("[EmsLog] 召唤失败(sn=%s, date=%s, type=%s): %w", info.Sn, info.Date, info.FileSupType, err)
+	}
+
+	// 2. 轮询等待召唤结果
+	recordInfo, err := pollEmsLogResult(ctx, info.Sn, info.Date, info.FileSupType)
+	if err != nil {
+		return err
+	}
+
+	// 3. 下载并保存文件
+	mainObjId, _ := primitive.ObjectIDFromHex(mainTaskId)
+	return downloadAndSaveEmsLog(ctx, mainObjId, info, recordInfo)
+}
+
+func pollEmsLogResult(ctx context.Context, sn, date, fileSupType string) (*domain.EmsLogRecordInfo, error) {
+	ticker := time.NewTicker(time.Minute)
+	defer ticker.Stop()
+	timeout := time.After(30 * time.Minute)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-timeout:
+			return nil, fmt.Errorf("[EmsLog] 轮询超时(sn=%s, date=%s)", sn, date)
+		case <-ticker.C:
+			for attempt := 0; attempt < maxCallRetry; attempt++ {
+				recordInfo, err := getEmsLogUrl(sn, date, fileSupType)
+				if err != nil {
+					continue
+				}
+				if recordInfo.Status == -1 {
+					return nil, fmt.Errorf("[EmsLog] 召唤失败(sn=%s)", sn)
+				}
+				if recordInfo.Status == 1 {
+					return recordInfo, nil
+				}
+				// status == 0: 还在处理中
+				break
+			}
+		}
+	}
+}
+
+func downloadAndSaveEmsLog(ctx context.Context, mainTaskId primitive.ObjectID, info domain.BatchDownloadFileTaskInfo, recordInfo *domain.EmsLogRecordInfo) error {
+	var dataBytes []byte
+	var err error
+	for attempt := 0; attempt < maxCallRetry; attempt++ {
+		dataBytes, err = repositories.QueryDownloadEmsLogFile(recordInfo.FileUrl)
+		if err == nil {
+			break
+		}
+		zap.L().Warn("[EmsLog] 文件下载重试", zap.String("sn", info.Sn), zap.Int("attempt", attempt))
+	}
+	if err != nil {
+		return fmt.Errorf("[EmsLog] 文件下载失败(sn=%s): %w", info.Sn, err)
+	}
+
+	// 提取文件名
+	baseStr := path.Base(recordInfo.FileUrl)
+	if baseStr == "." || baseStr == "/" {
+		baseStr = ""
+	}
+
+	// 解析召唤时间
+	loc, err := time.LoadLocation("America/Los_Angeles")
+	if err != nil {
+		return fmt.Errorf("[EmsLog] 时区加载失败: %w", err)
+	}
+	callTime, err := time.ParseInLocation(time.DateTime, recordInfo.CallTime, loc)
+	if err != nil {
+		return fmt.Errorf("[EmsLog] CallTime解析失败: %w", err)
+	}
+
+	// 保存到 MinIO
+	objName, err := jobs.SaveBatchDownloadEmsLog(info.Sn, dataBytes, baseStr, info.FileSupType, info.Date, callTime)
+	if err != nil {
+		return fmt.Errorf("[EmsLog] 文件保存失败(sn=%s): %w", info.Sn, err)
+	}
+
+	// 写入路径记录
+	if err := repositories.InsertBatchDownloadPathEmsLogs(mainTaskId, info.Sn, info.Date, info.FileSupType, objName); err != nil {
+		return fmt.Errorf("[EmsLog] 路径写入失败(sn=%s): %w", info.Sn, err)
+	}
+
+	return nil
+}
+
+// ==================== FaultRecord 下载 ====================
+
+func handleFaultRecordDownload(ctx context.Context, mainTaskId string, info domain.BatchDownloadFileTaskInfo) error {
+	// 1. 解析参数
+	timeRange := strings.Split(info.Date, ",")
+	if len(timeRange) != 2 {
+		return fmt.Errorf("[FaultRecord] timeRange 格式错误: %s", info.Date)
+	}
+
+	// 2. 查询故障录波列表,判断是否存在满足时间范围的数据
+	existingRecords, err := repositories.QueryFaultRecordList(info.Sn, timeRange[0], timeRange[1])
+	if err != nil {
+		zap.L().Error("[FaultRecord] 查询已有录波列表失败", zap.String("sn", info.Sn), zap.String("err", err.Error()))
+		return err
+	} else {
+		// 判断列表中是否存在 earliestFaultTime 在时间范围内的记录
+		faultID, err := checkFaultRecordInTimeRange(existingRecords, info.DevType, info.Sn, timeRange[0], timeRange[1])
+		if err != nil {
+			zap.L().Error("[FaultRecord] 判断时间范围失败", zap.String("sn", info.Sn), zap.String("err", err.Error()))
+			return err
+		}
+		if faultID >= 0 {
+			// 故障录波文件存在,直接下载
+			// 下载所有子记录文件
+			mainObjId, _ := primitive.ObjectIDFromHex(mainTaskId)
+			return downloadAndSaveFaultRecords(ctx, mainObjId, faultID)
+		}
+		zap.L().Info("[FaultRecord] 无满足时间范围的录波数据,需要召唤",
+			zap.String("sn", info.Sn),
+			zap.String("startTime", timeRange[0]),
+			zap.String("endTime", timeRange[1]),
+		)
+	}
+
+	indexList, err := parseIndexList(info.FileSupType)
+	if err != nil {
+		return fmt.Errorf("[FaultRecord] indexList 解析错误: %w", err)
+	}
+
+	// 3. 召唤故障录波
+	if err := callFaultRecord(info.DevType, info.Sn, timeRange[0], timeRange[1], indexList); err != nil {
+		return fmt.Errorf("[FaultRecord] 召唤失败(sn=%s): %w", info.Sn, err)
+	}
+
+	// 4. 轮询等待召唤结果
+	faultId, err := pollFaultRecordResult(ctx, info.DevType, info.Sn, timeRange[0], timeRange[1])
+	if err != nil {
+		return err
+	}
+
+	// 5. 下载所有子记录文件
+	mainObjId, _ := primitive.ObjectIDFromHex(mainTaskId)
+	return downloadAndSaveFaultRecords(ctx, mainObjId, faultId)
+}
+
+// checkFaultRecordInTimeRange 检查故障录波列表中是否存在 earliestFaultTime 满足时间范围的记录,返回FaultID和错误信息,不在范围内FaultID = -1
+func checkFaultRecordInTimeRange(records []domain.FaultRecordInfo, devType int, sn string, startTime string, endTime string) (int64, error) {
+	start, err := time.Parse(time.DateTime, startTime)
+	if err != nil {
+		zap.L().Error("[checkFaultRecordInTimeRange] 解析startTime失败",
+			zap.String("startTime", startTime),
+			zap.String("err", err.Error()),
+		)
+		return -1, err // 解析失败,保守处理:继续召唤
+	}
+	end, err := time.Parse(time.DateTime, endTime)
+	if err != nil {
+		zap.L().Error("[checkFaultRecordInTimeRange] 解析endTime失败",
+			zap.String("endTime", endTime),
+			zap.String("err", err.Error()),
+		)
+		return -1, err
+	}
+
+	for _, record := range records {
+		// 匹配设备类型和 SN
+		if record.DeviceType != devType {
+			continue
+		}
+		var recordSn string
+		switch devType {
+		case 0: // aPower
+			if record.ApowerId != nil {
+				recordSn = *record.ApowerId
+			}
+		case 1: // aGate
+			recordSn = record.GatewayId
+		case 2: // MPPT
+			if record.ApowerId != nil {
+				recordSn = *record.ApowerId
+			}
+		case 3: // aHub
+			if record.PowerlinkId != nil {
+				recordSn = *record.PowerlinkId
+			}
+		}
+		if recordSn != sn {
+			continue
+		}
+
+		// 解析 earliestFaultTime 并判断是否在时间范围内
+		faultTime, err := time.Parse(time.DateTime, record.EarliestFaultTime)
+		if err != nil {
+			// 尝试时间戳格式
+			continue
+		}
+
+		inRange := (faultTime.Equal(start) || faultTime.After(start)) &&
+			(faultTime.Equal(end) || faultTime.Before(end))
+		if inRange {
+			return record.Id, nil
+		}
+	}
+
+	return -1, nil
+}
+
+func pollFaultRecordResult(ctx context.Context, devType int, sn, startTime, endTime string) (int64, error) {
+	ticker := time.NewTicker(time.Minute)
+	defer ticker.Stop()
+	timeout := time.After(30 * time.Minute)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return 0, ctx.Err()
+		case <-timeout:
+			return 0, fmt.Errorf("[FaultRecord] 轮询超时(sn=%s)", sn)
+		case <-ticker.C:
+			for attempt := 0; attempt < maxCallRetry; attempt++ {
+				status, faultId, reason, err := getFaultRecordUrl(devType, sn, startTime, endTime)
+				if err != nil {
+					continue
+				}
+				if status == -1 {
+					return 0, fmt.Errorf("[FaultRecord] 召唤失败(sn=%s): %s", sn, reason)
+				}
+				if status == 2 {
+					return faultId, nil
+				}
+				// status == 0: 还在处理中
+				break
+			}
+		}
+	}
+}
+
+func downloadAndSaveFaultRecords(ctx context.Context, mainTaskId primitive.ObjectID, faultId int64) error {
+	subRecordList, err := repositories.QueryFaultRecordSubList(faultId)
+	if err != nil {
+		return fmt.Errorf("[FaultRecord] 子记录查询失败: %w", err)
+	}
+
+	for _, subRecord := range subRecordList {
+		var dataBytes []byte
+		var downloadErr error
+		for attempt := 0; attempt < maxCallRetry; attempt++ {
+			_, dataBytes, downloadErr = repositories.QueryDownloadFaultRecordData(faultId, subRecord.Id)
+			if downloadErr == nil {
+				break
+			}
+			zap.L().Warn("[FaultRecord] 文件下载重试",
+				zap.String("sn", subRecord.GatewayId),
+				zap.Int64("subId", subRecord.Id),
+				zap.Int("attempt", attempt),
+			)
+		}
+		if downloadErr != nil {
+			return fmt.Errorf("[FaultRecord] 文件下载失败(sn=%s, id=%d): %w", subRecord.GatewayId, subRecord.Id, downloadErr)
+		}
+
+		// 保存到 MinIO
+		objName, err := jobs.SaveBatchDownloadFaultRecord(subRecord.GatewayId, dataBytes, subRecord.FileName+".xls")
+		if err != nil {
+			return fmt.Errorf("[FaultRecord] 文件保存失败(sn=%s): %w", subRecord.GatewayId, err)
+		}
+
+		// 写入路径记录
+		if err := repositories.InsertBatchDownloadPathFaultRecord(mainTaskId, subRecord.GatewayId, subRecord.FileName, objName); err != nil {
+			return fmt.Errorf("[FaultRecord] 路径写入失败(sn=%s): %w", subRecord.GatewayId, err)
+		}
+	}
+
+	return nil
+}
+
+// ==================== 辅助函数(复用原有逻辑) ====================
+
+// EMS日志类型map
+var mapEmsLogType = map[string]int{
+	"EmsRunLog":     4,
+	"EmsBattLog":    7,
+	"EmsMonitorLog": 9,
+	"Ems4gLog":      10,
+	"EmsAwsLog":     11,
+	"EmsMainLog":    12,
+}
+
+func callEmsLog(sn string, date string, logType string) error {
+	logTypeNum, ok := mapEmsLogType[logType]
+	if !ok {
+		return fmt.Errorf("logType不存在: %s", logType)
+	}
+	return repositories.QueryCallEmsLog(sn, date, logTypeNum)
+}
+
+func getEmsLogUrl(sn string, date string, logType string) (*domain.EmsLogRecordInfo, error) {
+	logTypeNum, ok := mapEmsLogType[logType]
+	if !ok {
+		return nil, fmt.Errorf("logType不存在: %s", logType)
+	}
+	return repositories.QueryEmsLogCallRecord(sn, date, logTypeNum)
+}
+
+func callFaultRecord(devType int, sn string, startTime string, endTime string, indexList []int) error {
+	return repositories.QueryCellFaultRecord(devType, sn, indexList)
+}
+
+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 {
+		if record.DeviceType == devType {
+			var devSn string
+			switch devType {
+			case 0:
+				devSn = *record.ApowerId
+			case 1:
+				devSn = record.GatewayId
+			case 2:
+				devSn = *record.ApowerId
+			case 3:
+				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)", devType, sn)
+}
+
+func parseIndexList(fileSupType string) ([]int, error) {
+	indexStrList := strings.Split(fileSupType, ",")
+	indexList := make([]int, 0, len(indexStrList))
+	for _, s := range indexStrList {
+		idx, err := strconv.Atoi(s)
+		if err != nil {
+			return nil, fmt.Errorf("无法解析 index '%s': %w", s, err)
+		}
+		indexList = append(indexList, idx)
+	}
+	return indexList, nil
+}

+ 139 - 335
service/task/queue/fwhQueue/fwhTaskQueue.go

@@ -2,372 +2,176 @@ package fwhQueue
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
-	"fwh-toolbox-server/cache"
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/logger"
-	"slices"
 	"time"
 
-	"github.com/redis/go-redis/v9"
+	"fwh-toolbox-server/dto"
+	task_core "fwh-toolbox-server/task/core"
+
+	"github.com/hibiken/asynq"
 	"go.uber.org/zap"
 )
 
-var (
-	ctx        = context.Background()
-	streamName = "fwhtoolbox:taskQueue:taskStream"
-	groupName  = "task_queue"
-	consumerID = "worker-taskQueue"
+// 任务类型常量
+const (
+	TaskTypeBatchDownload  = "fwh:BatchDownload"
+	TaskTypeCallFile       = "fwh:CallFile"
+	TaskTypeSiteAnalysis   = "fwh:SiteAnalysis"
+	TaskTypeShutdownStats  = "fwh:ShutdownStatistics"
+	TaskTypeSiteInfoUpdate = "fwh:SiteInfoUpdate"
+)
 
-	// retryConsumerID = "retry-taskQueue"
-	retryConsumerID = consumerID
-	maxRetry        = 3
-	idleTime        = 10 * time.Minute
+// 队列名称
+const (
+	QueueMain     = "fwh:main"     // 主任务队列(顺序执行)
+	QueueDownload = "fwh:download" // 文件下载队列(可并发)
+)
 
-	// 状态管理相关变量
-	runningID      = ""                 // 当前执行的任务ID
-	failedTaskList = []string{}         // 失败且未处理任务ID列表
-	errorTaskList  = []redis.XMessage{} // 超过重试次数仍失败的任务列表
+// Redis key 前缀
+const (
+	// 子任务计数器 key: fwh:callfile:counter:{mainTaskId}
+	callFileCounterPrefix = "fwh:callfile:counter:"
+	// 子任务失败原因 key: fwh:callfile:failures:{mainTaskId}
+	callFileFailuresPrefix = "fwh:callfile:failures:"
+)
 
-	// 唯一任务key前缀
-	uniqueTaskKeyPrefix = "fwhtoolbox:taskQueue:taskUnique:"
+// 模块级变量
+var (
+	TaskClient *asynq.Client
+	inspector  *asynq.Inspector
 )
 
-type TaskStreamInfo struct {
-	Ctx        context.Context
-	StreamName string
-	GroupName  string
-	ID         string
+// InitFwhTaskQueue 初始化新的 FWH 任务队列
+// 通过 TaskCore 创建 Asynq Server(worker)、Client(入队)、Inspector(状态查询)
+func InitFwhTaskQueue(tc *task_core.TaskCore) {
+	// 获取 Inspector(用于主任务轮询子任务状态)
+	inspector = tc.Inspector.GetInspector()
+
+	// 注册处理函数
+	mux := asynq.NewServeMux()
+	mux.HandleFunc(TaskTypeBatchDownload, handleBatchDownload)
+	mux.HandleFunc(TaskTypeCallFile, handleCallFile)
+	mux.HandleFunc(TaskTypeSiteAnalysis, handleSiteAnalysis)
+	mux.HandleFunc(TaskTypeShutdownStats, handleShutdownStatistics)
+	mux.HandleFunc(TaskTypeSiteInfoUpdate, handleSiteInfoUpdate)
+
+	// 通过 TaskCore 创建任务 Server 和 Client
+	TaskClient = tc.CreateTask(task_core.TaskConfig{
+		Name: "FwhTask",
+		SvrCfg: asynq.Config{
+			Concurrency: 6, // 总并发数
+			Queues: map[string]int{
+				QueueDownload: 4, // 文件下载队列:4 个并发 worker
+				QueueMain:     2, // 主任务队列:2 个并发 worker
+			},
+			// 错误处理
+			ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {
+				zap.L().Error("[fwhQueue] task failed",
+					zap.String("type", task.Type()),
+					zap.String("err", err.Error()),
+				)
+			}),
+			// 重试延迟策略(指数退避)
+			RetryDelayFunc: func(n int, e error, t *asynq.Task) time.Duration {
+				return time.Duration(n*n) * 30 * time.Second // 30s, 2min, 4.5min
+			},
+		},
+		Mux: mux,
+	})
+
+	zap.L().Info("[fwhQueue] initialized successfully")
 }
 
-type TaskPayloadBase struct {
-	Type string `json:"type"`
+// GetClient 获取 Asynq Client(供内部使用)
+func GetClient() *asynq.Client {
+	return TaskClient
 }
 
-type TaskPayload[T any] struct {
-	TaskPayloadBase
-	Payload T `json:"payload"`
+// GetInspector 获取 Asynq Inspector(供内部使用)
+func GetInspector() *asynq.Inspector {
+	return inspector
 }
 
-func InitFwhTaskQueue() {
-	// 确保消费组存在(忽略已存在错误)
-	_ = cache.XGroupCreate(ctx, streamName, groupName, "$")
-
-	// 启动后台任务队列(不会阻塞主线程)
-	go startTaskQueueWorker()
-}
-
-// 任务消费者(单 worker,顺序执行)
-func startTaskQueueWorker() {
-	streamsMap := make(map[string]string)
-	streamsMap[streamName] = ">"
-
-	for {
-		// 处理新任务
-		res, err := cache.XReadGroup(ctx, groupName, consumerID, streamsMap, 1) // 等待任务
-		if err != nil {
-			if err == redis.Nil {
-				// 未读取到新消息
-				// 处理滞留的消息
-				retryRes, _, err := cache.XAutoClaim(ctx, streamName, groupName, retryConsumerID, idleTime, 1)
-				if err != nil {
-					if err == redis.Nil {
-						continue
-					}
-					logger.Std.Println("XAutoClaim error:", err)
-					time.Sleep(time.Second)
-					continue
-				}
-
-				for _, msg := range retryRes {
-					pendingRes, err := cache.XPendingExtOne(ctx, streamName, groupName, retryConsumerID, msg.ID)
-					if err != nil {
-						logger.Std.Println("XPendingExtOne error:", err)
-					} else if pendingRes == nil {
-						panic("无法读取XPendingExtOne")
-					}
-
-					// 判断重试次数
-					if pendingRes.RetryCount > int64(maxRetry) {
-						if slices.Contains(failedTaskList, msg.ID) {
-							failedTaskList = slices.DeleteFunc(failedTaskList, func(s string) bool { return s == msg.ID })
-						}
-						errorTaskList = append(errorTaskList, msg)
-						ackTask(TaskStreamInfo{
-							Ctx:        ctx,
-							StreamName: streamName,
-							GroupName:  groupName,
-							ID:         msg.ID,
-						}, true, fmt.Errorf("任务超过最大重试次数。"))
-						continue
-					}
-
-					zap.L().Warn("taskQueue任务重试。", zap.String("ID", msg.ID), zap.Int64("count", pendingRes.RetryCount))
+// GetTaskInfoList 获取所有 FWH 队列中的任务列表(供外部查询)
+func GetTaskInfoList() ([]dto.BackgroundTaskItem, error) {
+	if inspector == nil {
+		return nil, fmt.Errorf("inspector not initialized")
+	}
 
-					runningID = msg.ID
-					handleTaskQueueMsg(msg)
-				}
-				continue
+	var result []dto.BackgroundTaskItem
+	queues := []string{QueueMain, QueueDownload}
+
+	for _, queue := range queues {
+		// 查询活跃任务(running)
+		activeTasks, err := inspector.ListActiveTasks(queue, asynq.PageSize(100))
+		if err == nil {
+			for _, t := range activeTasks {
+				result = append(result, dto.BackgroundTaskItem{
+					Id:      t.ID,
+					Status:  "running",
+					Retry:   int64(t.Retried),
+					Payload: string(t.Payload),
+				})
 			}
-			logger.Std.Println("XReadGroup error:", err)
-			time.Sleep(time.Second)
-			continue
 		}
 
-		for _, stream := range res {
-			for _, message := range stream.Messages {
-				runningID = message.ID
-				handleTaskQueueMsg(message)
+		// 查询等待中的任务(pending)
+		pendingTasks, err := inspector.ListPendingTasks(queue, asynq.PageSize(100))
+		if err == nil {
+			for _, t := range pendingTasks {
+				result = append(result, dto.BackgroundTaskItem{
+					Id:      t.ID,
+					Status:  "pending",
+					Retry:   int64(t.Retried),
+					Payload: string(t.Payload),
+				})
 			}
 		}
-	}
-}
-
-// 处理单条消息
-func handleTaskQueueMsg(message redis.XMessage) {
-	id := message.ID
-	rawPayload := message.Values["payload"].(string)
-
-	var rawLockKey *string
-	var rawLockToken *string
-
-	if val, ok := message.Values["uniqueLockKey"]; ok {
-		if str, ok := val.(string); ok {
-			rawLockKey = &str
-		}
-	}
 
-	if val, ok := message.Values["uniqueLockToken"]; ok {
-		if str, ok := val.(string); ok {
-			rawLockToken = &str
+		// 查询重试中的任务(retry)
+		retryTasks, err := inspector.ListRetryTasks(queue, asynq.PageSize(100))
+		if err == nil {
+			for _, t := range retryTasks {
+				result = append(result, dto.BackgroundTaskItem{
+					Id:      t.ID,
+					Status:  "retry",
+					Retry:   int64(t.Retried),
+					Payload: string(t.Payload),
+				})
+			}
 		}
-	}
 
-	defer func() {
-		if rawLockKey != nil && rawLockToken != nil {
-			// 释放唯一任务锁
-			FinishUniqueTask(*rawLockKey, *rawLockToken)
+		// 查询已完成的任务(completed)
+		completedTasks, err := inspector.ListCompletedTasks(queue, asynq.PageSize(100))
+		if err == nil {
+			for _, t := range completedTasks {
+				result = append(result, dto.BackgroundTaskItem{
+					Id:      t.ID,
+					Status:  "completed",
+					Retry:   int64(t.Retried),
+					Payload: string(t.Payload),
+				})
+			}
 		}
-	}()
-
-	// 先反序列化成 map 确定 type
-	var base TaskPayloadBase
-	if err := json.Unmarshal([]byte(rawPayload), &base); err != nil {
-		ackTask(TaskStreamInfo{
-			Ctx:        ctx,
-			StreamName: streamName,
-			GroupName:  groupName,
-			ID:         id,
-		}, false, fmt.Errorf("unmarshal base error - %s", err.Error()))
-		return
-	}
 
-	switch base.Type {
-	case TaskTypeSiteAnalysis: // 站点分析任务
-		var payload TaskPayload[domain.TaskSiteAnalysis]
-		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 := handleTaskSiteAnalysis(ctx, payload.Payload); err != nil {
-			ackTask(TaskStreamInfo{
-				Ctx:        ctx,
-				StreamName: streamName,
-				GroupName:  groupName,
-				ID:         id,
-			}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
-			return
-		}
-	case TaskTypeShutdownStatistics: // Shutdown统计任务
-		var payload TaskPayload[domain.TaskShutdownStatistics]
-		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 := handleTaskShutdownStatistics(ctx, payload.Payload); err != nil {
-			ackTask(TaskStreamInfo{
-				Ctx:        ctx,
-				StreamName: streamName,
-				GroupName:  groupName,
-				ID:         id,
-			}, 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
-		}
-	case TaskTypeSiteInfoUpdate:
-		var payload TaskPayload[domain.TaskSiteInfoUpdate]
-		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 := handleTaskSiteInfoUpdate(ctx, payload.Payload); err != nil {
-			ackTask(TaskStreamInfo{
-				Ctx:        ctx,
-				StreamName: streamName,
-				GroupName:  groupName,
-				ID:         id,
-			}, false, fmt.Errorf("handle task SiteInfoUpdate error - %s", err.Error()))
-			return
-		}
-	default:
-		logger.Std.Println("Unknown task type:", base.Type)
-	}
-
-	// 任务完成
-	ackTask(TaskStreamInfo{
-		Ctx:        ctx,
-		StreamName: streamName,
-		GroupName:  groupName,
-		ID:         id,
-	}, true, nil)
-}
-
-func ackTask(streamInfo TaskStreamInfo, success bool, err error) {
-	if success {
-		// 确认任务完成
-		if err := cache.XAckDel(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); err != nil {
-			logger.Std.Println("XAck error:", err)
-		}
-	} else {
-		if !slices.Contains(failedTaskList, streamInfo.ID) {
-			failedTaskList = append(failedTaskList, streamInfo.ID)
+		// 查询失败的任务(archived/failed)
+		archivedTasks, err := inspector.ListArchivedTasks(queue, asynq.PageSize(100))
+		if err == nil {
+			for _, t := range archivedTasks {
+				result = append(result, dto.BackgroundTaskItem{
+					Id:      t.ID,
+					Status:  "failed",
+					Retry:   int64(t.Retried),
+					Payload: string(t.Payload),
+				})
+			}
 		}
-		// 任务失败
-		logger.Std.Println("Task queue execution failed, error:", err)
 	}
 
-	if err != nil {
-		zap.L().Error("taskQueue ackTask error.", zap.String("err", err.Error()))
-	}
+	return result, nil
 }
 
-func EnqueueTask[T any](payload TaskPayload[T]) error {
-	data, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	if _, err := cache.XAdd(ctx, streamName, map[string]interface{}{"payload": string(data)}); err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func EnqueueUniqueTask[T any](payload TaskPayload[T]) error {
-	data, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	key := uniqueTaskKeyPrefix + payload.Type
-
-	lock, ok, err := cache.TryLock(ctx, key, 2*time.Hour)
-	if err != nil {
-		return err
-	}
-
-	if !ok {
-		return fmt.Errorf("task type already exists: %s", payload.Type)
-	}
-
-	if _, err := cache.XAdd(ctx, streamName, map[string]interface{}{"payload": string(data), "uniqueLockKey": lock.GetKey(), "uniqueLockToken": lock.GetToken()}); err != nil {
-		lock.Release(ctx)
-		return err
-	}
-
-	return nil
-}
-
-func FinishUniqueTask(key string, token string) {
-	cache.LockRelease(ctx, key, token)
-}
-
-func GetTaskInfoList() ([]dto.BackgroundTaskItem, error) {
-	c, cancel := context.WithTimeout(ctx, 15*time.Second)
-	defer cancel()
-
-	allMsg, err := cache.XRangeN(c, streamName, "-", "+", 1000) // 查询所有信息,限制1000条
-	if err != nil {
-		zap.L().Error("XRangeN查询失败", zap.String("err", err.Error()))
-		return nil, err
-	}
-
-	res := []dto.BackgroundTaskItem{}
-	for _, msg := range append(errorTaskList, allMsg...) {
-		// 判断任务状态
-		taskStatus := "pending" // 默认为pending
-		if msg.ID == runningID {
-			taskStatus = "running"
-		} else if slices.Contains(failedTaskList, msg.ID) {
-			taskStatus = "failed"
-		}
-
-		// 获取任务重试次数
-		var retryCount int64
-		pendingRes, err := cache.XPendingExtOne(ctx, streamName, groupName, retryConsumerID, msg.ID)
-		if err != nil {
-			zap.L().Error("XPendingExtOne查询失败", zap.String("err", err.Error()))
-			return nil, err
-		} else if pendingRes == nil {
-			retryCount = 0
-		} else {
-			retryCount = pendingRes.RetryCount
-		}
-
-		// 获取任务参数
-		payload, ok := msg.Values["payload"].(string)
-		if !ok {
-			payload = fmt.Sprint(msg.Values["taskId"])
-		}
-		item := dto.BackgroundTaskItem{
-			Id:      msg.ID,
-			Status:  taskStatus,
-			Retry:   retryCount,
-			Payload: payload,
-		}
-
-		res = append(res, item)
-	}
-	return res, nil
+// newTaskID 生成固定格式的 TaskID,方便追踪
+func newTaskID(taskType string, id string) string {
+	return fmt.Sprintf("%s:%s", taskType, id)
 }

+ 41 - 57
service/task/queue/fwhQueue/fwhTaskShutdownStatistics.go

@@ -2,34 +2,55 @@ package fwhQueue
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/jobs"
-	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
 
+	"github.com/hibiken/asynq"
 	"go.mongodb.org/mongo-driver/bson/primitive"
 	"go.uber.org/zap"
 )
 
-var (
-	TaskTypeShutdownStatistics = "task_ShutdownStatistics"
-)
+// handleShutdownStatistics 是 Shutdown 统计任务的 Asynq handler
+func handleShutdownStatistics(ctx context.Context, task *asynq.Task) error {
+	var payload domain.TaskShutdownStatistics
+	if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+		return fmt.Errorf("unmarshal ShutdownStatistics payload: %w", err)
+	}
+
+	zap.L().Info("[ShutdownStats] 开始执行", zap.String("date", payload.Date))
+
+	err := executeShutdownStatistics(ctx, payload)
+	if err != nil {
+		zap.L().Error("[ShutdownStats] 任务执行失败", zap.String("date", payload.Date), zap.String("err", err.Error()))
+		return nil // 业务错误不重试
+	}
+
+	zap.L().Info("[ShutdownStats] 任务执行成功", zap.String("date", payload.Date))
+	return nil
+}
+
+// executeShutdownStatistics 执行 Shutdown 统计
+func executeShutdownStatistics(ctx context.Context, payload domain.TaskShutdownStatistics) error {
+	records, err := repositories.QuerySiteShutdownList(payload.Date, 0)
+	if err != nil {
+		return fmt.Errorf("查询 Shutdown 列表失败: %w", err)
+	}
 
-func handleTaskShutdownStatistics(ctx context.Context, payload domain.TaskShutdownStatistics) error {
-	fmt.Printf("开始执行任务 %v\n", payload)
-	err := executeTaskShutdownStatistics(ctx, payload)
-	// 记录任务执行结果
+	// 分析 Shutdown 统计数据
+	err = jobs.StatisticsShutdown(payload.Date, records, analysisShutdownSiteForNew)
 	if err != nil {
-		fmt.Printf("任务执行失败 %s\n", err.Error())
-	} else {
-		fmt.Println("任务执行成功")
+		return fmt.Errorf("Shutdown 统计分析失败(date=%s): %w", payload.Date, err)
 	}
 
-	return err
+	return nil
 }
 
-func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
+// analysisShutdownSiteForNew 为 Shutdown 统计触发站点分析
+// 复用原有逻辑,通过新队列入队
+func analysisShutdownSiteForNew(sn string, date string) (*primitive.ObjectID, error) {
 	// 读取已完成或正在进行的站点分析
 	queryTimePeriod := [2]string{date, date}
 	queryTrigger := domain.TriggerAuto
@@ -38,16 +59,14 @@ func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
 		return nil, err
 	}
 	if res == nil {
-		return nil, fmt.Errorf("GetSiteAnalysisManageList查询结过为空")
+		return nil, fmt.Errorf("GetSiteAnalysisManageList 查询结果为空")
 	}
-	queryInfoList := res.Data
 
-	// 判断是否有相同的分析数据
-	if len(queryInfoList) > 0 {
-		// 站点分析已存在,返回第一个数据的ID
-		return &queryInfoList[0].Id, nil
+	if len(res.Data) > 0 {
+		return &res.Data[0].Id, nil
 	}
 
+	// 创建新的分析任务
 	writeConfig := domain.SiteAnalysisConfigWrite{
 		Sn:          sn,
 		Description: fmt.Sprintf("Shutdown站点分析 %s", date),
@@ -63,48 +82,13 @@ func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
 		return nil, err
 	}
 
+	// 通过新队列入队站点分析任务
 	EnqueueSiteAnalysisTask(domain.TaskSiteAnalysis{
 		Id:         id,
 		Sn:         writeConfig.Sn,
 		TimePeriod: writeConfig.TimePeriod,
-		Options: domain.SiteAnalysisOptions{
-			EnableTimeline: writeConfig.Options.EnableTimeline,
-			EnableRuntime:  writeConfig.Options.EnableRuntime,
-		},
-	}, true)
+		Options:    writeConfig.Options,
+	})
 
 	return &id, nil
 }
-
-var executeTaskShutdownStatistics = func(ctx context.Context, payload domain.TaskShutdownStatistics) error {
-	executeDate := payload.Date
-
-	records, err := repositories.QuerySiteShutdownList(executeDate, 0) // 请求所有设备
-	if err != nil {
-		return err
-	}
-
-	// 分析Shutdown统计数据,并将结果写入数据库
-	fmt.Println("分析Shutdown统计数据,并将结果写入数据库")
-	err = jobs.StatisticsShutdown(executeDate, records, analysisShutdownSite)
-	if err != nil {
-		zap.L().Error("Shutdown统计数据分析失败", zap.String("date", payload.Date))
-		return err
-	}
-
-	return nil
-}
-
-func EnqueueShutdownStatisticsTask(payload domain.TaskShutdownStatistics) error {
-	p := TaskPayload[domain.TaskShutdownStatistics]{
-		TaskPayloadBase: TaskPayloadBase{
-			Type: TaskTypeShutdownStatistics,
-		},
-		Payload: payload,
-	}
-	if err := EnqueueTask[domain.TaskShutdownStatistics](p); err != nil {
-		logger.Std.Printf("EnqueueShutdownStatisticsTask - %s : %s\n", payload.Id, err.Error())
-		return err
-	}
-	return nil
-}

+ 46 - 97
service/task/queue/fwhQueue/fwhTaskSiteAnalysis.go

@@ -2,90 +2,70 @@ package fwhQueue
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/jobs"
-	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
 	"time"
 
+	"github.com/hibiken/asynq"
 	"go.uber.org/zap"
 )
 
-var (
-	TaskTypeSiteAnalysis = "task_SiteAnalysis"
-)
+// handleSiteAnalysis 是站点分析任务的 Asynq handler
+func handleSiteAnalysis(ctx context.Context, task *asynq.Task) error {
+	var payload domain.TaskSiteAnalysis
+	if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+		return fmt.Errorf("unmarshal SiteAnalysis payload: %w", err)
+	}
 
-// type TaskSiteAnalysis = types.TaskSiteAnalysis
+	taskId := payload.Id.Hex()
 
-func handleTaskSiteAnalysis(ctx context.Context, payload domain.TaskSiteAnalysis) error {
 	// 更新运行状态
-	fmt.Println("更新任务运行状态")
-	if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressRunning); repoErr != nil {
-		zap.L().Error("更新站点分析结果状态失败(Running)", zap.String("id", payload.Id.String()))
-		return repoErr
+	if err := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressRunning); err != nil {
+		zap.L().Error("[SiteAnalysis] 更新状态失败(Running)", zap.String("id", taskId))
+		return err
 	}
 
-	fmt.Printf("开始执行任务 %v\n", payload)
-	err := executeTaskSiteAnalysis(ctx, payload)
-	// 记录任务执行结果
+	// 执行分析逻辑
+	err := executeSiteAnalysis(ctx, payload)
+
 	if err != nil {
-		fmt.Printf("任务执行失败 %s\n", err.Error())
-		if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressError); repoErr != nil {
-			zap.L().Error("更新站点分析结果状态失败(Error)", zap.String("id", payload.Id.String()))
-			return repoErr
-		}
-	} else {
-		fmt.Println("任务执行成功")
-		if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressComplete); repoErr != nil {
-			zap.L().Error("更新站点分析结果状态失败(Complete)", zap.String("id", payload.Id.String()), zap.String("err", repoErr.Error()))
-			return repoErr
-		}
+		zap.L().Error("[SiteAnalysis] 任务执行失败", zap.String("id", taskId), zap.String("err", err.Error()))
+		repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressError)
+		return nil // 返回 nil 避免 Asynq 重试(业务错误不需要重试)
 	}
 
-	return err
+	zap.L().Info("[SiteAnalysis] 任务执行成功", zap.String("id", taskId))
+	repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressComplete)
+	return nil
 }
 
-var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteAnalysis) error {
-	// 转换时间范围,只取用日期部分
+// executeSiteAnalysis 执行站点分析
+func executeSiteAnalysis(ctx context.Context, payload domain.TaskSiteAnalysis) 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 err
+		return fmt.Errorf("startTime 格式错误: %w", 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 err
+		return fmt.Errorf("endTime 格式错误: %w", err)
 	}
-
-	// 将 endTime 调整到当天的 23:59:59
 	endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
 
-	fmt.Println(payload.Options)
-
-	// 创建记录数据库用于存储原始MQTT日志请求数据
-	queryRecord, err := repositories.CreateSitesAnalysisRecordTable(string(payload.Id.Hex()))
+	// 创建记录数据库用于存储原始 MQTT 日志请求数据
+	queryRecord, err := repositories.CreateSitesAnalysisRecordTable(payload.Id.Hex())
 	if err != nil {
 		return err
 	}
 
 	if payload.Options.EnableTimeline {
-		// 时间线展示功能
-		// Site 时间线生成逻辑:
-		// 1、分析MQTT日志0和101,获取离线上线状态
-		// 2、分析事件日志,获取站点的事件触发记录
-		// 3、分析告警日志,获取站点的告警开始-结束触发记录
-		// 4、分析MQTT日志357,获取站点模式切换记录
-		// 最终处理:获取事件时间点的MQTT日志201数据,关联到事件点。
-		// if payload.SiteAnalysisOptions.EnableTimeline
-
-		// 查询MQTT日志0和101
-		fmt.Println("查询MQTT日志0")
+		// 查询 MQTT 日志 0
 		logList_0, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_0, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志0失败", zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("查询MQTT日志0失败: %w", err)
 		}
 		for _, l := range logList_0 {
 			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
@@ -93,11 +73,10 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			}
 		}
 
-		fmt.Println("查询MQTT日志101")
+		// 查询 MQTT 日志 101
 		logList_101, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_101, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志101失败", zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("查询MQTT日志101失败: %w", err)
 		}
 		for _, l := range logList_101 {
 			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
@@ -105,31 +84,26 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			}
 		}
 
-		// 查询站点的事件触发记录
-		fmt.Println("查询站点的事件触发记录")
+		// 查询事件触发记录
 		eventLog, err := repositories.QueryEventOrAlarmListOnTimeRange(payload.Sn, startTime, endTime, repositories.Event)
 		if err != nil {
-			zap.L().Error("查询事件日志失败", zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("查询事件日志失败: %w", err)
 		}
 
-		time.Sleep(time.Second) // 延时1s
+		time.Sleep(time.Second)
 
-		// 查询站点的告警开始-结束触发记录
-		fmt.Println("查询站点的告警开始-结束触发记录")
+		// 查询告警触发记录
 		alarmLog, err := repositories.QueryEventOrAlarmListOnTimeRange(payload.Sn, startTime, endTime, repositories.Alarm)
 		if err != nil {
-			zap.L().Error("查询事件日志失败", zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("查询告警日志失败: %w", err)
 		}
 
-		time.Sleep(time.Second) // 延时1s
+		time.Sleep(time.Second)
 
-		// 查询MQTT日志357
+		// 查询 MQTT 日志 357
 		logList_357, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_357, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志357失败", zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("查询MQTT日志357失败: %w", err)
 		}
 		for _, l := range logList_357 {
 			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
@@ -137,8 +111,7 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			}
 		}
 
-		// 分析数据,并将结果写入数据库
-		fmt.Println("分析数据,并将结果写入数据库")
+		// 分析时间线数据
 		err = jobs.AnalysisSiteTimeline(payload.Id, jobs.SiteDataSet{
 			Log_0:    logList_0,
 			Log_101:  logList_101,
@@ -147,20 +120,15 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			AlarmLog: alarmLog,
 		})
 		if err != nil {
-			zap.L().Error("站点时间线数据分析失败", zap.String("sn", payload.Sn), zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("站点时间线分析失败(sn=%s): %w", payload.Sn, err)
 		}
 	}
 
 	if payload.Options.EnableRuntime {
-		// 实时数据展示
-
-		// 查询MQTT日志201
-		fmt.Println("查询MQTT日志201")
+		// 查询 MQTT 日志 201
 		logList_201, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_201, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志201失败", zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("查询MQTT日志201失败: %w", err)
 		}
 		for _, l := range logList_201 {
 			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
@@ -168,33 +136,14 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			}
 		}
 
-		// 分析数据,并将结果写入数据库
-		fmt.Println("分析数据,并将结果写入数据库")
+		// 分析运行时数据
 		err = jobs.AnalysisSiteRuntime(payload.Id, jobs.SiteDataSet{
 			Log_201: logList_201,
 		})
 		if err != nil {
-			zap.L().Error("站点运行时数据分析失败", zap.String("sn", payload.Sn), zap.String("err", err.Error()))
-			return err
+			return fmt.Errorf("站点运行时数据分析失败(sn=%s): %w", payload.Sn, err)
 		}
 	}
 
 	return nil
 }
-
-// 插入站点分析队列
-// payload - 任务参数
-// auto - 是否是自动插入
-func EnqueueSiteAnalysisTask(payload domain.TaskSiteAnalysis, auto bool) error {
-	p := TaskPayload[domain.TaskSiteAnalysis]{
-		TaskPayloadBase: TaskPayloadBase{
-			Type: TaskTypeSiteAnalysis,
-		},
-		Payload: payload,
-	}
-	if err := EnqueueTask[domain.TaskSiteAnalysis](p); err != nil {
-		logger.Std.Printf("EnqueueSiteAnalysisTask - %s : %s\n", payload.Id, err.Error())
-		return err
-	}
-	return nil
-}

+ 24 - 47
service/task/queue/fwhQueue/fwhTaskSiteInfoUpdate.go

@@ -2,76 +2,68 @@ package fwhQueue
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
 
+	"github.com/hibiken/asynq"
 	"go.uber.org/zap"
 )
 
-var (
-	TaskTypeSiteInfoUpdate = "task_SiteInfoUpdate"
-)
+// handleSiteInfoUpdate 是站点信息更新任务的 Asynq handler
+func handleSiteInfoUpdate(ctx context.Context, task *asynq.Task) error {
+	var payload domain.TaskSiteInfoUpdate
+	if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+		return fmt.Errorf("unmarshal SiteInfoUpdate payload: %w", err)
+	}
 
-// type TaskSiteInfoUpdate = types.TaskSiteInfoUpdate
+	taskId := payload.Id.Hex()
 
-func handleTaskSiteInfoUpdate(ctx context.Context, payload domain.TaskSiteInfoUpdate) error {
 	// 更新运行状态
-	fmt.Println("更新任务运行状态")
-	if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressRunning, 0); repoErr != nil {
-		zap.L().Error("更新站点信息Update结果状态失败(Running)", zap.String("err", repoErr.Error()))
-		return repoErr
+	if err := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressRunning, 0); err != nil {
+		zap.L().Error("[SiteInfoUpdate] 更新状态失败(Running)", zap.String("id", taskId))
+		return err
 	}
 
-	fmt.Printf("开始执行任务 %v\n", payload)
-	total, err := executeTaskSiteInfoUpdate(ctx, payload)
-	// 记录任务执行结果
+	// 执行任务
+	total, err := executeSiteInfoUpdate(ctx, payload)
 	if err != nil {
-		fmt.Printf("任务执行失败 %s\n", err.Error())
-		if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressError, 0); repoErr != nil {
-			zap.L().Error("更新站点信息Update结果状态失败(Error)", zap.String("err", repoErr.Error()))
-			return repoErr
-		}
-	} else {
-		fmt.Println("任务执行成功")
-		if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressComplete, total); repoErr != nil {
-			zap.L().Error("更新站点信息Update结果状态失败(Complete)", zap.String("err", repoErr.Error()))
-			return repoErr
-		}
+		zap.L().Error("[SiteInfoUpdate] 任务执行失败", zap.String("id", taskId), zap.String("err", err.Error()))
+		repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressError, 0)
+		return nil // 业务错误不重试
 	}
 
-	return err
+	zap.L().Info("[SiteInfoUpdate] 任务执行成功", zap.String("id", taskId), zap.Uint64("total", total))
+	repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressComplete, total)
+	return nil
 }
 
-var executeTaskSiteInfoUpdate = func(ctx context.Context, payload domain.TaskSiteInfoUpdate) (uint64, error) {
+// executeSiteInfoUpdate 执行站点信息更新
+func executeSiteInfoUpdate(ctx context.Context, payload domain.TaskSiteInfoUpdate) (uint64, error) {
 	var pageSize uint = 50
 	var pageNum uint = 1
 	var total uint64 = 0
 	var realTotal uint64 = 0
 
-	fmt.Println("查询站点信息")
 	for {
 		resp, err := repositories.QuerySiteInfoList(dto.SiteInfoPara{
 			PageSize: pageSize,
 			PageNum:  pageNum,
 		})
 		if err != nil {
-			zap.L().Error("查询站点信息失败", zap.String("err", err.Error()), zap.Uint("pageSize", pageSize), zap.Uint("pageNum", pageNum), zap.Uint64("total", total))
-			return 0, err
+			return 0, fmt.Errorf("查询站点信息失败(page=%d): %w", pageNum, err)
 		}
 
 		for _, info := range *resp.Result {
 			if err := repositories.InsertSiteInfoData(*domain.FwhSiteInfoToSiteInfoDomain(&info)); err != nil {
-				zap.L().Error("保存站点信息失败", zap.String("err", err.Error()), zap.Uint("pageSize", pageSize), zap.Uint("pageNum", pageNum), zap.Uint64("total", total))
-				return 0, err
+				return 0, fmt.Errorf("保存站点信息失败(page=%d): %w", pageNum, err)
 			}
 			realTotal++
 		}
 
 		pageNum++
-
 		total = resp.Total
 		if (int64(pageNum)-1)*int64(pageSize) >= int64(total) {
 			break
@@ -80,18 +72,3 @@ var executeTaskSiteInfoUpdate = func(ctx context.Context, payload domain.TaskSit
 
 	return realTotal, nil
 }
-
-// 插入站点分析队列
-func EnqueueSiteInfoUpdateTask(payload domain.TaskSiteInfoUpdate) error {
-	p := TaskPayload[domain.TaskSiteInfoUpdate]{
-		TaskPayloadBase: TaskPayloadBase{
-			Type: TaskTypeSiteInfoUpdate,
-		},
-		Payload: payload,
-	}
-	if err := EnqueueUniqueTask[domain.TaskSiteInfoUpdate](p); err != nil {
-		logger.Std.Printf("EnqueueSiteInfoUpdateTask error : %s\n", err.Error())
-		return err
-	}
-	return nil
-}

+ 1 - 1
service/task/queue/fwhQueue/fwhTaskAlarmStatistics.go → service/task/queue/fwhQueueOld/fwhTaskAlarmStatistics.go

@@ -1,4 +1,4 @@
-package fwhQueue
+package fwhQueueOld
 
 import (
 	"context"

+ 559 - 0
service/task/queue/fwhQueueOld/fwhTaskBatchDownload.go

@@ -0,0 +1,559 @@
+package fwhQueueOld
+
+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.Hex()))
+		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.Hex()))
+			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.Hex()), 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.Hex()), 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.Hex())
+			if err != nil {
+				zap.L().Error("BatchDownload - 读取CallFileTaskStandbyList失败", zap.String("id", payload.Id.Hex()), zap.String("err", err.Error()))
+				return nil, err
+			}
+
+			if len(standbyList) == 0 {
+				break
+			}
+
+			// 等待1分钟后再判断
+			time.Sleep(time.Minute)
+		}
+
+		// 处理失败列表
+		failedList, err := repositories.GetBatchDownloadCallFileTaskFailedList(payload.Id.Hex())
+		if err != nil {
+			zap.L().Error("BatchDownload - 读取CallFileTaskFailedList失败", zap.String("id", payload.Id.Hex()), zap.String("err", err.Error()))
+			return nil, err
+		}
+
+		// 删除task list
+		repositories.DeleteBatchDownloadCallFileTaskList(payload.Id.Hex())
+
+		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
+	}
+
+	// 未召唤到记录,不添加任务
+	if len(callInfoList) == 0 {
+		return nil
+	}
+
+	// 提取符合日期范围的选项
+	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
+}

+ 373 - 0
service/task/queue/fwhQueueOld/fwhTaskQueue.go

@@ -0,0 +1,373 @@
+package fwhQueueOld
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/logger"
+	"slices"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+	"go.uber.org/zap"
+)
+
+var (
+	ctx        = context.Background()
+	streamName = "fwhtoolbox:taskQueue:taskStream"
+	groupName  = "task_queue"
+	consumerID = "worker-taskQueue"
+
+	// retryConsumerID = "retry-taskQueue"
+	retryConsumerID = consumerID
+	maxRetry        = 3
+	idleTime        = 10 * time.Minute
+
+	// 状态管理相关变量
+	runningID      = ""                 // 当前执行的任务ID
+	failedTaskList = []string{}         // 失败且未处理任务ID列表
+	errorTaskList  = []redis.XMessage{} // 超过重试次数仍失败的任务列表
+
+	// 唯一任务key前缀
+	uniqueTaskKeyPrefix = "fwhtoolbox:taskQueue:taskUnique:"
+)
+
+type TaskStreamInfo struct {
+	Ctx        context.Context
+	StreamName string
+	GroupName  string
+	ID         string
+}
+
+type TaskPayloadBase struct {
+	Type string `json:"type"`
+}
+
+type TaskPayload[T any] struct {
+	TaskPayloadBase
+	Payload T `json:"payload"`
+}
+
+func InitFwhTaskQueue() {
+	// 确保消费组存在(忽略已存在错误)
+	_ = cache.XGroupCreate(ctx, streamName, groupName, "$")
+
+	// 启动后台任务队列(不会阻塞主线程)
+	go startTaskQueueWorker()
+}
+
+// 任务消费者(单 worker,顺序执行)
+func startTaskQueueWorker() {
+	streamsMap := make(map[string]string)
+	streamsMap[streamName] = ">"
+
+	for {
+		// 处理新任务
+		res, err := cache.XReadGroup(ctx, groupName, consumerID, streamsMap, 1) // 等待任务
+		if err != nil {
+			if err == redis.Nil {
+				// 未读取到新消息
+				// 处理滞留的消息
+				retryRes, _, err := cache.XAutoClaim(ctx, streamName, groupName, retryConsumerID, idleTime, 1)
+				if err != nil {
+					if err == redis.Nil {
+						continue
+					}
+					logger.Std.Println("XAutoClaim error:", err)
+					time.Sleep(time.Second)
+					continue
+				}
+
+				for _, msg := range retryRes {
+					pendingRes, err := cache.XPendingExtOne(ctx, streamName, groupName, retryConsumerID, msg.ID)
+					if err != nil {
+						logger.Std.Println("XPendingExtOne error:", err)
+					} else if pendingRes == nil {
+						panic("无法读取XPendingExtOne")
+					}
+
+					// 判断重试次数
+					if pendingRes.RetryCount > int64(maxRetry) {
+						if slices.Contains(failedTaskList, msg.ID) {
+							failedTaskList = slices.DeleteFunc(failedTaskList, func(s string) bool { return s == msg.ID })
+						}
+						errorTaskList = append(errorTaskList, msg)
+						ackTask(TaskStreamInfo{
+							Ctx:        ctx,
+							StreamName: streamName,
+							GroupName:  groupName,
+							ID:         msg.ID,
+						}, true, fmt.Errorf("任务超过最大重试次数。"))
+						continue
+					}
+
+					zap.L().Warn("taskQueue任务重试。", zap.String("ID", msg.ID), zap.Int64("count", pendingRes.RetryCount))
+
+					runningID = msg.ID
+					handleTaskQueueMsg(msg)
+				}
+				continue
+			}
+			logger.Std.Println("XReadGroup error:", err)
+			time.Sleep(time.Second)
+			continue
+		}
+
+		for _, stream := range res {
+			for _, message := range stream.Messages {
+				runningID = message.ID
+				handleTaskQueueMsg(message)
+			}
+		}
+	}
+}
+
+// 处理单条消息
+func handleTaskQueueMsg(message redis.XMessage) {
+	id := message.ID
+	rawPayload := message.Values["payload"].(string)
+
+	var rawLockKey *string
+	var rawLockToken *string
+
+	if val, ok := message.Values["uniqueLockKey"]; ok {
+		if str, ok := val.(string); ok {
+			rawLockKey = &str
+		}
+	}
+
+	if val, ok := message.Values["uniqueLockToken"]; ok {
+		if str, ok := val.(string); ok {
+			rawLockToken = &str
+		}
+	}
+
+	defer func() {
+		if rawLockKey != nil && rawLockToken != nil {
+			// 释放唯一任务锁
+			FinishUniqueTask(*rawLockKey, *rawLockToken)
+		}
+	}()
+
+	// 先反序列化成 map 确定 type
+	var base TaskPayloadBase
+	if err := json.Unmarshal([]byte(rawPayload), &base); err != nil {
+		ackTask(TaskStreamInfo{
+			Ctx:        ctx,
+			StreamName: streamName,
+			GroupName:  groupName,
+			ID:         id,
+		}, false, fmt.Errorf("unmarshal base error - %s", err.Error()))
+		return
+	}
+
+	switch base.Type {
+	case TaskTypeSiteAnalysis: // 站点分析任务
+		var payload TaskPayload[domain.TaskSiteAnalysis]
+		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 := handleTaskSiteAnalysis(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
+			return
+		}
+	case TaskTypeShutdownStatistics: // Shutdown统计任务
+		var payload TaskPayload[domain.TaskShutdownStatistics]
+		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 := handleTaskShutdownStatistics(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, 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
+		}
+	case TaskTypeSiteInfoUpdate:
+		var payload TaskPayload[domain.TaskSiteInfoUpdate]
+		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 := handleTaskSiteInfoUpdate(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("handle task SiteInfoUpdate error - %s", err.Error()))
+			return
+		}
+	default:
+		logger.Std.Println("Unknown task type:", base.Type)
+	}
+
+	// 任务完成
+	ackTask(TaskStreamInfo{
+		Ctx:        ctx,
+		StreamName: streamName,
+		GroupName:  groupName,
+		ID:         id,
+	}, true, nil)
+}
+
+func ackTask(streamInfo TaskStreamInfo, success bool, err error) {
+	if success {
+		// 确认任务完成
+		if err := cache.XAckDel(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); err != nil {
+			logger.Std.Println("XAck error:", err)
+		}
+	} else {
+		if !slices.Contains(failedTaskList, streamInfo.ID) {
+			failedTaskList = append(failedTaskList, streamInfo.ID)
+		}
+		// 任务失败
+		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[T any](payload TaskPayload[T]) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+
+	if _, err := cache.XAdd(ctx, streamName, map[string]interface{}{"payload": string(data)}); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func EnqueueUniqueTask[T any](payload TaskPayload[T]) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+
+	key := uniqueTaskKeyPrefix + payload.Type
+
+	lock, ok, err := cache.TryLock(ctx, key, 2*time.Hour)
+	if err != nil {
+		return err
+	}
+
+	if !ok {
+		return fmt.Errorf("task type already exists: %s", payload.Type)
+	}
+
+	if _, err := cache.XAdd(ctx, streamName, map[string]interface{}{"payload": string(data), "uniqueLockKey": lock.GetKey(), "uniqueLockToken": lock.GetToken()}); err != nil {
+		lock.Release(ctx)
+		return err
+	}
+
+	return nil
+}
+
+func FinishUniqueTask(key string, token string) {
+	cache.LockRelease(ctx, key, token)
+}
+
+func GetTaskInfoList() ([]dto.BackgroundTaskItem, error) {
+	c, cancel := context.WithTimeout(ctx, 15*time.Second)
+	defer cancel()
+
+	allMsg, err := cache.XRangeN(c, streamName, "-", "+", 1000) // 查询所有信息,限制1000条
+	if err != nil {
+		zap.L().Error("XRangeN查询失败", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	res := []dto.BackgroundTaskItem{}
+	for _, msg := range append(errorTaskList, allMsg...) {
+		// 判断任务状态
+		taskStatus := "pending" // 默认为pending
+		if msg.ID == runningID {
+			taskStatus = "running"
+		} else if slices.Contains(failedTaskList, msg.ID) {
+			taskStatus = "failed"
+		}
+
+		// 获取任务重试次数
+		var retryCount int64
+		pendingRes, err := cache.XPendingExtOne(ctx, streamName, groupName, retryConsumerID, msg.ID)
+		if err != nil {
+			zap.L().Error("XPendingExtOne查询失败", zap.String("err", err.Error()))
+			return nil, err
+		} else if pendingRes == nil {
+			retryCount = 0
+		} else {
+			retryCount = pendingRes.RetryCount
+		}
+
+		// 获取任务参数
+		payload, ok := msg.Values["payload"].(string)
+		if !ok {
+			payload = fmt.Sprint(msg.Values["taskId"])
+		}
+		item := dto.BackgroundTaskItem{
+			Id:      msg.ID,
+			Status:  taskStatus,
+			Retry:   retryCount,
+			Payload: payload,
+		}
+
+		res = append(res, item)
+	}
+	return res, nil
+}

+ 110 - 0
service/task/queue/fwhQueueOld/fwhTaskShutdownStatistics.go

@@ -0,0 +1,110 @@
+package fwhQueueOld
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/jobs"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+var (
+	TaskTypeShutdownStatistics = "task_ShutdownStatistics"
+)
+
+func handleTaskShutdownStatistics(ctx context.Context, payload domain.TaskShutdownStatistics) error {
+	fmt.Printf("开始执行任务 %v\n", payload)
+	err := executeTaskShutdownStatistics(ctx, payload)
+	// 记录任务执行结果
+	if err != nil {
+		fmt.Printf("任务执行失败 %s\n", err.Error())
+	} else {
+		fmt.Println("任务执行成功")
+	}
+
+	return err
+}
+
+func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
+	// 读取已完成或正在进行的站点分析
+	queryTimePeriod := [2]string{date, date}
+	queryTrigger := domain.TriggerAuto
+	res, err := repositories.GetSiteAnalysisManageList(1, 1000, &sn, &queryTimePeriod, nil, &queryTrigger)
+	if err != nil {
+		return nil, err
+	}
+	if res == nil {
+		return nil, fmt.Errorf("GetSiteAnalysisManageList查询结过为空")
+	}
+	queryInfoList := res.Data
+
+	// 判断是否有相同的分析数据
+	if len(queryInfoList) > 0 {
+		// 站点分析已存在,返回第一个数据的ID
+		return &queryInfoList[0].Id, nil
+	}
+
+	writeConfig := domain.SiteAnalysisConfigWrite{
+		Sn:          sn,
+		Description: fmt.Sprintf("Shutdown站点分析 %s", date),
+		TimePeriod:  [2]string{date, date},
+		Options: domain.SiteAnalysisOptions{
+			EnableTimeline: true,
+			EnableRuntime:  true,
+		},
+	}
+
+	id, err := repositories.InsertSiteAnalysisConfig(writeConfig, true)
+	if err != nil {
+		return nil, err
+	}
+
+	EnqueueSiteAnalysisTask(domain.TaskSiteAnalysis{
+		Id:         id,
+		Sn:         writeConfig.Sn,
+		TimePeriod: writeConfig.TimePeriod,
+		Options: domain.SiteAnalysisOptions{
+			EnableTimeline: writeConfig.Options.EnableTimeline,
+			EnableRuntime:  writeConfig.Options.EnableRuntime,
+		},
+	}, true)
+
+	return &id, nil
+}
+
+var executeTaskShutdownStatistics = func(ctx context.Context, payload domain.TaskShutdownStatistics) error {
+	executeDate := payload.Date
+
+	records, err := repositories.QuerySiteShutdownList(executeDate, 0) // 请求所有设备
+	if err != nil {
+		return err
+	}
+
+	// 分析Shutdown统计数据,并将结果写入数据库
+	fmt.Println("分析Shutdown统计数据,并将结果写入数据库")
+	err = jobs.StatisticsShutdown(executeDate, records, analysisShutdownSite)
+	if err != nil {
+		zap.L().Error("Shutdown统计数据分析失败", zap.String("date", payload.Date))
+		return err
+	}
+
+	return nil
+}
+
+func EnqueueShutdownStatisticsTask(payload domain.TaskShutdownStatistics) error {
+	p := TaskPayload[domain.TaskShutdownStatistics]{
+		TaskPayloadBase: TaskPayloadBase{
+			Type: TaskTypeShutdownStatistics,
+		},
+		Payload: payload,
+	}
+	if err := EnqueueTask[domain.TaskShutdownStatistics](p); err != nil {
+		logger.Std.Printf("EnqueueShutdownStatisticsTask - %s : %s\n", payload.Id, err.Error())
+		return err
+	}
+	return nil
+}

+ 200 - 0
service/task/queue/fwhQueueOld/fwhTaskSiteAnalysis.go

@@ -0,0 +1,200 @@
+package fwhQueueOld
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/jobs"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+var (
+	TaskTypeSiteAnalysis = "task_SiteAnalysis"
+)
+
+// type TaskSiteAnalysis = types.TaskSiteAnalysis
+
+func handleTaskSiteAnalysis(ctx context.Context, payload domain.TaskSiteAnalysis) error {
+	// 更新运行状态
+	fmt.Println("更新任务运行状态")
+	if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressRunning); repoErr != nil {
+		zap.L().Error("更新站点分析结果状态失败(Running)", zap.String("id", payload.Id.String()))
+		return repoErr
+	}
+
+	fmt.Printf("开始执行任务 %v\n", payload)
+	err := executeTaskSiteAnalysis(ctx, payload)
+	// 记录任务执行结果
+	if err != nil {
+		fmt.Printf("任务执行失败 %s\n", err.Error())
+		if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressError); repoErr != nil {
+			zap.L().Error("更新站点分析结果状态失败(Error)", zap.String("id", payload.Id.String()))
+			return repoErr
+		}
+	} else {
+		fmt.Println("任务执行成功")
+		if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressComplete); repoErr != nil {
+			zap.L().Error("更新站点分析结果状态失败(Complete)", zap.String("id", payload.Id.String()), zap.String("err", repoErr.Error()))
+			return repoErr
+		}
+	}
+
+	return err
+}
+
+var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteAnalysis) 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 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 err
+	}
+
+	// 将 endTime 调整到当天的 23:59:59
+	endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
+
+	fmt.Println(payload.Options)
+
+	// 创建记录数据库用于存储原始MQTT日志请求数据
+	queryRecord, err := repositories.CreateSitesAnalysisRecordTable(string(payload.Id.Hex()))
+	if err != nil {
+		return err
+	}
+
+	if payload.Options.EnableTimeline {
+		// 时间线展示功能
+		// Site 时间线生成逻辑:
+		// 1、分析MQTT日志0和101,获取离线上线状态
+		// 2、分析事件日志,获取站点的事件触发记录
+		// 3、分析告警日志,获取站点的告警开始-结束触发记录
+		// 4、分析MQTT日志357,获取站点模式切换记录
+		// 最终处理:获取事件时间点的MQTT日志201数据,关联到事件点。
+		// if payload.SiteAnalysisOptions.EnableTimeline
+
+		// 查询MQTT日志0和101
+		fmt.Println("查询MQTT日志0")
+		logList_0, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_0, startTime, endTime)
+		if err != nil {
+			zap.L().Error("查询MQTT日志0失败", zap.String("err", err.Error()))
+			return err
+		}
+		for _, l := range logList_0 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
+
+		fmt.Println("查询MQTT日志101")
+		logList_101, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_101, startTime, endTime)
+		if err != nil {
+			zap.L().Error("查询MQTT日志101失败", zap.String("err", err.Error()))
+			return err
+		}
+		for _, l := range logList_101 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
+
+		// 查询站点的事件触发记录
+		fmt.Println("查询站点的事件触发记录")
+		eventLog, err := repositories.QueryEventOrAlarmListOnTimeRange(payload.Sn, startTime, endTime, repositories.Event)
+		if err != nil {
+			zap.L().Error("查询事件日志失败", zap.String("err", err.Error()))
+			return err
+		}
+
+		time.Sleep(time.Second) // 延时1s
+
+		// 查询站点的告警开始-结束触发记录
+		fmt.Println("查询站点的告警开始-结束触发记录")
+		alarmLog, err := repositories.QueryEventOrAlarmListOnTimeRange(payload.Sn, startTime, endTime, repositories.Alarm)
+		if err != nil {
+			zap.L().Error("查询事件日志失败", zap.String("err", err.Error()))
+			return err
+		}
+
+		time.Sleep(time.Second) // 延时1s
+
+		// 查询MQTT日志357
+		logList_357, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_357, startTime, endTime)
+		if err != nil {
+			zap.L().Error("查询MQTT日志357失败", zap.String("err", err.Error()))
+			return err
+		}
+		for _, l := range logList_357 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
+
+		// 分析数据,并将结果写入数据库
+		fmt.Println("分析数据,并将结果写入数据库")
+		err = jobs.AnalysisSiteTimeline(payload.Id, jobs.SiteDataSet{
+			Log_0:    logList_0,
+			Log_101:  logList_101,
+			Log_357:  logList_357,
+			EventLog: eventLog,
+			AlarmLog: alarmLog,
+		})
+		if err != nil {
+			zap.L().Error("站点时间线数据分析失败", zap.String("sn", payload.Sn), zap.String("err", err.Error()))
+			return err
+		}
+	}
+
+	if payload.Options.EnableRuntime {
+		// 实时数据展示
+
+		// 查询MQTT日志201
+		fmt.Println("查询MQTT日志201")
+		logList_201, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_201, startTime, endTime)
+		if err != nil {
+			zap.L().Error("查询MQTT日志201失败", zap.String("err", err.Error()))
+			return err
+		}
+		for _, l := range logList_201 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
+
+		// 分析数据,并将结果写入数据库
+		fmt.Println("分析数据,并将结果写入数据库")
+		err = jobs.AnalysisSiteRuntime(payload.Id, jobs.SiteDataSet{
+			Log_201: logList_201,
+		})
+		if err != nil {
+			zap.L().Error("站点运行时数据分析失败", zap.String("sn", payload.Sn), zap.String("err", err.Error()))
+			return err
+		}
+	}
+
+	return nil
+}
+
+// 插入站点分析队列
+// payload - 任务参数
+// auto - 是否是自动插入
+func EnqueueSiteAnalysisTask(payload domain.TaskSiteAnalysis, auto bool) error {
+	p := TaskPayload[domain.TaskSiteAnalysis]{
+		TaskPayloadBase: TaskPayloadBase{
+			Type: TaskTypeSiteAnalysis,
+		},
+		Payload: payload,
+	}
+	if err := EnqueueTask[domain.TaskSiteAnalysis](p); err != nil {
+		logger.Std.Printf("EnqueueSiteAnalysisTask - %s : %s\n", payload.Id, err.Error())
+		return err
+	}
+	return nil
+}

+ 97 - 0
service/task/queue/fwhQueueOld/fwhTaskSiteInfoUpdate.go

@@ -0,0 +1,97 @@
+package fwhQueueOld
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+
+	"go.uber.org/zap"
+)
+
+var (
+	TaskTypeSiteInfoUpdate = "task_SiteInfoUpdate"
+)
+
+// type TaskSiteInfoUpdate = types.TaskSiteInfoUpdate
+
+func handleTaskSiteInfoUpdate(ctx context.Context, payload domain.TaskSiteInfoUpdate) error {
+	// 更新运行状态
+	fmt.Println("更新任务运行状态")
+	if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressRunning, 0); repoErr != nil {
+		zap.L().Error("更新站点信息Update结果状态失败(Running)", zap.String("err", repoErr.Error()))
+		return repoErr
+	}
+
+	fmt.Printf("开始执行任务 %v\n", payload)
+	total, err := executeTaskSiteInfoUpdate(ctx, payload)
+	// 记录任务执行结果
+	if err != nil {
+		fmt.Printf("任务执行失败 %s\n", err.Error())
+		if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressError, 0); repoErr != nil {
+			zap.L().Error("更新站点信息Update结果状态失败(Error)", zap.String("err", repoErr.Error()))
+			return repoErr
+		}
+	} else {
+		fmt.Println("任务执行成功")
+		if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressComplete, total); repoErr != nil {
+			zap.L().Error("更新站点信息Update结果状态失败(Complete)", zap.String("err", repoErr.Error()))
+			return repoErr
+		}
+	}
+
+	return err
+}
+
+var executeTaskSiteInfoUpdate = func(ctx context.Context, payload domain.TaskSiteInfoUpdate) (uint64, error) {
+	var pageSize uint = 50
+	var pageNum uint = 1
+	var total uint64 = 0
+	var realTotal uint64 = 0
+
+	fmt.Println("查询站点信息")
+	for {
+		resp, err := repositories.QuerySiteInfoList(dto.SiteInfoPara{
+			PageSize: pageSize,
+			PageNum:  pageNum,
+		})
+		if err != nil {
+			zap.L().Error("查询站点信息失败", zap.String("err", err.Error()), zap.Uint("pageSize", pageSize), zap.Uint("pageNum", pageNum), zap.Uint64("total", total))
+			return 0, err
+		}
+
+		for _, info := range *resp.Result {
+			if err := repositories.InsertSiteInfoData(*domain.FwhSiteInfoToSiteInfoDomain(&info)); err != nil {
+				zap.L().Error("保存站点信息失败", zap.String("err", err.Error()), zap.Uint("pageSize", pageSize), zap.Uint("pageNum", pageNum), zap.Uint64("total", total))
+				return 0, err
+			}
+			realTotal++
+		}
+
+		pageNum++
+
+		total = resp.Total
+		if (int64(pageNum)-1)*int64(pageSize) >= int64(total) {
+			break
+		}
+	}
+
+	return realTotal, nil
+}
+
+// 插入站点分析队列
+func EnqueueSiteInfoUpdateTask(payload domain.TaskSiteInfoUpdate) error {
+	p := TaskPayload[domain.TaskSiteInfoUpdate]{
+		TaskPayloadBase: TaskPayloadBase{
+			Type: TaskTypeSiteInfoUpdate,
+		},
+		Payload: payload,
+	}
+	if err := EnqueueUniqueTask[domain.TaskSiteInfoUpdate](p); err != nil {
+		logger.Std.Printf("EnqueueSiteInfoUpdateTask error : %s\n", err.Error())
+		return err
+	}
+	return nil
+}

+ 1 - 1
service/task/queue/taskQueue.go

@@ -11,6 +11,6 @@ func InitTaskQueue(tc *task_core.TaskCore) {
 		panic("InitTaskQueue failed, TaskCore is nil.")
 	}
 
-	fwhQueue.InitFwhTaskQueue()
+	fwhQueue.InitFwhTaskQueue(tc)
 	simCardQueue.InitSimCardTaskQueue(tc)
 }

+ 248 - 0
service/test/getSiteVersion/main.go

@@ -0,0 +1,248 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/xuri/excelize/v2"
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// SiteVersionInfo 用于存储站点版本相关字段
+type SiteVersionInfo struct {
+	Sn           string `bson:"_id"`
+	Version      string `bson:"version"`
+	ProtocolVer  string `bson:"protocolVer"`
+	SysHdVersion int    `bson:"sysHdVersion"`
+}
+
+// VersionCombination 版本组合
+type VersionCombination struct {
+	Version      string
+	ProtocolVer  string
+	SysHdVersion int
+	Count        int
+	Sites        []string // 记录前几个站点SN作为示例
+}
+
+const (
+	collectionName = "SiteInfoManageData"
+	maxExampleSites = 5 // 每个组合最多记录的示例站点数
+)
+
+func main() {
+	// 从环境变量获取 MongoDB DSN
+	mongoDSN := os.Getenv("MONGO_DSN")
+	if mongoDSN == "" {
+		fmt.Println("请设置 MONGO_DSN 环境变量")
+		fmt.Println("例如: export MONGO_DSN=mongodb://127.0.0.1:27017/FwhToolboxDb")
+		os.Exit(1)
+	}
+
+	// 连接 MongoDB
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoDSN))
+	if err != nil {
+		fmt.Printf("MongoDB 连接失败: %v\n", err)
+		os.Exit(1)
+	}
+	defer client.Disconnect(ctx)
+
+	if err := client.Ping(ctx, nil); err != nil {
+		fmt.Printf("MongoDB Ping 失败: %v\n", err)
+		os.Exit(1)
+	}
+	fmt.Println("MongoDB 连接成功")
+
+	// 从 URI 中解析数据库名
+	parts := strings.Split(mongoDSN, "/")
+	dbName := parts[len(parts)-1]
+	// 移除可能的查询参数
+	if idx := strings.Index(dbName, "?"); idx != -1 {
+		dbName = dbName[:idx]
+	}
+
+	db := client.Database(dbName)
+	collection := db.Collection(collectionName)
+
+	// 只查询需要的字段以提高性能
+	projection := bson.M{
+		"_id":          1,
+		"version":      1,
+		"protocolVer":  1,
+		"sysHdVersion": 1,
+	}
+
+	findOpts := options.Find().SetProjection(projection)
+
+	cursor, err := collection.Find(ctx, bson.M{}, findOpts)
+	if err != nil {
+		fmt.Printf("查询失败: %v\n", err)
+		os.Exit(1)
+	}
+	defer cursor.Close(ctx)
+
+	// 统计版本组合
+	type comboKey struct {
+		Version      string
+		ProtocolVer  string
+		SysHdVersion int
+	}
+
+	comboMap := make(map[comboKey]*VersionCombination)
+	totalCount := 0
+
+	for cursor.Next(ctx) {
+		var site SiteVersionInfo
+		if err := cursor.Decode(&site); err != nil {
+			fmt.Printf("解码失败 (sn=%s): %v\n", site.Sn, err)
+			continue
+		}
+		totalCount++
+
+		key := comboKey{
+			Version:      site.Version,
+			ProtocolVer:  site.ProtocolVer,
+			SysHdVersion: site.SysHdVersion,
+		}
+
+		if combo, exists := comboMap[key]; exists {
+			combo.Count++
+			if len(combo.Sites) < maxExampleSites {
+				combo.Sites = append(combo.Sites, site.Sn)
+			}
+		} else {
+			comboMap[key] = &VersionCombination{
+				Version:      site.Version,
+				ProtocolVer:  site.ProtocolVer,
+				SysHdVersion: site.SysHdVersion,
+				Count:        1,
+				Sites:        []string{site.Sn},
+			}
+		}
+	}
+
+	if err := cursor.Err(); err != nil {
+		fmt.Printf("游标遍历出错: %v\n", err)
+		os.Exit(1)
+	}
+
+	// 将结果转为切片并排序(按数量降序)
+	combos := make([]*VersionCombination, 0, len(comboMap))
+	for _, combo := range comboMap {
+		combos = append(combos, combo)
+	}
+
+	sort.Slice(combos, func(i, j int) bool {
+		return combos[i].Count > combos[j].Count
+	})
+
+	// 输出结果
+	fmt.Printf("\n========== 站点版本组合统计 ==========\n")
+	fmt.Printf("总站点数: %d\n", totalCount)
+	fmt.Printf("唯一版本组合数: %d\n", len(combos))
+	fmt.Printf("======================================\n\n")
+
+	fmt.Printf("%-6s | %-20s | %-15s | %-12s | %s\n",
+		"序号", "Version", "ProtocolVer", "SysHdVersion", "站点数量")
+	fmt.Println(strings.Repeat("-", 80))
+
+	for i, combo := range combos {
+		fmt.Printf("%-6d | %-20s | %-15s | %-12d | %d\n",
+			i+1, combo.Version, combo.ProtocolVer, combo.SysHdVersion, combo.Count)
+	}
+
+	// 输出详细信息(包含示例站点)
+	fmt.Printf("\n\n========== 详细信息(含示例站点)==========\n\n")
+	for i, combo := range combos {
+		fmt.Printf("[%d] Version=%s, ProtocolVer=%s, SysHdVersion=%d (共 %d 个站点)\n",
+			i+1, combo.Version, combo.ProtocolVer, combo.SysHdVersion, combo.Count)
+		fmt.Printf("    示例站点: %s\n", strings.Join(combo.Sites, ", "))
+		fmt.Println()
+	}
+
+	// 生成 Excel 表格
+	if err := exportToExcel(combos, totalCount); err != nil {
+		fmt.Printf("生成 Excel 失败: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+// exportToExcel 将版本组合统计结果导出为 Excel 文件
+func exportToExcel(combos []*VersionCombination, totalCount int) error {
+	f := excelize.NewFile()
+	defer f.Close()
+
+	sheetName := "版本组合统计"
+	// 重命名默认 Sheet
+	f.SetSheetName("Sheet1", sheetName)
+
+	// 设置表头样式
+	headerStyle, _ := f.NewStyle(&excelize.Style{
+		Font:      &excelize.Font{Bold: true, Size: 12},
+		Fill:      excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"#DCE6F1"}},
+		Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
+		Border: []excelize.Border{
+			{Type: "left", Color: "#000000", Style: 1},
+			{Type: "top", Color: "#000000", Style: 1},
+			{Type: "bottom", Color: "#000000", Style: 1},
+			{Type: "right", Color: "#000000", Style: 1},
+		},
+	})
+
+	// 设置列宽
+	f.SetColWidth(sheetName, "A", "A", 8)
+	f.SetColWidth(sheetName, "B", "B", 22)
+	f.SetColWidth(sheetName, "C", "C", 18)
+	f.SetColWidth(sheetName, "D", "D", 15)
+	f.SetColWidth(sheetName, "E", "E", 12)
+	f.SetColWidth(sheetName, "F", "F", 60)
+
+	// 写入汇总信息
+	f.SetCellValue(sheetName, "A1", "站点版本组合统计报告")
+	f.SetCellValue(sheetName, "A2", fmt.Sprintf("总站点数: %d", totalCount))
+	f.SetCellValue(sheetName, "A3", fmt.Sprintf("唯一版本组合数: %d", len(combos)))
+	f.SetCellValue(sheetName, "A4", fmt.Sprintf("生成时间: %s", time.Now().Format("2006-01-02 15:04:05")))
+
+	// 写入表头 (从第6行开始)
+	headers := []string{"序号", "Version", "ProtocolVer", "SysHdVersion", "站点数量", "示例站点"}
+	for i, header := range headers {
+		cell := fmt.Sprintf("%s%d", columnName(i+1), 6)
+		f.SetCellValue(sheetName, cell, header)
+		f.SetCellStyle(sheetName, cell, cell, headerStyle)
+	}
+
+	// 写入数据
+	for i, combo := range combos {
+		row := i + 7 // 数据从第7行开始
+		f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), i+1)
+		f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), combo.Version)
+		f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), combo.ProtocolVer)
+		f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), combo.SysHdVersion)
+		f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), combo.Count)
+		f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), strings.Join(combo.Sites, ", "))
+	}
+
+	// 保存文件
+	fileName := fmt.Sprintf("site_version_combinations_%s.xlsx", time.Now().Format("20060102_150405"))
+	if err := f.SaveAs(fileName); err != nil {
+		return fmt.Errorf("保存 Excel 文件失败: %w", err)
+	}
+
+	fmt.Printf("\n✅ Excel 文件已生成: %s\n", fileName)
+	return nil
+}
+
+// columnName 将列号(1-based)转为Excel列名
+func columnName(col int) string {
+	name, _ := excelize.ColumnNumberToName(col)
+	return name
+}

+ 132 - 12
service/utils/mqttClient/mqttClient.go

@@ -2,9 +2,14 @@ package mqttClient
 
 import (
 	"crypto/tls"
+	"errors"
 	"fmt"
+	"log"
 	"net/url"
+	"os"
+	"reflect"
 	"time"
+	"unsafe"
 
 	mqtt "github.com/eclipse/paho.mqtt.golang"
 	"go.uber.org/zap"
@@ -17,19 +22,27 @@ type MqttClientOpt struct {
 }
 
 type MqttClient struct {
-	client       mqtt.Client
-	subscribeMap map[string]func(payload []byte)
-	clientID     string // 客户端名称
+	client        mqtt.Client
+	clientOptions *mqtt.ClientOptions
+	subscribeMap  map[string]func(payload []byte)
+	clientID      string // 客户端名称
 }
 
 const (
 	connectTimeout      = 30 * time.Second // 30s
+	reconnectTimeout    = 10 * time.Second // 10s
+	disconnectTimeout   = 10 * 1000        // 10s
 	subscribeMsgTimeout = 30 * time.Second // 30s
 	sendMsgTimeout      = 30 * time.Second // 30s
 )
 
 // 创建客户端
 func CreateMqttClient(opt MqttClientOpt) (*MqttClient, error) {
+	// 初始化前设置(放在 InitFwhMqttClient 开头或 init() 中)
+	mqtt.DEBUG = log.New(os.Stdout, "[paho-DEBUG] ", log.Ltime|log.Lmicroseconds)
+	mqtt.WARN = log.New(os.Stdout, "[paho-WARN]  ", log.Ltime|log.Lmicroseconds)
+	mqtt.ERROR = log.New(os.Stderr, "[paho-ERROR] ", log.Ltime|log.Lmicroseconds)
+
 	// 创建客户端选项
 	opts := mqtt.NewClientOptions()
 	opts.AddBroker(opt.Broker)
@@ -45,6 +58,7 @@ func CreateMqttClient(opt MqttClientOpt) (*MqttClient, error) {
 
 	mqttClient := MqttClient{
 		subscribeMap: map[string]func(payload []byte){},
+		clientID:     clientID,
 	}
 
 	// 连接成功回调
@@ -79,19 +93,91 @@ func CreateMqttClient(opt MqttClientOpt) (*MqttClient, error) {
 
 	// 创建客户端
 	mqttClient.client = mqtt.NewClient(opts)
+	mqttClient.clientOptions = opts
 
 	return &mqttClient, nil
 }
 
+func (c *MqttClient) RecreateClient() error {
+	if c.clientOptions == nil {
+		return errors.New("RecreateClient failed, ClientOptions is nil.")
+	}
+
+	// 尝试断开旧连接(非阻塞)
+	oldClient := c.client
+	go func() {
+		if oldClient != nil {
+			done := make(chan struct{})
+			go func() {
+				oldClient.Disconnect(disconnectTimeout)
+				close(done)
+			}()
+			select {
+			case <-done:
+			case <-time.After(15 * time.Second):
+				zap.L().Warn("[MQTT] old client disconnect timeout, abandoning")
+			}
+		}
+	}()
+
+	// 重新创建客户端
+	c.client = mqtt.NewClient(c.clientOptions)
+	zap.L().Info("[MQTT] create new client")
+	return nil
+}
+
 func (c *MqttClient) Connect() error {
-	if c.client.IsConnected() {
+	isConnected := c.client.IsConnected()
+	isOpen := c.client.IsConnectionOpen()
+
+	zap.L().Debug("[MQTT] Connect() status check",
+		zap.Bool("IsConnected", isConnected),
+		zap.Bool("IsConnectionOpen", isOpen),
+	)
+
+	// 底层连接已活跃,无需再连
+	if isOpen {
 		return nil
 	}
 
-	// 连接
+	// IsConnected=true 但连接不活跃 → 正在重连中,等待恢复
+	if isConnected && !isOpen {
+		zap.L().Info("[MQTT] client is reconnecting, waiting for connection to restore")
+		deadline := time.Now().Add(reconnectTimeout)
+		for time.Now().Before(deadline) {
+			if c.client.IsConnectionOpen() {
+				return nil
+			}
+			// 如果 IsConnected 也变成 false 了,说明重连失败/放弃了
+			if !c.client.IsConnected() {
+				zap.L().Warn("[MQTT] client lost connection during reconnect wait, will re-connect")
+				break
+			}
+			time.Sleep(500 * time.Millisecond)
+		}
+
+		// 等待超时或重连失败,检查最终状态
+		if c.client.IsConnectionOpen() {
+			return nil
+		}
+
+		// 强制断开后重连(清除 disconnecting 卡死状态)
+		// zap.L().Warn("[MQTT] reconnect failed, forcing disconnect and re-connect")
+		// c.client.Disconnect(0)
+		// time.Sleep(100 * time.Millisecond)
+	}
+
+	// 真正断开状态,执行连接
 	token := c.client.Connect()
 	if !token.WaitTimeout(connectTimeout) {
-		return fmt.Errorf("connect timeout (%s)", c.clientID)
+		if err := c.RecreateClient(); err != nil {
+			return fmt.Errorf("recreate client failed (%s)", c.clientID)
+		}
+
+		token = c.client.Connect()
+		if !token.WaitTimeout(connectTimeout) {
+			return fmt.Errorf("connect timeout (%s)", c.clientID)
+		}
 	}
 
 	return token.Error()
@@ -99,15 +185,11 @@ func (c *MqttClient) Connect() error {
 
 // 断开连接
 func (c *MqttClient) Disconnect() {
-	if !c.client.IsConnected() {
-		return
-	}
-
 	// 清空订阅
 	c.CleanSubscribe()
 
-	// 安全断开,等待最多 2 秒让队列消息发送完
-	c.client.Disconnect(2000)
+	// 安全断开,等待最多 10 秒让队列消息发送完
+	c.client.Disconnect(disconnectTimeout)
 }
 
 // 检查是否连接
@@ -253,3 +335,41 @@ func (c *MqttClient) CleanMessageRetained(topic string) error {
 
 	return token.Error()
 }
+
+// getClientStatus 通过反射强制获取 paho client 内部的连接状态(仅调试用)
+// 返回值: 0=disconnected, 1=disconnecting, 2=connecting, 3=reconnecting, 4=connected
+func (c *MqttClient) GetClientStatus() string {
+	statusMap := map[uint32]string{
+		0: "disconnected",
+		1: "disconnecting",
+		2: "connecting",
+		3: "reconnecting",
+		4: "connected",
+	}
+
+	// mqtt.Client 是接口,需要先拿到底层 *client 指针
+	rv := reflect.ValueOf(c.client).Elem() // *client -> client struct
+
+	// 获取 status 字段 (类型: connectionStatus)
+	statusField := rv.FieldByName("status")
+	if !statusField.IsValid() {
+		return "unknown(field not found)"
+	}
+
+	// connectionStatus 结构体中第一个字段之后是 status uint32
+	// connectionStatus 内嵌了 sync.RWMutex,RWMutex 占据前面的空间
+	// 实际上 status 是 connectionStatus 结构体中名为 "status" 的字段
+	innerStatus := statusField.FieldByName("status")
+	if !innerStatus.IsValid() {
+		return "unknown(inner status not found)"
+	}
+
+	// 强制读取未导出字段
+	ptr := unsafe.Pointer(innerStatus.UnsafeAddr())
+	val := *(*uint32)(ptr)
+
+	if name, ok := statusMap[val]; ok {
+		return name
+	}
+	return fmt.Sprintf("unknown(%d)", val)
+}