Przeglądaj źródła

temp: 代码临时提交,未编译

Jerry Liang 1 rok temu
rodzic
commit
a0e8da18b4

+ 87 - 41
service/controllers/sitesAnalysisControllers.go

@@ -3,6 +3,8 @@ package controllers
 import (
 	"context"
 	"fwh-toolbox-server/database"
+	taskqueue "fwh-toolbox-server/taskQueue"
+	"fwh-toolbox-server/types"
 	"time"
 
 	"go.mongodb.org/mongo-driver/bson"
@@ -10,42 +12,39 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
-type Progress string // 'pending' | 'running' | 'error' | 'complete'
-
-type SiteAnalysisInfo struct {
-	Id          primitive.ObjectID `bson:"_id" json:"id"`
-	Sn          string             `bson:"sn" json:"sn"`
-	Description string             `bson:"description" json:"description"`
-	Item        []string           `bson:"item" json:"item"`
-	Progress    Progress           `bson:"progress" json:"progress"`
-}
-
-type SiteAnalysisConfig struct {
-	Id                  primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
-	Sn                  string             `bson:"sn" json:"sn"`
-	Description         string             `bson:"description" json:"description"`
-	Item                []string           `bson:"item" json:"item"`
-	Progress            Progress           `bson:"progress" json:"progress"`
-	TimePeriod          [2]string          `bson:"timePeriod" json:"timePeriod"`
-	EnableEventAndAlarm bool               `bson:"enableEventAndAlarm" json:"enableEventAndAlarm"`
-	EnableCloudLog      bool               `bson:"enableCloudLog" json:"enableCloudLog"`
-	EnableSiteLog       bool               `bson:"enableSiteLog" json:"enableSiteLog"`
-}
+type SiteAnalysisInfo = types.SiteAnalysisInfo
+type SiteAnalysisOptions = types.SiteAnalysisOptions
+type SiteAnalysisConfig = types.SiteAnalysisConfig
+type SiteAnalysisConfigWrite = types.SiteAnalysisConfigWrite
 
 // 从数据库获取站点分析列表
-func GetSiteAnalysisManageList() ([]SiteAnalysisInfo, error) {
+func GetSiteAnalysisManageList(page, size uint, sn, progress *string) (*ResPage[SiteAnalysisInfo], error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
 	collection := database.GetCollection("SiteAnalysisConfig")
-	cursor, err := collection.Find(ctx, bson.M{}, options.Find().SetProjection(bson.M{
-		// 配置需要的字段
-		"_id":         1, // (默认会返回)
-		"sn":          1,
-		"description": 1,
-		"item":        1,
-		"progress":    1,
-	})) // 空条件,查全部
+
+	filter := bson.M{}
+	// 条件
+	if sn != nil {
+		filter["sn"] = *sn
+	}
+	if progress != nil {
+		filter["progress"] = *progress
+	}
+
+	findOptions := options.Find().
+		SetProjection(bson.M{
+			"_id":         1,
+			"sn":          1,
+			"description": 1,
+			"item":        1,
+			"progress":    1,
+		}).
+		SetSkip(int64((page - 1) * size)).
+		SetLimit(int64(size))
+
+	cursor, err := collection.Find(ctx, filter, findOptions)
 	if err != nil {
 		return nil, err
 	}
@@ -60,7 +59,20 @@ func GetSiteAnalysisManageList() ([]SiteAnalysisInfo, error) {
 		configList = append(configList, cfg)
 	}
 
-	return configList, nil
+	// 查询总数
+	total, err := collection.CountDocuments(ctx, filter)
+	if err != nil {
+		return nil, err
+	}
+
+	res := ResPage[SiteAnalysisInfo]{
+		Data:     configList,
+		PageSize: size,
+		PageNum:  page,
+		Total:    uint(total),
+	}
+
+	return &res, nil
 }
 
 // 从数据库获取一条站点分析配置
@@ -79,37 +91,60 @@ func GetSiteAnalysisConfig(id primitive.ObjectID) (*SitesMonitorConfig, error) {
 }
 
 // 插入一条站点分析配置到数据库
-func InsertSiteAnalysisConfig(config SiteAnalysisConfig) error {
+func InsertSiteAnalysisConfig(writeConfig SiteAnalysisConfigWrite) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	config := SiteAnalysisConfig{
+		SiteAnalysisConfigWrite: writeConfig,
+		Item:                    []string{},
+		Progress:                "pending",
+	}
 
-	// _, err := collection.InsertOne(ctx, config)
-	filter := bson.M{"_id": config.Id} // 按用户名匹配
-	update := bson.M{
-		"$set": config, // 如果存在就更新所有字段
+	// 生成Item标签
+	if writeConfig.EnableTimeline {
+		config.Item = append(config.Item, "时间线")
 	}
 
-	opts := options.Update().SetUpsert(true)
+	collection := database.GetCollection("SiteMonitorConfig")
 
-	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	res, err := collection.InsertOne(ctx, config)
 	if err != nil {
 		return err
 	}
 
+	taskqueue.EnqueueSiteAnalysisTask(taskqueue.TaskSiteAnalysis{
+		Id:         res.InsertedID.(primitive.ObjectID),
+		Sn:         writeConfig.Sn,
+		TimePeriod: writeConfig.TimePeriod,
+		SiteAnalysisOptions: SiteAnalysisOptions{
+			EnableTimeline: writeConfig.EnableTimeline,
+		},
+	})
+
 	return nil
 }
 
 // 更新一条站点分析配置到数据库
-func UpdateSiteAnalysisConfig(id primitive.ObjectID, config SiteAnalysisConfig) error {
+func UpdateSiteAnalysisConfig(id primitive.ObjectID, writeConfig SiteAnalysisConfigWrite) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
+	config := SiteAnalysisConfig{
+		SiteAnalysisConfigWrite: writeConfig,
+		Item:                    []string{},
+		Progress:                "pending",
+	}
+
+	// 生成Item标签
+	if writeConfig.EnableTimeline {
+		config.Item = append(config.Item, "时间线")
+	}
+
 	collection := database.GetCollection("SiteMonitorConfig")
 
 	// _, err := collection.InsertOne(ctx, config)
-	filter := bson.M{"_id": id} // 按用户名匹配
+	filter := bson.M{"_id": id} // 按ID匹配
 	update := bson.M{
 		"$set": config, // 如果存在就更新所有字段
 	}
@@ -121,6 +156,15 @@ func UpdateSiteAnalysisConfig(id primitive.ObjectID, config SiteAnalysisConfig)
 		return err
 	}
 
+	taskqueue.EnqueueSiteAnalysisTask(taskqueue.TaskSiteAnalysis{
+		Id:         writeConfig.Id,
+		Sn:         writeConfig.Sn,
+		TimePeriod: writeConfig.TimePeriod,
+		SiteAnalysisOptions: SiteAnalysisOptions{
+			EnableTimeline: writeConfig.EnableTimeline,
+		},
+	})
+
 	return nil
 }
 
@@ -136,5 +180,7 @@ func DeleteSiteAnalysisConfig(id primitive.ObjectID) error {
 		return err
 	}
 
+	// TODO 需删除相关数据
+
 	return nil
 }

+ 150 - 0
service/ext/api/fwh_pro/fwh_eventAlarmLog.go

@@ -0,0 +1,150 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+)
+
+// 查询事件事件日志
+func queryEventAndAlarmLog(sn string, params url.Values) ([]byte, error) {
+	baseURL := "https://energy.franklinwh.com/hes-gateway/manage/iotDeviceRunLogs"
+
+	token := getLoginToken()
+	userId, _ := getUserId(token)
+
+	// 构造 query 参数
+	params.Add("userId", strconv.Itoa(userId))
+	params.Add("searchName", sn)
+	params.Add("timeType", strconv.Itoa(1)) // 0-通讯时间;1-设备时间
+	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("land", "ZH_CN")
+
+	// 拼接到 URL
+	finalURL := baseURL + "?" + params.Encode()
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodGet,
+		finalURL,
+		nil)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Logintoken", token)
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
+	return body, nil
+}
+
+// 查询快照信息
+// logTime 格式 YYYY-MM-DD
+func QueryAlarmSnapshotInfo(sn, logTime string, page int) ([]byte, error) {
+	baseURL := "https://energy.franklinwh.com/hes-gateway/manage/selectAlarmSnapshotList"
+
+	token := getLoginToken()
+
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("logTime", logTime)
+	params.Add("linkId", sn)
+	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
+	params.Add("pageNum", strconv.Itoa(page))
+	params.Add("land", "ZH_CN")
+
+	// 拼接到 URL
+	finalURL := baseURL + "?" + params.Encode()
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodGet,
+		finalURL,
+		nil)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Logintoken", token)
+
+	// fmt.Println(req.Header)
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
+	return body, nil
+}
+
+// 查询所有事件告警
+// startTime&endTime 格式 YYYY-MM-DD HH:mm:SS
+func QueryAllEventAlarm(sn, startTime, endTime string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("startTime", startTime)
+	params.Add("endTime", endTime)
+
+	return queryEventAndAlarmLog(sn, params)
+}
+
+// 查询事件日志
+// startTime&endTime 格式 YYYY-MM-DD HH:mm:SS
+func QueryEventLog(sn, startTime, endTime string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("logType", strconv.Itoa(0))
+	params.Add("startTime", startTime)
+	params.Add("endTime", endTime)
+
+	return queryEventAndAlarmLog(sn, params)
+}
+
+// 查询告警日志
+// startTime&endTime 格式 YYYY-MM-DD HH:mm:SS
+// alarmCode - 支持多个code,用‘,’隔开,“”代表查询所有
+func QueryAlarmLog(sn, startTime, endTime, alarmCode string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("logType", strconv.Itoa(1))
+	params.Add("alarmCode", alarmCode)
+	params.Add("startTime", startTime)
+	params.Add("endTime", endTime)
+
+	return queryEventAndAlarmLog(sn, params)
+}
+
+// 查询实时告警
+func QueryRealtimeAlarm(sn string) ([]byte, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("logType", strconv.Itoa(2))
+
+	return queryEventAndAlarmLog(sn, params)
+}

+ 176 - 0
service/ext/api/fwh_pro/fwh_login.go

@@ -0,0 +1,176 @@
+package fwh_pro
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+var loginInfo LoginData = LoginData{
+	UserId: 0,
+	Token:  "",
+}
+
+type LoginPayload struct {
+	LoginName string `json:"loginName"`
+	Password  string `json:"password"`
+	Type      int    `json:"type"`
+	Lang      string `json:"lang"`
+}
+
+type LoginData struct {
+	UserId uint   `json:"userId"`
+	Token  string `json:"token"`
+}
+
+type LoginResponse struct {
+	Code    int       `json:"code"`
+	Msg     string    `json:"msg"`
+	Data    LoginData `json:"data"`
+	Success bool      `json:"success"`
+}
+
+type JwtPayload struct {
+	AdminLevel  int    `json:"adminLevel"`
+	LoginName   string `json:"loginName"`
+	AccountType int    `json:"accountType"`
+	GroupId     int    `json:"groupId"`
+	Iss         string `json:"iss"`
+	UserManage  int    `json:"userManage"`
+	Exp         uint64 `json:"exp"`
+	UserName    string `json:"userName"`
+	Iat         uint64 `json:"iat"`
+	UserId      int    `json:"userId"`
+}
+
+func Login() error {
+	// 要发送的 JSON
+	payload := LoginPayload{
+		LoginName: "FMH001",
+		Password:  "cfd9d803fafcf2c7a832bfba5d039512",
+		Type:      1,
+		Lang:      "ZH_CN",
+	}
+
+	jsonData, _ := json.Marshal(payload)
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodPost,
+		"https://oss.franklinwh.com/api-user/user/login",
+		bytes.NewBuffer(jsonData))
+	if err != nil {
+		fmt.Println(err.Error())
+		return err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/json")
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s", resp.Status, body)
+
+	var loginRes LoginResponse
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(body, &loginRes)
+	if jsonErr != nil {
+		return jsonErr
+	}
+
+	loginInfo.UserId = loginRes.Data.UserId
+	loginInfo.Token = loginRes.Data.Token
+
+	return nil
+}
+
+func isTokenExpired(token string) (bool, error) {
+	parts := strings.Split(token, ".")
+	if len(parts) != 3 {
+		return false, fmt.Errorf("token 格式错误")
+	}
+
+	payloadSegment := parts[1]
+	// JWT 使用 base64 URL encoding
+	payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadSegment)
+	if err != nil {
+		return false, err
+	}
+
+	var payload map[string]interface{}
+	if err := json.Unmarshal(payloadBytes, &payload); err != nil {
+		return false, err
+	}
+
+	expFloat, ok := payload["exp"].(float64)
+	if !ok {
+		return false, fmt.Errorf("token payload 中没有 exp")
+	}
+
+	expTime := time.Unix(int64(expFloat), 0)
+	return time.Now().After(expTime), nil
+}
+
+func getLoginToken() string {
+	// 判断是否已经有token
+	if loginInfo.Token != "" {
+		// 判断token是否过期
+		expired, err := isTokenExpired(loginInfo.Token)
+		if err != nil {
+			fmt.Println("loginInfo.Token 解析出错:", err)
+			return ""
+		}
+
+		if expired {
+			fmt.Println("Token 已过期")
+		} else {
+			// fmt.Println("Token 有效")
+			return loginInfo.Token
+		}
+	}
+
+	// 重新登录
+	Login()
+	return loginInfo.Token
+}
+
+func getUserId(token string) (int, error) {
+	// JWT 由三部分组成: header.payload.signature
+	parts := strings.Split(token, ".")
+	if len(parts) < 2 {
+		return 0, fmt.Errorf("getUserId: invalid token")
+	}
+
+	// 解码 payload 部分
+	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+	if err != nil {
+		return 0, fmt.Errorf("getUserId: %s", err.Error())
+	}
+
+	// 解析 JSON
+	var claims JwtPayload
+	if err := json.Unmarshal(payload, &claims); err != nil {
+		return 0, fmt.Errorf("getUserId: %s", err.Error())
+	}
+
+	return claims.UserId, nil
+}
+
+// 清空token
+func ClearToken() {
+	loginInfo.UserId = 0
+	loginInfo.Token = ""
+}

+ 53 - 0
service/ext/api/fwh_pro/fwh_powerFlow.go

@@ -0,0 +1,53 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+// 查询站点功率流信息
+func QuerySitePowerFlow(sn string, day time.Time) ([]byte, error) {
+	baseURL := "https://energy.franklinwh.com/api-energy/power/getFhpPowerByDayOld"
+
+	token := getLoginToken()
+
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("gatewayId", sn)
+	params.Add("dayTime", day.Format(time.DateOnly)) // 会自动 URL 编码
+
+	// 拼接到 URL
+	finalURL := baseURL + "?" + params.Encode()
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodGet,
+		finalURL,
+		nil)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Logintoken", token)
+
+	// fmt.Println(req.Header)
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
+	return body, nil
+}

+ 0 - 347
service/ext/api/fwh_pro/fwh_pro.go

@@ -1,347 +0,0 @@
-package fwh_pro
-
-import (
-	"bytes"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-	"strconv"
-	"strings"
-	"time"
-)
-
-var loginInfo LoginData = LoginData{
-	UserId: 0,
-	Token:  "",
-}
-
-type LoginPayload struct {
-	LoginName string `json:"loginName"`
-	Password  string `json:"password"`
-	Type      int    `json:"type"`
-	Lang      string `json:"lang"`
-}
-
-type LoginData struct {
-	UserId uint   `json:"userId"`
-	Token  string `json:"token"`
-}
-
-type LoginResponse struct {
-	Code    int       `json:"code"`
-	Msg     string    `json:"msg"`
-	Data    LoginData `json:"data"`
-	Success bool      `json:"success"`
-}
-
-type SiteInfoListPayload struct {
-	Search        *string `json:"search,omitempty"`  // 只能搜索单条SN
-	Serials       *string `json:"serials,omitempty"` // 可搜索多条SN,SN之间以‘,’隔开
-	CountryId     *int    `json:"countryId,omitempty"`
-	ProvinceId    *int    `json:"provinceId,omitempty"`
-	CityId        *int    `json:"cityId,omitempty"`
-	NetworkStatus *int    `json:"networkStatus,omitempty"`
-	DeviceStatus  *int    `json:"deviceStatus,omitempty"`
-	OnOffStatus   *int    `json:"onOffStatus,omitempty"`
-	PageSize      uint    `json:"pageSize"`
-	PageNum       uint    `json:"pageNum"`
-	Lang          string  `json:"lang"`
-}
-
-type SiteModeInfoPayload struct {
-	GatewayId string `json:"gatewayId"`
-	ShowType  int    `json:"showType"`
-	Lang      string `json:"lang"`
-}
-
-func Login() error {
-	// 要发送的 JSON
-	payload := LoginPayload{
-		LoginName: "FMH001",
-		Password:  "cfd9d803fafcf2c7a832bfba5d039512",
-		Type:      1,
-		Lang:      "ZH_CN",
-	}
-
-	jsonData, _ := json.Marshal(payload)
-
-	// 创建请求
-	req, err := http.NewRequest(
-		http.MethodPost,
-		"https://oss.franklinwh.com/api-user/user/login",
-		bytes.NewBuffer(jsonData))
-	if err != nil {
-		fmt.Println(err.Error())
-		return err
-	}
-
-	// 设置 Content-Type 和 Authorization
-	req.Header.Set("Content-Type", "application/json")
-
-	// 发送请求
-	client := &http.Client{Timeout: 5 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		fmt.Println(err.Error())
-		return err
-	}
-	defer resp.Body.Close()
-
-	body, _ := io.ReadAll(resp.Body)
-	// fmt.Printf("Status: %s\nBody: %s", resp.Status, body)
-
-	var loginRes LoginResponse
-	// 反序列化(unmarshal)
-	jsonErr := json.Unmarshal(body, &loginRes)
-	if jsonErr != nil {
-		return jsonErr
-	}
-
-	loginInfo.UserId = loginRes.Data.UserId
-	loginInfo.Token = loginRes.Data.Token
-
-	return nil
-}
-
-func isTokenExpired(token string) (bool, error) {
-	parts := strings.Split(token, ".")
-	if len(parts) != 3 {
-		return false, fmt.Errorf("token 格式错误")
-	}
-
-	payloadSegment := parts[1]
-	// JWT 使用 base64 URL encoding
-	payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadSegment)
-	if err != nil {
-		return false, err
-	}
-
-	var payload map[string]interface{}
-	if err := json.Unmarshal(payloadBytes, &payload); err != nil {
-		return false, err
-	}
-
-	expFloat, ok := payload["exp"].(float64)
-	if !ok {
-		return false, fmt.Errorf("token payload 中没有 exp")
-	}
-
-	expTime := time.Unix(int64(expFloat), 0)
-	return time.Now().After(expTime), nil
-}
-
-func getLoginToken() string {
-	// 判断是否已经有token
-	if loginInfo.Token != "" {
-		// 判断token是否过期
-		expired, err := isTokenExpired(loginInfo.Token)
-		if err != nil {
-			fmt.Println("loginInfo.Token 解析出错:", err)
-			return ""
-		}
-
-		if expired {
-			fmt.Println("Token 已过期")
-		} else {
-			// fmt.Println("Token 有效")
-			return loginInfo.Token
-		}
-	}
-
-	// 重新登录
-	Login()
-	return loginInfo.Token
-}
-
-// 清空token
-func ClearToken() {
-	loginInfo.UserId = 0
-	loginInfo.Token = ""
-}
-
-// 查询站点基本信息
-func QuerySiteInfoList(sn []string, pageSize uint, pageNum uint) ([]byte, error) {
-	snList := strings.Join(sn, ",")
-
-	token := getLoginToken()
-
-	// 要发送的 JSON
-	payload := SiteInfoListPayload{
-		Serials:  &snList,
-		PageSize: pageSize,
-		PageNum:  pageNum,
-		Lang:     "ZH_CN",
-	}
-
-	// 要发送的表单数据
-	form := url.Values{}
-	form.Set("serials", *payload.Serials) // *string 转 string
-	form.Set("pageSize", fmt.Sprintf("%d", payload.PageSize))
-	form.Set("pageNum", fmt.Sprintf("%d", payload.PageNum))
-	form.Set("lang", payload.Lang)
-
-	// 创建请求
-	req, err := http.NewRequest(
-		http.MethodPost,
-		"https://energy.franklinwh.com/hes-gateway/manage/selectIotFirmwareGatewayList",
-		strings.NewReader(form.Encode()))
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-
-	// 设置 Content-Type 和 Authorization
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
-	req.Header.Set("Logintoken", token)
-
-	// 发送请求
-	client := &http.Client{Timeout: 5 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	body, _ := io.ReadAll(resp.Body)
-	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
-	return body, nil
-}
-
-// 查询站点工作模式信息
-func QuerySiteModeInfo(sn string) ([]byte, error) {
-	token := getLoginToken()
-
-	// 要发送的 JSON
-	payload := SiteModeInfoPayload{
-		GatewayId: sn,
-		ShowType:  1,
-		Lang:      "ZH_CN",
-	}
-
-	// 要发送的表单数据
-	form := url.Values{}
-	form.Set("gatewayId", payload.GatewayId)
-	form.Set("showType", strconv.Itoa(payload.ShowType))
-	form.Set("lang", payload.Lang)
-
-	// 创建请求
-	req, err := http.NewRequest(
-		http.MethodPost,
-		"https://energy.franklinwh.com/hes-gateway/manage/tou/getGatewayTouListV2",
-		strings.NewReader(form.Encode()))
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-
-	// 设置 Content-Type 和 Authorization
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
-	req.Header.Set("Logintoken", token)
-
-	// fmt.Println(req.Header)
-
-	// 发送请求
-	client := &http.Client{Timeout: 5 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	body, _ := io.ReadAll(resp.Body)
-	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
-	return body, nil
-}
-
-// 查询站点功率流信息
-func QuerySitePowerFlow(sn string, day time.Time) ([]byte, error) {
-	baseURL := "https://energy.franklinwh.com/api-energy/power/getFhpPowerByDayOld"
-
-	token := getLoginToken()
-
-	// 构造 query 参数
-	params := url.Values{}
-	params.Add("gatewayId", sn)
-	params.Add("dayTime", day.Format(time.DateOnly)) // 会自动 URL 编码
-
-	// 拼接到 URL
-	finalURL := baseURL + "?" + params.Encode()
-
-	// 创建请求
-	req, err := http.NewRequest(
-		http.MethodGet,
-		finalURL,
-		nil)
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-
-	// 设置 Content-Type 和 Authorization
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Logintoken", token)
-
-	// fmt.Println(req.Header)
-
-	// 发送请求
-	client := &http.Client{Timeout: 5 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	body, _ := io.ReadAll(resp.Body)
-	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
-	return body, nil
-}
-
-// 查询站点告警信息
-func QuerySiteAlarmList(sn string) ([]byte, error) {
-	baseURL := "https://energy.franklinwh.com/hes-gateway/manage/getEquipmentAlarm"
-
-	token := getLoginToken()
-
-	// 构造 query 参数
-	params := url.Values{}
-	params.Add("gatewayId", sn)
-	params.Add("land", "ZH_CN")
-
-	// 拼接到 URL
-	finalURL := baseURL + "?" + params.Encode()
-
-	// 创建请求
-	req, err := http.NewRequest(
-		http.MethodGet,
-		finalURL,
-		nil)
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-
-	// 设置 Content-Type 和 Authorization
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Logintoken", token)
-
-	// fmt.Println(req.Header)
-
-	// 发送请求
-	client := &http.Client{Timeout: 5 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		fmt.Println(err.Error())
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	body, _ := io.ReadAll(resp.Body)
-	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
-	return body, nil
-}

+ 3 - 0
service/ext/api/fwh_pro/fwh_public.go

@@ -0,0 +1,3 @@
+package fwh_pro
+
+var REQ_PAGE_SIZE uint64 = 1000

+ 53 - 0
service/ext/api/fwh_pro/fwh_realtimeAalrm.go

@@ -0,0 +1,53 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+// 查询站点告警信息
+func QuerySiteAlarmList(sn string) ([]byte, error) {
+	baseURL := "https://energy.franklinwh.com/hes-gateway/manage/getEquipmentAlarm"
+
+	token := getLoginToken()
+
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("gatewayId", sn)
+	params.Add("land", "ZH_CN")
+
+	// 拼接到 URL
+	finalURL := baseURL + "?" + params.Encode()
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodGet,
+		finalURL,
+		nil)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Logintoken", token)
+
+	// fmt.Println(req.Header)
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
+	return body, nil
+}

+ 127 - 0
service/ext/api/fwh_pro/fwh_siteInfo.go

@@ -0,0 +1,127 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type SiteInfoListPayload struct {
+	Search        *string `json:"search,omitempty"`  // 只能搜索单条SN
+	Serials       *string `json:"serials,omitempty"` // 可搜索多条SN,SN之间以‘,’隔开
+	CountryId     *int    `json:"countryId,omitempty"`
+	ProvinceId    *int    `json:"provinceId,omitempty"`
+	CityId        *int    `json:"cityId,omitempty"`
+	NetworkStatus *int    `json:"networkStatus,omitempty"`
+	DeviceStatus  *int    `json:"deviceStatus,omitempty"`
+	OnOffStatus   *int    `json:"onOffStatus,omitempty"`
+	PageSize      uint    `json:"pageSize"`
+	PageNum       uint    `json:"pageNum"`
+	Lang          string  `json:"lang"`
+}
+
+type SiteModeInfoPayload struct {
+	GatewayId string `json:"gatewayId"`
+	ShowType  int    `json:"showType"`
+	Lang      string `json:"lang"`
+}
+
+// 查询站点基本信息
+func QuerySiteInfoList(sn []string, pageSize uint, pageNum uint) ([]byte, error) {
+	snList := strings.Join(sn, ",")
+
+	token := getLoginToken()
+
+	// 要发送的 JSON
+	payload := SiteInfoListPayload{
+		Serials:  &snList,
+		PageSize: pageSize,
+		PageNum:  pageNum,
+		Lang:     "ZH_CN",
+	}
+
+	// 要发送的表单数据
+	form := url.Values{}
+	form.Set("serials", *payload.Serials) // *string 转 string
+	form.Set("pageSize", fmt.Sprintf("%d", payload.PageSize))
+	form.Set("pageNum", fmt.Sprintf("%d", payload.PageNum))
+	form.Set("lang", payload.Lang)
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodPost,
+		"https://energy.franklinwh.com/hes-gateway/manage/selectIotFirmwareGatewayList",
+		strings.NewReader(form.Encode()))
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
+	req.Header.Set("Logintoken", token)
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
+	return body, nil
+}
+
+// 查询站点工作模式信息
+func QuerySiteModeInfo(sn string) ([]byte, error) {
+	token := getLoginToken()
+
+	// 要发送的 JSON
+	payload := SiteModeInfoPayload{
+		GatewayId: sn,
+		ShowType:  1,
+		Lang:      "ZH_CN",
+	}
+
+	// 要发送的表单数据
+	form := url.Values{}
+	form.Set("gatewayId", payload.GatewayId)
+	form.Set("showType", strconv.Itoa(payload.ShowType))
+	form.Set("lang", payload.Lang)
+
+	// 创建请求
+	req, err := http.NewRequest(
+		http.MethodPost,
+		"https://energy.franklinwh.com/hes-gateway/manage/tou/getGatewayTouListV2",
+		strings.NewReader(form.Encode()))
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+
+	// 设置 Content-Type 和 Authorization
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
+	req.Header.Set("Logintoken", token)
+
+	// fmt.Println(req.Header)
+
+	// 发送请求
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		fmt.Println(err.Error())
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	// fmt.Printf("Status: %s\nBody: %s\n", resp.Status, body)
+	return body, nil
+}

+ 1 - 0
service/go.mod

@@ -22,6 +22,7 @@ require (
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
+	github.com/bwmarrin/snowflake v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/fsnotify/fsnotify v1.8.0 // indirect

+ 2 - 0
service/go.sum

@@ -8,6 +8,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
+github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=

+ 94 - 0
service/jobs/getAlarmSnapshot.go

@@ -0,0 +1,94 @@
+package jobs
+
+import (
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/ext/api/fwh_pro"
+	"fwh-toolbox-server/types"
+	"io"
+	"net/http"
+	"os"
+)
+
+type AlarmSnapshotInfo struct {
+	Id           uint64 `json:"id"`
+	CodeType     int    `json:"codeType"` // 作用未知
+	FileName     string `json:"fileName"`
+	FileUrl      string `json:"fileUrl"`
+	LinkId       string `json:"linkId"`  // site SN
+	LogTime      string `json:"logTime"` // format YYYY-MM-DD
+	SerialNumber string `json:"serialNumber"`
+	StartTime    string `json:"startTime"`    // format YYYY-MM-DD HH:mm:SS
+	EndTime      string `json:"endTime"`      // format YYYY-MM-DD HH:mm:SS
+	Status       int    `json:"status"`       // 1-完成
+	CompleteTime string `json:"completeTime"` // format YYYY-MM-DD HH:mm:SS
+}
+
+type AlarmSnapshotListResponse = types.FwhPageResponse[AlarmSnapshotInfo]
+
+// 解析
+func handleAlarmSnapshot(sn, logTime, page) (uint64, []AlarmSnapshotInfo, error) {
+	// 查询告警快照数据
+	resByte, err := fwh_pro.QueryAlarmSnapshotInfo(sn, logTime, page)
+	if err != nil {
+		return 0, nil, fmt.Errorf("[handleAlarmSnapshot] %s", err.Error())
+	}
+
+	var alarmSnapshotRes AlarmSnapshotListResponse
+	// 反序列化(unmarshal)
+	jsonErr := json.Unmarshal(resByte, &alarmSnapshotRes)
+	if jsonErr != nil {
+		return 0, nil, fmt.Errorf("[handleAlarmSnapshot] %s", jsonErr)
+	}
+
+	return alarmSnapshotRes.Total, alarmSnapshotRes.Result, nil
+}
+
+// 获取告警快照
+// 从FHW后端请求快照文件后,处理并放入数据库
+// logTime 格式 YYYY-MM-DD
+func GetAlarmSnapshot(sn, logTime string) error {
+	// 请求快照信息列表
+	var page int = 1
+	snapshotList := make([]AlarmSnapshotInfo, 0)
+	total, dataList, err := handleAlarmSnapshot(sn, logTime, page)
+	if err != nil {
+		return fmt.Errorf("[GetAlarmSnapshot] %s", err.Error())
+	}
+	snapshotList = append(snapshotList, dataList...)
+
+	for uint64(len(snapshotList)) < total {
+		page = page + 1
+		_, dataList, err = handleAlarmSnapshot(sn, logTime, page)
+		if err != nil {
+			return fmt.Errorf("[GetAlarmSnapshot] %s", err.Error())
+		}
+		snapshotList = append(snapshotList, dataList...)
+	}
+
+	// 下载快照文件
+
+	// 解析文件并写入数据库
+
+	return nil
+}
+
+func downloadAlarmSnapshotFile(url, filepath string) error {
+	// 发送 HTTP GET 请求
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	// 创建本地文件
+	out, err := os.Create(filepath)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	// 将响应内容写入文件
+	_, err = io.Copy(out, resp.Body)
+	return err
+}

+ 19 - 4
service/main.go

@@ -20,9 +20,20 @@ import (
 )
 
 func main() {
-	cfg, _ := config.LoadConfig()
-	logger.Init(cfg.AppEnv)
-	database.Init(cfg)
+	var cfg *config.Config
+	var err error
+	cfg, err = config.LoadConfig()
+	if err != nil {
+		panic(err)
+	}
+	err = logger.Init(cfg.AppEnv)
+	if err != nil {
+		panic(err)
+	}
+	err = database.Init(cfg)
+	if err != nil {
+		panic(err)
+	}
 	cache.Init(cfg.RedisAddr)
 	scheduler.Init()
 
@@ -42,8 +53,12 @@ func main() {
 	go func() {
 		if err := networkEvents.InitEvio(); err != nil {
 			fmt.Println(err.Error())
+			panic(err)
 		}
 	}()
 
-	app.Listen("localhost:" + cfg.AppPort)
+	err = app.Listen("localhost:" + cfg.AppPort)
+	if err != nil {
+		panic(err)
+	}
 }

+ 56 - 0
service/models/siteOriginalData.go

@@ -0,0 +1,56 @@
+package models
+
+import (
+	"fwh-toolbox-server/database"
+	"time"
+
+	"gorm.io/datatypes"
+)
+
+var db = database.PgSQL
+
+type DataTypes string
+
+// DataType
+const (
+	DT_API  DataTypes = "API"
+	DT_MQTT DataTypes = "MQTT"
+)
+
+/*
+ * DataType -> ChildType -> SubChildType
+ *
+ * DataType = "API"
+ * 当数据从API请求获取时,数据类型则为 “API”
+ * API类型数据的ChildType为API路径名
+ *
+ * DataType = "MQTT"
+ * 当数据是从云MQTT日志获取时,数据类型则为 “MQTT”
+ * MQTT类型数据的ChildType为cmdType,如:0、101、201等
+ */
+
+type SiteOriginalData struct {
+	ID           int64          `gorm:"primaryKey"` // 使用雪花ID算法
+	DataType     DataTypes      `gorm:"size:64;not null;uniqueIndex:idx_type_sn_time"`
+	ChildType    string         `gorm:"size:64;not null;uniqueIndex:idx_type_sn_time"`
+	SubChildType string         `gorm:"size:64;not null;default:'';uniqueIndex:idx_type_sn_time"`
+	SN           string         `gorm:"size:64;not null;uniqueIndex:idx_type_sn_time"`
+	DataTime     time.Time      `gorm:"type:timestamp;not null;default:CURRENT_TIMESTAMP;uniqueIndex:idx_type_sn_time"` // TIMESTAMP WITHOUT TIME ZONE
+	JsonData     datatypes.JSON `gorm:"type:jsonb"`                                                                     // PostgreSQL 使用 jsonb
+}
+
+// 指定 schema
+func (SiteOriginalData) TableName() string {
+	return "original.site_original_data"
+}
+
+func InitSiteOriginalDataTable() {
+	err := db.AutoMigrate(&SiteOriginalData{})
+	if err != nil {
+		panic(err)
+	}
+}
+
+func InsertSiteOriginalApiData(type string, ) {
+
+}

+ 27 - 3
service/routes/sitesAnalysisRoutes.go

@@ -9,6 +9,7 @@ package routes
 
 import (
 	"fwh-toolbox-server/controllers"
+	"strconv"
 
 	"github.com/gofiber/fiber/v2"
 	"go.mongodb.org/mongo-driver/bson/primitive"
@@ -17,7 +18,30 @@ import (
 func SitesAnalysisSetup(api fiber.Router) {
 	// 获取站点分析管理名称表
 	api.Get("/sitesAnalysis/manage/analysisList", func(c *fiber.Ctx) error {
-		listPage, err := controllers.GetSiteAnalysisManageList()
+		pageInt, _ := strconv.Atoi(c.Query("pageNum", "1"))
+		sizeInt, _ := strconv.Atoi(c.Query("pageSize", "20"))
+
+		if pageInt <= 0 || sizeInt <= 0 {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"code":    400,
+				"message": "Invalid request para",
+			})
+		}
+
+		sn := c.Query("sn", "")
+		progress := c.Query("progress", "")
+
+		var snP *string = nil
+		if sn != "" {
+			snP = &sn
+		}
+
+		var progressP *string = nil
+		if progress != "" {
+			progressP = &progress
+		}
+
+		listPage, err := controllers.GetSiteAnalysisManageList(uint(pageInt), uint(sizeInt), snP, progressP)
 		if err != nil {
 			return JSONResponseNoData(c, 500, "Database read failed. (MongoDb)")
 		}
@@ -26,7 +50,7 @@ func SitesAnalysisSetup(api fiber.Router) {
 
 	// 创建一个分析配置
 	api.Post("/sitesAnalysis/manage/config", func(c *fiber.Ctx) error {
-		var body controllers.SiteAnalysisConfig
+		var body controllers.SiteAnalysisConfigWrite
 		if err := c.BodyParser(&body); err != nil {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
 				"code":    400,
@@ -59,7 +83,7 @@ func SitesAnalysisSetup(api fiber.Router) {
 	// 更新一个分析配置
 	api.Post("/sitesAnalysis/manage/config/:id", func(c *fiber.Ctx) error {
 		id := c.Params("id")
-		var body controllers.SiteAnalysisConfig
+		var body controllers.SiteAnalysisConfigWrite
 		if err := c.BodyParser(&body); err != nil {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
 				"code":    400,

+ 107 - 0
service/taskQueue/taskQueue.go

@@ -0,0 +1,107 @@
+package taskqueue
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"log"
+	"time"
+
+	"github.com/go-redis/redis/v8"
+)
+
+var (
+	ctx        = context.Background()
+	rdb        = cache.C
+	streamName = "fwhtoolbox_stream"
+	groupName  = "task_queue"
+	consumerID = "worker-taskQueue"
+)
+
+type TaskPayloadBase struct {
+	Type string `json:"type"`
+}
+
+type TaskPayload[T any] struct {
+	TaskPayloadBase
+	Payload T `json:"payload"`
+}
+
+func InitTaskQueue() {
+	// 确保消费组存在(忽略已存在错误)
+	_ = rdb.XGroupCreateMkStream(ctx, streamName, groupName, "$").Err()
+
+	// 启动后台任务队列(不会阻塞主线程)
+	go startTaskQueueWorker()
+}
+
+// 任务消费者(单 worker,顺序执行)
+func startTaskQueueWorker() {
+	for {
+		res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+			Group:    groupName,
+			Consumer: consumerID,
+			Streams:  []string{streamName, ">"},
+			Count:    1,
+			Block:    5 * time.Second, // 等待任务
+		}).Result()
+
+		if err != nil {
+			if err == redis.Nil {
+				continue
+			}
+			log.Println("XReadGroup error:", err)
+			time.Sleep(time.Second)
+			continue
+		}
+
+		for _, stream := range res {
+			for _, message := range stream.Messages {
+				id := message.ID
+				rawPayload := message.Values["payload"].(string)
+
+				// 先反序列化成 map 确定 type
+				var base TaskPayloadBase
+				if err := json.Unmarshal([]byte(rawPayload), &base); err != nil {
+					log.Println("unmarshal base error:", err)
+					continue
+				}
+
+				switch base.Type {
+				case TaskTypeSiteAnalysis:
+					var payload TaskPayload[TaskSiteAnalysis]
+					if err := json.Unmarshal([]byte(rawPayload), &payload); err != nil {
+						log.Println("unmarshal error:", err)
+						continue
+					}
+					fmt.Printf("Processing task %s: %+v\n", id, payload)
+					handleTaskSiteAnalysis(payload.Payload)
+				default:
+					log.Println("Unknown task type:", base.Type)
+				}
+
+				// 确认任务完成
+				if err := rdb.XAck(ctx, streamName, groupName, id).Err(); err != nil {
+					log.Println("XAck error:", err)
+				}
+			}
+		}
+	}
+}
+
+func EnqueueTask[T any](payload TaskPayload[T]) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+
+	if _, err := rdb.XAdd(ctx, &redis.XAddArgs{
+		Stream: streamName,
+		Values: map[string]interface{}{"payload": string(data)},
+	}).Result(); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 35 - 0
service/taskQueue/taskSiteAnalysis.go

@@ -0,0 +1,35 @@
+package taskqueue
+
+import (
+	"fmt"
+	"fwh-toolbox-server/types"
+	"time"
+)
+
+var (
+	TaskTypeSiteAnalysis = "task_SiteAnalysis"
+)
+
+type TaskSiteAnalysis = types.TaskSiteAnalysis
+
+func handleTaskSiteAnalysis(payload TaskSiteAnalysis) {
+	// TODO 执行任务(严格顺序)
+	fmt.Printf("Processing task %s: %v\n", id, payload)
+	time.Sleep(3 * time.Second) // 模拟耗时
+
+	
+}
+
+func EnqueueSiteAnalysisTask(payload TaskSiteAnalysis) error {
+	p := TaskPayload[TaskSiteAnalysis]{
+		TaskPayloadBase: TaskPayloadBase{
+			Type: TaskTypeSiteAnalysis,
+		},
+		Payload: payload,
+	}
+	if err := EnqueueTask[TaskSiteAnalysis](p); err != nil {
+		fmt.Printf("EnqueueSiteAnalysisTask - %s : %s\n", payload.Id, err.Error())
+		return err
+	}
+	return nil
+}

+ 9 - 0
service/types/fwhPublic.go

@@ -0,0 +1,9 @@
+package types
+
+type FwhPageResponse[T any] struct {
+	Code    int    `json:"code"`
+	Message string `json:"message"`
+	Result  []T    `json:"result"`
+	Success bool   `json:"success"`
+	Total   uint64 `json:"total"`
+}

+ 38 - 0
service/types/siteAnalysis.go

@@ -0,0 +1,38 @@
+package types
+
+import "go.mongodb.org/mongo-driver/bson/primitive"
+
+type Progress string // 'pending' | 'running' | 'error' | 'complete'
+
+type SiteAnalysisInfo struct {
+	Id          primitive.ObjectID `bson:"_id" json:"id"`
+	Sn          string             `bson:"sn" json:"sn"`
+	Description string             `bson:"description" json:"description"`
+	Item        []string           `bson:"item" json:"item"`
+	Progress    Progress           `bson:"progress" json:"progress"`
+}
+
+type SiteAnalysisOptions struct {
+	EnableTimeline bool `bson:"enableTimeline" json:"enableTimeline"`
+}
+
+type SiteAnalysisConfigWrite struct {
+	Id          primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
+	Sn          string             `bson:"sn" json:"sn"`
+	Description string             `bson:"description" json:"description"`
+	TimePeriod  [2]string          `bson:"timePeriod" json:"timePeriod"`
+	SiteAnalysisOptions
+}
+
+type SiteAnalysisConfig struct {
+	SiteAnalysisConfigWrite
+	Item     []string `bson:"item" json:"item"`
+	Progress Progress `bson:"progress" json:"progress"`
+}
+
+type TaskSiteAnalysis struct {
+	Id         primitive.ObjectID `bson:"_id" json:"id"`
+	Sn         string             `bson:"sn" json:"sn"`
+	TimePeriod [2]string          `bson:"timePeriod" json:"timePeriod"`
+	SiteAnalysisOptions
+}

+ 414 - 0
service/utils/files/compress.go

@@ -0,0 +1,414 @@
+package files
+
+import (
+	"archive/tar"
+	"archive/zip"
+	"bufio"
+	"bytes"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+)
+
+// ========== ZIP ==========
+
+// 压缩文件或文件夹为 zip
+func Zip(srcPath, destZip string) error {
+	zipFile, err := os.Create(destZip)
+	if err != nil {
+		return err
+	}
+	defer zipFile.Close()
+
+	zw := zip.NewWriter(zipFile)
+	defer zw.Close()
+
+	info, err := os.Stat(srcPath)
+	if err != nil {
+		return err
+	}
+
+	if info.IsDir() {
+		// 压缩目录
+		return filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+
+			relPath, err := filepath.Rel(srcPath, path)
+			if err != nil {
+				return err
+			}
+			if relPath == "." {
+				return nil
+			}
+
+			if info.IsDir() {
+				_, err := zw.Create(relPath + "/")
+				return err
+			}
+
+			// 添加文件
+			w, err := zw.Create(relPath)
+			if err != nil {
+				return err
+			}
+			f, err := os.Open(path)
+			if err != nil {
+				return err
+			}
+			defer f.Close()
+			_, err = io.Copy(w, f)
+			return err
+		})
+	} else {
+		// 压缩单文件
+		w, err := zw.Create(info.Name())
+		if err != nil {
+			return err
+		}
+		f, err := os.Open(srcPath)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+
+		_, err = io.Copy(w, f)
+		return err
+	}
+}
+
+// 解压 zip
+func Unzip(src, dest string) error {
+	r, err := zip.OpenReader(src)
+	if err != nil {
+		return err
+	}
+	defer r.Close()
+
+	for _, f := range r.File {
+		// 拼接目标路径
+		fpath := filepath.Join(dest, f.Name)
+
+		// 目录则创建
+		if f.FileInfo().IsDir() {
+			if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
+				return err
+			}
+			continue
+		}
+
+		// 创建父目录
+		if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
+			return err
+		}
+
+		// 解压文件
+		rc, err := f.Open()
+		if err != nil {
+			return err
+		}
+		defer rc.Close()
+
+		out, err := os.Create(fpath)
+		if err != nil {
+			return err
+		}
+		defer out.Close()
+
+		if _, err := io.Copy(out, rc); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ========== TAR ==========
+
+// 打包文件或目录为 tar
+func Tar(srcPath, destTar string) error {
+	tarFile, err := os.Create(destTar)
+	if err != nil {
+		return err
+	}
+	defer tarFile.Close()
+
+	tw := tar.NewWriter(tarFile)
+	defer tw.Close()
+
+	info, err := os.Stat(srcPath)
+	if err != nil {
+		return err
+	}
+
+	if info.IsDir() {
+		// 打包目录
+		return filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+			relPath, err := filepath.Rel(srcPath, path)
+			if err != nil {
+				return err
+			}
+			if relPath == "." {
+				return nil
+			}
+
+			header, err := tar.FileInfoHeader(info, "")
+			if err != nil {
+				return err
+			}
+			header.Name = relPath
+
+			if err := tw.WriteHeader(header); err != nil {
+				return err
+			}
+
+			if !info.Mode().IsRegular() {
+				return nil
+			}
+
+			f, err := os.Open(path)
+			if err != nil {
+				return err
+			}
+			defer f.Close()
+
+			_, err = io.Copy(tw, f)
+			return err
+		})
+	} else {
+		// 打包单文件
+		header, err := tar.FileInfoHeader(info, "")
+		if err != nil {
+			return err
+		}
+		header.Name = info.Name()
+
+		if err := tw.WriteHeader(header); err != nil {
+			return err
+		}
+
+		if !info.Mode().IsRegular() {
+			return nil
+		}
+
+		f, err := os.Open(srcPath)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+
+		_, err = io.Copy(tw, f)
+		return err
+	}
+}
+
+// 解包 tar
+func Untar(srcTar, destDir string) error {
+	f, err := os.Open(srcTar)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	tr := tar.NewReader(f)
+	for {
+		header, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+
+		fpath := filepath.Join(destDir, header.Name)
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
+				return err
+			}
+		case tar.TypeReg:
+			if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
+				return err
+			}
+			out, err := os.Create(fpath)
+			if err != nil {
+				return err
+			}
+			defer out.Close()
+			if _, err := io.Copy(out, tr); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// ========== GZIP ==========
+
+// 压缩单文件为 .gz
+func Gzip(srcFile, destGz string) error {
+	in, err := os.Open(srcFile)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := os.Create(destGz)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	gw := gzip.NewWriter(out)
+	defer gw.Close()
+
+	_, err = io.Copy(gw, in)
+	return err
+}
+
+// 解压 .gz 为文件
+func Gunzip(srcGz, destFile string) error {
+	in, err := os.Open(srcGz)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	gr, err := gzip.NewReader(in)
+	if err != nil {
+		return err
+	}
+	defer gr.Close()
+
+	out, err := os.Create(destFile)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	_, err = io.Copy(out, gr)
+	return err
+}
+
+// ========== TGZ ==========
+
+// 压缩文件或文件夹为 tgz
+func TarGz(srcPath, destTgz string) error {
+	tempFile, err := os.CreateTemp("", "fwhtb-temp-*.tar")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(tempFile.Name()) // 程序结束时删除
+
+	// 打包目录为tar文件
+	if err = Tar(srcPath, tempFile.Name()); err != nil {
+		return err
+	}
+	// 压缩单tar文件为.tgz或.tar.gz
+	if err = Gzip(tempFile.Name(), destTgz); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 解压 tgz
+func UntarGz(srcTgz, destDir string) error {
+	tempFile, err := os.CreateTemp("", "fwhtb-temp-*.tar")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(tempFile.Name()) // 程序结束时删除
+
+	// 解压.tgz或.tar.gz为tar文件
+	if err = Gunzip(srcTgz, tempFile.Name()); err != nil {
+		return err
+	}
+	// 解包tar文件
+	if err = Untar(tempFile.Name(), destDir); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// ========== 自动解包 ==========
+
+// 自动解包
+func AutoExtract(srcFile, destDir string) error {
+	ext := filepath.Ext(srcFile)
+	name := filepath.Base(srcFile)
+
+	switch {
+	// .tar.gz / .tgz
+	case ext == ".gz" && (filepath.Ext(name[:len(name)-len(ext)]) == ".tar"):
+		fmt.Println("识别为 tar.gz")
+		return UntarGz(srcFile, destDir)
+	case ext == ".tgz":
+		fmt.Println("识别为 .tgz")
+		return UntarGz(srcFile, destDir)
+
+	// .zip
+	case ext == ".zip":
+		fmt.Println("识别为 zip")
+		return Unzip(srcFile, destDir)
+
+	// .tar
+	case ext == ".tar":
+		fmt.Println("识别为 tar")
+		return Untar(srcFile, destDir)
+
+	// .gz (单文件压缩)
+	case ext == ".gz":
+		fmt.Println("识别为 gzip")
+		destFile := filepath.Join(destDir, name[:len(name)-3]) // 去掉.gz
+		return Gunzip(srcFile, destFile)
+
+	default:
+		// 扩展名不明,尝试魔数检测
+		fmt.Println("未知扩展名,尝试魔数检测")
+		return detectByMagic(srcFile, destDir)
+	}
+}
+
+// 魔数检测
+func detectByMagic(srcFile, destDir string) error {
+	f, err := os.Open(srcFile)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	reader := bufio.NewReader(f)
+	head := make([]byte, 6)
+	_, err = reader.Read(head)
+	if err != nil {
+		return err
+	}
+
+	switch {
+	case bytes.HasPrefix(head, []byte{0x50, 0x4B, 0x03, 0x04}): // ZIP
+		return Unzip(srcFile, destDir)
+	case bytes.HasPrefix(head, []byte{0x1F, 0x8B}): // GZIP
+		// 可能是 .gz 或 .tar.gz,尝试当 gzip 解开再看
+		tempFile := srcFile + ".tmp"
+		if err := Gunzip(srcFile, tempFile); err != nil {
+			return err
+		}
+		// 检查是不是 tar
+		tf, _ := os.Open(tempFile)
+		defer tf.Close()
+		buf := make([]byte, 262)
+		tf.Read(buf)
+		if bytes.Contains(buf[257:262], []byte("ustar")) {
+			return UntarGz(srcFile, destDir) // 是 tar.gz
+		}
+		return Gunzip(srcFile, filepath.Join(destDir, filepath.Base(srcFile[:len(srcFile)-3])))
+	}
+	return fmt.Errorf("无法识别文件类型: %s", srcFile)
+}

+ 30 - 0
service/utils/files/download.go

@@ -0,0 +1,30 @@
+package files
+
+import (
+	"io"
+	"net/http"
+	"os"
+)
+
+// 下载文件
+// url - "https://example.com/file.zip"
+// filepath - "file.zip"
+func DownloadFile(url, filepath string) error {
+	// 发送 HTTP GET 请求
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	// 创建本地文件
+	out, err := os.Create(filepath)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	// 将响应内容写入文件
+	_, err = io.Copy(out, resp.Body)
+	return err
+}

+ 21 - 0
service/utils/snowflakeID/snowflakeID.go

@@ -0,0 +1,21 @@
+package snowflakeid
+
+import (
+	"fmt"
+
+	"github.com/bwmarrin/snowflake"
+)
+
+var node *snowflake.Node
+
+func NewSnowflakeID() int64 {
+	if node == nil {
+		if n, err := snowflake.NewNode(0); err != nil {
+			fmt.Printf("NewSnowflakeID failed. err: %s\n", err.Error())
+			return 0
+		} else {
+			node = n
+		}
+	}
+	return node.Generate().Int64()
+}

+ 1 - 1
service/utils/xlog_encrypt.go → service/utils/xlog/xlog_encrypt.go

@@ -1,4 +1,4 @@
-package utils
+package xlog
 
 import (
 	"crypto/aes"

+ 1 - 1
service/utils/xlog_package.go → service/utils/xlog/xlog_package.go

@@ -1,4 +1,4 @@
-package utils
+package xlog
 
 import (
 	"bytes"

+ 1 - 1
service/utils/xlog_types.go → service/utils/xlog/xlog_types.go

@@ -1,4 +1,4 @@
-package utils
+package xlog
 
 import (
 	"sync/atomic"

+ 63 - 0
src/views/sites-analysis/component/siteTimeline.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="flex flex-col gap-y-2 p-4">
+    <NCard content-class="flex gap-4">
+
+    </NCard>
+    <NCard>
+      <NTimeline>
+        <component v-for="item in timeLine" :is="NTimelineItem"
+          :key="item.time + ' - ' + item.type + ' - ' + item.title" v-bind="item.props" v-slots="item.slots" />
+      </NTimeline>
+    </NCard>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { NCard, NTimeline, NTimelineItem } from 'naive-ui'
+import type { TimelineItemProps, TimelineItemSlots } from 'naive-ui'
+
+interface TimelineItemConfig {
+  type: 'info' | 'alarm' | 'error'
+  time: string // format YYYY-MM-DD HH:mm
+  title: string
+  content: string
+  props?: TimelineItemProps
+  slots?: TimelineItemSlots
+}
+
+// type 的优先级映射
+const typeOrder: Record<TimelineItemConfig['type'], number> = {
+  info: 1,
+  alarm: 2,
+  error: 3
+}
+
+// TimeLine排序
+const sortTimeLine = (list: TimelineItemConfig[]): TimelineItemConfig[] => {
+  return [...list].sort((a, b) => {
+    // 先比较时间
+    if (a.time < b.time) return -1
+    if (a.time > b.time) return 1
+
+    // 时间相同则比较 type 优先级
+    return typeOrder[a.type] - typeOrder[b.type]
+  })
+}
+
+const timeLine = ref<TimelineItemConfig[]>([])
+
+onMounted(async () => {
+  // 获取Timeline数据
+  const data: TimeLineItemConfig[] = [
+    {
+      type: 'info',
+      time: '2025-09-19 11:02',
+      title: 'test',
+      content: 'test',
+      component: NTimelineItem
+    }
+  ]
+})
+
+</script>