package controllers import ( "errors" "hr_receiver/config" "hr_receiver/models" "net/http" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type StatisticsController struct { DB *gorm.DB } func NewStatisticsController() *StatisticsController { return &StatisticsController{DB: config.DB} } // --- 请求参数 --- type analysisRecordListParams struct { PageNum int `form:"pageNum,default=1"` PageSize int `form:"pageSize,default=10"` RegionID uint32 `form:"regionId"` StartTime int64 `form:"startTime"` EndTime int64 `form:"endTime"` } type mqttMeasurementHistoryParams struct { PageNum int `form:"pageNum,default=1"` PageSize int `form:"pageSize,default=50"` DataType string `form:"dataType"` RegionID uint32 `form:"regionId"` Addr string `form:"addr"` ValueOperator string `form:"valueOperator"` Value *int64 `form:"value"` StartTime int64 `form:"startTime"` EndTime int64 `form:"endTime"` } type mqttMeasurementHistoryItem struct { ID uint `json:"id"` DataType string `json:"dataType"` Identifier string `json:"identifier"` Topic string `json:"topic"` RegionID uint32 `json:"regionId"` Addr string `json:"addr"` Value int64 `json:"value"` ValueLabel string `json:"valueLabel"` PacketNum uint32 `json:"packetNum"` GatewayMAC string `json:"gatewayMac"` Battery uint32 `json:"battery"` IsActive bool `json:"isActive"` IsOnSkin bool `json:"isOnSkin"` SignalRSSINeg float64 `json:"signalRssiNeg"` SNR float64 `json:"snr"` ReceivedAt int64 `json:"receivedAt"` CreatedAtMilli int64 `json:"createdAt"` } type mqttMeasurementHistorySummary struct { AvgValue float64 `json:"avgValue"` MaxValue int64 `json:"maxValue"` MinValue int64 `json:"minValue"` RecordCount int64 `json:"recordCount"` UniqueAddrs int64 `json:"uniqueAddrs"` ValueLabel string `json:"valueLabel"` } // --- 查询接口 --- // @Summary 获取AI分析记录列表 // @Description 分页查询AI分析记录,支持按区域和时间范围筛选 // @Tags 统计管理 // @Produce json // @Param pageNum query int false "页码(默认1)" // @Param pageSize query int false "每页数量(默认10,最大100)" // @Param regionId query int false "区域ID" // @Param startTime query int false "开始时间(毫秒时间戳)" // @Param endTime query int false "结束时间(毫秒时间戳)" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "查询成功" // @Router /admin/statistics/ai-analysis-records [get] func (sc *StatisticsController) ListAIAnalysisRecords(c *gin.Context) { var params analysisRecordListParams if err := c.ShouldBindQuery(¶ms); err != nil { writeError(c, http.StatusBadRequest, err.Error()) return } if params.PageNum < 1 { params.PageNum = 1 } if params.PageSize < 1 || params.PageSize > 100 { params.PageSize = 10 } offset := (params.PageNum - 1) * params.PageSize query := sc.DB.Model(&models.AIAnalysisRecord{}) if params.RegionID > 0 { query = query.Where("region_id = ?", params.RegionID) } if params.StartTime > 0 { query = query.Where("upload_time >= ?", params.StartTime) } if params.EndTime > 0 { query = query.Where("upload_time <= ?", params.EndTime) } var total int64 if err := query.Count(&total).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to count records") return } var records []models.AIAnalysisRecord if err := query.Order("upload_time DESC").Offset(offset).Limit(params.PageSize).Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query records") return } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": records, "pagination": gin.H{ "currentPage": params.PageNum, "pageSize": params.PageSize, "totalList": total, "totalPage": int((total + int64(params.PageSize) - 1) / int64(params.PageSize)), }, }) } // @Summary 获取MQTT测量历史数据 // @Description 分页查询MQTT测量历史数据,支持按数据类型、区域、地址、数值比较和时间范围筛选 // @Tags 统计管理 // @Produce json // @Param pageNum query int false "页码(默认1)" // @Param pageSize query int false "每页数量(默认50,最大200)" // @Param dataType query string false "数据类型 heart_rate|step_count,默认 heart_rate" // @Param regionId query int false "区域ID" // @Param addr query string false "地址筛选,模糊匹配 beltAddr" // @Param valueOperator query string false "数值比较符 gt|gte|lt|lte|eq" // @Param value query int false "数值比较值" // @Param startTime query int false "开始时间(毫秒时间戳)" // @Param endTime query int false "结束时间(毫秒时间戳)" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "查询成功" // @Router /admin/statistics/mqtt-measurements [get] func (sc *StatisticsController) ListMqttMeasurementHistory(c *gin.Context) { var params mqttMeasurementHistoryParams if err := c.ShouldBindQuery(¶ms); err != nil { writeError(c, http.StatusBadRequest, err.Error()) return } if params.PageNum < 1 { params.PageNum = 1 } if params.PageSize < 1 || params.PageSize > 200 { params.PageSize = 50 } if strings.TrimSpace(params.DataType) == "" { params.DataType = "heart_rate" } switch params.DataType { case "heart_rate": sc.listHeartRateMeasurementHistory(c, params) case "step_count": sc.listStepCountMeasurementHistory(c, params) default: writeError(c, http.StatusBadRequest, "unsupported dataType, only heart_rate or step_count is allowed") } } // @Summary 获取MQTT测量图表数据 // @Description 查询MQTT测量图表数据,返回同筛选条件下的完整历史序列,不参与分页 // @Tags 统计管理 // @Produce json // @Param dataType query string false "数据类型 heart_rate|step_count,默认 heart_rate" // @Param regionId query int false "区域ID" // @Param addr query string false "地址筛选,模糊匹配 beltAddr" // @Param valueOperator query string false "数值比较符 gt|gte|lt|lte|eq" // @Param value query int false "数值比较值" // @Param startTime query int false "开始时间(毫秒时间戳)" // @Param endTime query int false "结束时间(毫秒时间戳)" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "查询成功" // @Router /admin/statistics/mqtt-measurements/chart [get] func (sc *StatisticsController) MqttMeasurementChartData(c *gin.Context) { var params mqttMeasurementHistoryParams if err := c.ShouldBindQuery(¶ms); err != nil { writeError(c, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(params.DataType) == "" { params.DataType = "heart_rate" } switch params.DataType { case "heart_rate": sc.chartHeartRateMeasurementHistory(c, params) case "step_count": sc.chartStepCountMeasurementHistory(c, params) default: writeError(c, http.StatusBadRequest, "unsupported dataType, only heart_rate or step_count is allowed") } } func (sc *StatisticsController) listHeartRateMeasurementHistory(c *gin.Context, params mqttMeasurementHistoryParams) { baseQuery := sc.DB.Model(&models.MqttHeartRateRecord{}) baseQuery = applyMqttMeasurementFilters(baseQuery, params, "belt_addr", "heart_rate", "received_at") var total int64 if err := baseQuery.Session(&gorm.Session{}).Count(&total).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to count mqtt heart rate records") return } offset := (params.PageNum - 1) * params.PageSize var records []models.MqttHeartRateRecord if err := baseQuery.Session(&gorm.Session{}). Order("received_at DESC"). Offset(offset). Limit(params.PageSize). Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt heart rate records") return } var summary mqttMeasurementHistorySummary summary.ValueLabel = "心率" if err := buildMqttMeasurementSummary(baseQuery.Session(&gorm.Session{}), "heart_rate", "belt_addr", &summary); err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt heart rate summary") return } items := make([]mqttMeasurementHistoryItem, 0, len(records)) for _, record := range records { items = append(items, mqttMeasurementHistoryItem{ ID: record.ID, DataType: "heart_rate", Identifier: record.Identifier, Topic: record.Topic, RegionID: record.RegionID, Addr: record.BeltAddr, Value: int64(record.HeartRate), ValueLabel: "心率", PacketNum: record.PacketNum, GatewayMAC: record.GatewayMAC, Battery: record.Battery, IsActive: record.IsActive, IsOnSkin: record.IsOnSkin, SignalRSSINeg: record.SignalRSSINeg, SNR: record.SNR, ReceivedAt: record.ReceivedAt, CreatedAtMilli: record.CreatedAt.UnixMilli(), }) } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": items, "pagination": gin.H{ "currentPage": params.PageNum, "pageSize": params.PageSize, "totalList": total, "totalPage": int((total + int64(params.PageSize) - 1) / int64(params.PageSize)), }, "summary": summary, }) } func (sc *StatisticsController) chartHeartRateMeasurementHistory(c *gin.Context, params mqttMeasurementHistoryParams) { baseQuery := sc.DB.Model(&models.MqttHeartRateRecord{}) baseQuery = applyMqttMeasurementFilters(baseQuery, params, "belt_addr", "heart_rate", "received_at") var records []models.MqttHeartRateRecord if err := baseQuery.Session(&gorm.Session{}). Order("received_at ASC"). Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt heart rate chart records") return } var summary mqttMeasurementHistorySummary summary.ValueLabel = "心率" if err := buildMqttMeasurementSummary(baseQuery.Session(&gorm.Session{}), "heart_rate", "belt_addr", &summary); err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt heart rate summary") return } items := make([]mqttMeasurementHistoryItem, 0, len(records)) for _, record := range records { items = append(items, mqttMeasurementHistoryItem{ ID: record.ID, DataType: "heart_rate", Identifier: record.Identifier, Topic: record.Topic, RegionID: record.RegionID, Addr: record.BeltAddr, Value: int64(record.HeartRate), ValueLabel: "心率", PacketNum: record.PacketNum, GatewayMAC: record.GatewayMAC, Battery: record.Battery, IsActive: record.IsActive, IsOnSkin: record.IsOnSkin, SignalRSSINeg: record.SignalRSSINeg, SNR: record.SNR, ReceivedAt: record.ReceivedAt, CreatedAtMilli: record.CreatedAt.UnixMilli(), }) } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": items, "summary": summary, }) } func (sc *StatisticsController) listStepCountMeasurementHistory(c *gin.Context, params mqttMeasurementHistoryParams) { baseQuery := sc.DB.Model(&models.MqttStepCountRecord{}) baseQuery = applyMqttMeasurementFilters(baseQuery, params, "belt_addr", "step_count", "received_at") var total int64 if err := baseQuery.Session(&gorm.Session{}).Count(&total).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to count mqtt step count records") return } offset := (params.PageNum - 1) * params.PageSize var records []models.MqttStepCountRecord if err := baseQuery.Session(&gorm.Session{}). Order("received_at DESC"). Offset(offset). Limit(params.PageSize). Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt step count records") return } var summary mqttMeasurementHistorySummary summary.ValueLabel = "步数" if err := buildMqttMeasurementSummary(baseQuery.Session(&gorm.Session{}), "step_count", "belt_addr", &summary); err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt step count summary") return } items := make([]mqttMeasurementHistoryItem, 0, len(records)) for _, record := range records { items = append(items, mqttMeasurementHistoryItem{ ID: record.ID, DataType: "step_count", Identifier: record.Identifier, Topic: record.Topic, RegionID: record.RegionID, Addr: record.BeltAddr, Value: int64(record.StepCount), ValueLabel: "步数", PacketNum: record.PacketNum, GatewayMAC: record.GatewayMAC, Battery: 0, IsActive: false, IsOnSkin: false, SignalRSSINeg: record.SignalRSSINeg, SNR: record.SNR, ReceivedAt: record.ReceivedAt, CreatedAtMilli: record.CreatedAt.UnixMilli(), }) } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": items, "pagination": gin.H{ "currentPage": params.PageNum, "pageSize": params.PageSize, "totalList": total, "totalPage": int((total + int64(params.PageSize) - 1) / int64(params.PageSize)), }, "summary": summary, }) } func (sc *StatisticsController) chartStepCountMeasurementHistory(c *gin.Context, params mqttMeasurementHistoryParams) { baseQuery := sc.DB.Model(&models.MqttStepCountRecord{}) baseQuery = applyMqttMeasurementFilters(baseQuery, params, "belt_addr", "step_count", "received_at") var records []models.MqttStepCountRecord if err := baseQuery.Session(&gorm.Session{}). Order("received_at ASC"). Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt step count chart records") return } var summary mqttMeasurementHistorySummary summary.ValueLabel = "步数" if err := buildMqttMeasurementSummary(baseQuery.Session(&gorm.Session{}), "step_count", "belt_addr", &summary); err != nil { writeError(c, http.StatusInternalServerError, "failed to query mqtt step count summary") return } items := make([]mqttMeasurementHistoryItem, 0, len(records)) for _, record := range records { items = append(items, mqttMeasurementHistoryItem{ ID: record.ID, DataType: "step_count", Identifier: record.Identifier, Topic: record.Topic, RegionID: record.RegionID, Addr: record.BeltAddr, Value: int64(record.StepCount), ValueLabel: "步数", PacketNum: record.PacketNum, GatewayMAC: record.GatewayMAC, Battery: 0, IsActive: false, IsOnSkin: false, SignalRSSINeg: record.SignalRSSINeg, SNR: record.SNR, ReceivedAt: record.ReceivedAt, CreatedAtMilli: record.CreatedAt.UnixMilli(), }) } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": items, "summary": summary, }) } func applyMqttMeasurementFilters(query *gorm.DB, params mqttMeasurementHistoryParams, addrField, valueField, timeField string) *gorm.DB { if params.RegionID > 0 { query = query.Where("region_id = ?", params.RegionID) } if addr := strings.TrimSpace(params.Addr); addr != "" { query = query.Where(addrField+" LIKE ?", "%"+addr+"%") } if params.StartTime > 0 { query = query.Where(timeField+" >= ?", params.StartTime) } if params.EndTime > 0 { query = query.Where(timeField+" <= ?", params.EndTime) } if params.Value != nil { switch strings.TrimSpace(params.ValueOperator) { case "", "eq": query = query.Where(valueField+" = ?", *params.Value) case "gt": query = query.Where(valueField+" > ?", *params.Value) case "gte": query = query.Where(valueField+" >= ?", *params.Value) case "lt": query = query.Where(valueField+" < ?", *params.Value) case "lte": query = query.Where(valueField+" <= ?", *params.Value) } } return query } func buildMqttMeasurementSummary(query *gorm.DB, valueField, addrField string, summary *mqttMeasurementHistorySummary) error { type rawSummary struct { RecordCount int64 UniqueAddrs int64 MinValue int64 MaxValue int64 AvgValue float64 } var result rawSummary if err := query.Session(&gorm.Session{}).Select( "COUNT(*) as record_count, COUNT(DISTINCT " + addrField + ") as unique_addrs, COALESCE(MIN(" + valueField + "), 0) as min_value, COALESCE(MAX(" + valueField + "), 0) as max_value, COALESCE(AVG(" + valueField + "), 0) as avg_value", ).Scan(&result).Error; err != nil { return err } summary.RecordCount = result.RecordCount summary.UniqueAddrs = result.UniqueAddrs summary.MinValue = result.MinValue summary.MaxValue = result.MaxValue summary.AvgValue = result.AvgValue return nil } // --- 删除接口 --- // @Summary 删除AI分析记录 // @Description 删除指定的AI分析记录 // @Tags 统计管理 // @Produce json // @Param id path int true "记录ID" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "删除成功" // @Failure 404 {object} SwagAPIResponse "记录不存在" // @Router /admin/statistics/ai-analysis-records/{id} [delete] func (sc *StatisticsController) DeleteAIAnalysisRecord(c *gin.Context) { id := strings.TrimSpace(c.Param("id")) if id == "" { writeError(c, http.StatusBadRequest, "id is required") return } var record models.AIAnalysisRecord if err := sc.DB.First(&record, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(c, http.StatusNotFound, "record not found") return } writeError(c, http.StatusInternalServerError, "failed to query record") return } if err := sc.DB.Delete(&record).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to delete record") return } writeSuccess(c, http.StatusOK, "delete success", nil) } // --- 统计接口 --- type regionStatisticsItem struct { RegionID uint32 `json:"regionId"` KindergartenName string `json:"kindergartenName"` Count int64 `json:"count"` TotalInputTokens int64 `json:"totalInputTokens"` TotalOutputTokens int64 `json:"totalOutputTokens"` TotalCacheHitTokens int64 `json:"totalCacheHitTokens"` TotalCacheMissTokens int64 `json:"totalCacheMissTokens"` TotalInputSizeBytes int64 `json:"totalInputSizeBytes"` TotalOutputSizeBytes int64 `json:"totalOutputSizeBytes"` TotalDurationMs int64 `json:"totalDurationMs"` AvgDurationMs float64 `json:"avgDurationMs"` AvgTotalCost float64 `json:"avgTotalCost"` TotalOriginalFileSize int64 `json:"totalOriginalFileSize"` TotalCompressedSize int64 `json:"totalCompressedSize"` TotalCost float64 `json:"totalCost"` TotalInputCost float64 `json:"totalInputCost"` TotalOutputCost float64 `json:"totalOutputCost"` TotalCacheHitCost float64 `json:"totalCacheHitCost"` TotalCacheMissCost float64 `json:"totalCacheMissCost"` AnalysisTypeCounts map[string]int64 `json:"analysisTypeCounts"` SourceTypeCounts map[string]int64 `json:"sourceTypeCounts"` FirstUsedAt *time.Time `json:"firstUsedAt"` LastUsedAt *time.Time `json:"lastUsedAt"` } // @Summary AI分析区域统计 // @Description 按区域统计AI分析的使用情况,包括调用次数、Token消耗、费用等 // @Tags 统计管理 // @Produce json // @Param regionId query int false "区域ID" // @Param startTime query int false "开始时间(毫秒时间戳)" // @Param endTime query int false "结束时间(毫秒时间戳)" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "查询成功" // @Router /admin/statistics/ai-analysis [get] func (sc *StatisticsController) StatisticsByRegion(c *gin.Context) { regionIDStr := c.Query("regionId") startTimeStr := c.Query("startTime") endTimeStr := c.Query("endTime") query := sc.DB.Model(&models.AIAnalysisRecord{}) if regionIDStr != "" { if regionID, err := strconv.ParseUint(regionIDStr, 10, 32); err == nil { query = query.Where("region_id = ?", uint32(regionID)) } } if startTimeStr != "" { if startTime, err := strconv.ParseInt(startTimeStr, 10, 64); err == nil { query = query.Where("upload_time >= ?", startTime) } } if endTimeStr != "" { if endTime, err := strconv.ParseInt(endTimeStr, 10, 64); err == nil { query = query.Where("upload_time <= ?", endTime) } } type rawStats struct { RegionID *uint32 Count int64 TotalInputTokens int64 TotalOutputTokens int64 TotalCacheHitTokens int64 TotalCacheMissTokens int64 TotalInputSizeBytes int64 TotalOutputSizeBytes int64 TotalDurationMs int64 TotalOriginalFileSize int64 TotalCompressedSize int64 TotalCost float64 TotalInputCost float64 TotalOutputCost float64 TotalCacheHitCost float64 TotalCacheMissCost float64 FirstUsedAt *int64 LastUsedAt *int64 } var rawResults []rawStats err := query.Select(` region_id, COUNT(*) as count, COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_hit_tokens), 0) as total_cache_hit_tokens, COALESCE(SUM(cache_miss_tokens), 0) as total_cache_miss_tokens, COALESCE(SUM(input_size_bytes), 0) as total_input_size_bytes, COALESCE(SUM(output_size_bytes), 0) as total_output_size_bytes, COALESCE(SUM(duration_ms), 0) as total_duration_ms, COALESCE(SUM(original_file_size), 0) as total_original_file_size, COALESCE(SUM(compressed_content_size), 0) as total_compressed_size, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM((cost_json::jsonb->>'cacheHitCost')::float8 + (cost_json::jsonb->>'cacheMissCost')::float8), 0) as total_input_cost, COALESCE(SUM((cost_json::jsonb->>'outputCost')::float8), 0) as total_output_cost, COALESCE(SUM((cost_json::jsonb->>'cacheHitCost')::float8), 0) as total_cache_hit_cost, COALESCE(SUM((cost_json::jsonb->>'cacheMissCost')::float8), 0) as total_cache_miss_cost, MIN(upload_time) as first_used_at, MAX(upload_time) as last_used_at `).Group("region_id").Scan(&rawResults).Error if err != nil { writeError(c, http.StatusInternalServerError, "failed to query statistics") return } type analysisTypeCount struct { RegionID *uint32 AnalysisType string Count int64 } var analysisTypeResults []analysisTypeCount if err := query.Select("region_id, analysis_type, COUNT(*) as count").Group("region_id, analysis_type").Scan(&analysisTypeResults).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query analysis type statistics") return } type sourceTypeCount struct { RegionID *uint32 SourceType string Count int64 } var sourceTypeResults []sourceTypeCount if err := query.Select("region_id, source_type, COUNT(*) as count").Group("region_id, source_type").Scan(&sourceTypeResults).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query source type statistics") return } analysisTypeMap := make(map[uint32]map[string]int64) for _, r := range analysisTypeResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } if analysisTypeMap[regionID] == nil { analysisTypeMap[regionID] = make(map[string]int64) } analysisTypeMap[regionID][r.AnalysisType] = r.Count } sourceTypeMap := make(map[uint32]map[string]int64) for _, r := range sourceTypeResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } if sourceTypeMap[regionID] == nil { sourceTypeMap[regionID] = make(map[string]int64) } sourceTypeMap[regionID][r.SourceType] = r.Count } // 收集所有 regionId 查询幼儿园名称 regionIDs := make([]uint32, 0, len(rawResults)) for _, r := range rawResults { if r.RegionID != nil && *r.RegionID > 0 { regionIDs = append(regionIDs, *r.RegionID) } } kindergartenMap := make(map[uint32]string) if len(regionIDs) > 0 { var kindergartens []models.Kindergarten if err := sc.DB.Where("region_id IN ?", regionIDs).Find(&kindergartens).Error; err == nil { for _, k := range kindergartens { kindergartenMap[k.RegionID] = k.Name } } } overall := regionStatisticsItem{ AnalysisTypeCounts: make(map[string]int64), SourceTypeCounts: make(map[string]int64), } regions := make(map[string]regionStatisticsItem, len(rawResults)) for _, r := range rawResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } avgDuration := float64(0) if r.Count > 0 { avgDuration = float64(r.TotalDurationMs) / float64(r.Count) } avgTotalCost := float64(0) if r.Count > 0 { avgTotalCost = r.TotalCost / float64(r.Count) } kgName := "" if regionID > 0 { kgName = kindergartenMap[regionID] } var firstUsedAt, lastUsedAt *time.Time if r.FirstUsedAt != nil { t := time.UnixMilli(*r.FirstUsedAt) firstUsedAt = &t } if r.LastUsedAt != nil { t := time.UnixMilli(*r.LastUsedAt) lastUsedAt = &t } item := regionStatisticsItem{ RegionID: regionID, KindergartenName: kgName, Count: r.Count, TotalInputTokens: r.TotalInputTokens, TotalOutputTokens: r.TotalOutputTokens, TotalCacheHitTokens: r.TotalCacheHitTokens, TotalCacheMissTokens: r.TotalCacheMissTokens, TotalInputSizeBytes: r.TotalInputSizeBytes, TotalOutputSizeBytes: r.TotalOutputSizeBytes, TotalDurationMs: r.TotalDurationMs, AvgDurationMs: avgDuration, AvgTotalCost: avgTotalCost, TotalOriginalFileSize: r.TotalOriginalFileSize, TotalCompressedSize: r.TotalCompressedSize, TotalCost: r.TotalCost, TotalInputCost: r.TotalInputCost, TotalOutputCost: r.TotalOutputCost, TotalCacheHitCost: r.TotalCacheHitCost, TotalCacheMissCost: r.TotalCacheMissCost, AnalysisTypeCounts: analysisTypeMap[regionID], SourceTypeCounts: sourceTypeMap[regionID], FirstUsedAt: firstUsedAt, LastUsedAt: lastUsedAt, } regions[strconv.FormatUint(uint64(regionID), 10)] = item overall.Count += r.Count overall.TotalInputTokens += r.TotalInputTokens overall.TotalOutputTokens += r.TotalOutputTokens overall.TotalCacheHitTokens += r.TotalCacheHitTokens overall.TotalCacheMissTokens += r.TotalCacheMissTokens overall.TotalInputSizeBytes += r.TotalInputSizeBytes overall.TotalOutputSizeBytes += r.TotalOutputSizeBytes overall.TotalDurationMs += r.TotalDurationMs overall.TotalOriginalFileSize += r.TotalOriginalFileSize overall.TotalCompressedSize += r.TotalCompressedSize overall.TotalCost += r.TotalCost overall.TotalInputCost += r.TotalInputCost overall.TotalOutputCost += r.TotalOutputCost overall.TotalCacheHitCost += r.TotalCacheHitCost overall.TotalCacheMissCost += r.TotalCacheMissCost if firstUsedAt != nil { if overall.FirstUsedAt == nil || firstUsedAt.Before(*overall.FirstUsedAt) { overall.FirstUsedAt = firstUsedAt } } if lastUsedAt != nil { if overall.LastUsedAt == nil || lastUsedAt.After(*overall.LastUsedAt) { overall.LastUsedAt = lastUsedAt } } } for _, r := range analysisTypeResults { overall.AnalysisTypeCounts[r.AnalysisType] += r.Count } for _, r := range sourceTypeResults { overall.SourceTypeCounts[r.SourceType] += r.Count } if overall.Count > 0 { overall.AvgDurationMs = float64(overall.TotalDurationMs) / float64(overall.Count) overall.AvgTotalCost = overall.TotalCost / float64(overall.Count) } writeSuccess(c, http.StatusOK, "query success", gin.H{ "overall": overall, "regions": regions, }) } type trainingSessionRegionStatisticsItem struct { RegionID uint32 `json:"regionId"` KindergartenName string `json:"kindergartenName"` Count int64 `json:"count"` StartedCount int64 `json:"startedCount"` EndedCount int64 `json:"endedCount"` CompletedCount int64 `json:"completedCount"` InProgressCount int64 `json:"inProgressCount"` TotalDurationMs int64 `json:"totalDurationMs"` AvgDurationMs float64 `json:"avgDurationMs"` EventTypeCounts map[string]int64 `json:"eventTypeCounts"` AppNameCounts map[string]int64 `json:"appNameCounts"` FlavorTypeCounts map[string]int64 `json:"flavorTypeCounts"` FirstPublishedAt *time.Time `json:"firstPublishedAt"` LastPublishedAt *time.Time `json:"lastPublishedAt"` } // @Summary 训练会话区域统计 // @Description 按区域统计MQTT训练会话情况,包括开始、结束、完成、进行中的会话数 // @Tags 统计管理 // @Produce json // @Param regionId query int false "区域ID" // @Param flavorType query string false "类型筛选" // @Param startTime query int false "开始时间(毫秒时间戳)" // @Param endTime query int false "结束时间(毫秒时间戳)" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "查询成功" // @Router /admin/statistics/mqtt-training-sessions [get] func (sc *StatisticsController) TrainingSessionStatisticsByRegion(c *gin.Context) { regionIDStr := c.Query("regionId") flavorType := strings.TrimSpace(c.Query("flavorType")) startTimeStr := c.Query("startTime") endTimeStr := c.Query("endTime") query := sc.DB.Model(&models.MqttTrainingSessionRecord{}) if regionIDStr != "" { if regionID, err := strconv.ParseUint(regionIDStr, 10, 32); err == nil { query = query.Where("region_id = ?", uint32(regionID)) } } if flavorType != "" { query = query.Where("flavor_type = ?", flavorType) } if startTimeStr != "" { if startTime, err := strconv.ParseInt(startTimeStr, 10, 64); err == nil { query = query.Where("published_at >= ?", startTime) } } if endTimeStr != "" { if endTime, err := strconv.ParseInt(endTimeStr, 10, 64); err == nil { query = query.Where("published_at <= ?", endTime) } } type rawTrainingStats struct { RegionID *uint32 Count int64 StartedCount int64 EndedCount int64 CompletedCount int64 InProgressCount int64 TotalDurationMs int64 FirstPublishedAt *int64 LastPublishedAt *int64 } var rawResults []rawTrainingStats err := query.Select(` region_id, COUNT(*) as count, COALESCE(SUM(CASE WHEN started_at IS NOT NULL THEN 1 ELSE 0 END), 0) as started_count, COALESCE(SUM(CASE WHEN ended_at IS NOT NULL THEN 1 ELSE 0 END), 0) as ended_count, COALESCE(SUM(CASE WHEN started_at IS NOT NULL AND ended_at IS NOT NULL THEN 1 ELSE 0 END), 0) as completed_count, COALESCE(SUM(CASE WHEN started_at IS NOT NULL AND ended_at IS NULL THEN 1 ELSE 0 END), 0) as in_progress_count, COALESCE(SUM(CASE WHEN started_at IS NOT NULL AND ended_at IS NOT NULL AND ended_at >= started_at THEN ended_at - started_at ELSE 0 END), 0) as total_duration_ms, MIN(published_at) as first_published_at, MAX(published_at) as last_published_at `).Group("region_id").Scan(&rawResults).Error if err != nil { writeError(c, http.StatusInternalServerError, "failed to query training session statistics") return } type trainingEventTypeCount struct { RegionID *uint32 EventType string Count int64 } var eventTypeResults []trainingEventTypeCount if err := query.Select("region_id, event_type, COUNT(*) as count").Group("region_id, event_type").Scan(&eventTypeResults).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query training session event type statistics") return } type trainingAppNameCount struct { RegionID *uint32 AppName string Count int64 } var appNameResults []trainingAppNameCount if err := query.Select("region_id, app_name, COUNT(*) as count").Group("region_id, app_name").Scan(&appNameResults).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query training session app name statistics") return } type trainingFlavorTypeCount struct { RegionID *uint32 FlavorType string Count int64 } var flavorTypeResults []trainingFlavorTypeCount if err := query.Select("region_id, flavor_type, COUNT(*) as count").Group("region_id, flavor_type").Scan(&flavorTypeResults).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query training session flavor type statistics") return } eventTypeMap := make(map[uint32]map[string]int64) for _, r := range eventTypeResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } if eventTypeMap[regionID] == nil { eventTypeMap[regionID] = make(map[string]int64) } eventTypeMap[regionID][r.EventType] = r.Count } appNameMap := make(map[uint32]map[string]int64) for _, r := range appNameResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } if appNameMap[regionID] == nil { appNameMap[regionID] = make(map[string]int64) } appNameMap[regionID][r.AppName] = r.Count } flavorTypeMap := make(map[uint32]map[string]int64) for _, r := range flavorTypeResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } if flavorTypeMap[regionID] == nil { flavorTypeMap[regionID] = make(map[string]int64) } flavorTypeMap[regionID][r.FlavorType] = r.Count } regionIDs := make([]uint32, 0, len(rawResults)) for _, r := range rawResults { if r.RegionID != nil && *r.RegionID > 0 { regionIDs = append(regionIDs, *r.RegionID) } } kindergartenMap := make(map[uint32]string) if len(regionIDs) > 0 { var kindergartens []models.Kindergarten if err := sc.DB.Where("region_id IN ?", regionIDs).Find(&kindergartens).Error; err == nil { for _, k := range kindergartens { kindergartenMap[k.RegionID] = k.Name } } } overall := trainingSessionRegionStatisticsItem{ EventTypeCounts: make(map[string]int64), AppNameCounts: make(map[string]int64), FlavorTypeCounts: make(map[string]int64), } regions := make(map[string]trainingSessionRegionStatisticsItem, len(rawResults)) for _, r := range rawResults { regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } avgDuration := float64(0) if r.CompletedCount > 0 { avgDuration = float64(r.TotalDurationMs) / float64(r.CompletedCount) } kgName := "" if regionID > 0 { kgName = kindergartenMap[regionID] } var firstPublishedAt, lastPublishedAt *time.Time if r.FirstPublishedAt != nil { t := time.UnixMilli(*r.FirstPublishedAt) firstPublishedAt = &t } if r.LastPublishedAt != nil { t := time.UnixMilli(*r.LastPublishedAt) lastPublishedAt = &t } item := trainingSessionRegionStatisticsItem{ RegionID: regionID, KindergartenName: kgName, Count: r.Count, StartedCount: r.StartedCount, EndedCount: r.EndedCount, CompletedCount: r.CompletedCount, InProgressCount: r.InProgressCount, TotalDurationMs: r.TotalDurationMs, AvgDurationMs: avgDuration, EventTypeCounts: eventTypeMap[regionID], AppNameCounts: appNameMap[regionID], FlavorTypeCounts: flavorTypeMap[regionID], FirstPublishedAt: firstPublishedAt, LastPublishedAt: lastPublishedAt, } regions[strconv.FormatUint(uint64(regionID), 10)] = item overall.Count += r.Count overall.StartedCount += r.StartedCount overall.EndedCount += r.EndedCount overall.CompletedCount += r.CompletedCount overall.InProgressCount += r.InProgressCount overall.TotalDurationMs += r.TotalDurationMs if firstPublishedAt != nil { if overall.FirstPublishedAt == nil || firstPublishedAt.Before(*overall.FirstPublishedAt) { overall.FirstPublishedAt = firstPublishedAt } } if lastPublishedAt != nil { if overall.LastPublishedAt == nil || lastPublishedAt.After(*overall.LastPublishedAt) { overall.LastPublishedAt = lastPublishedAt } } } for _, r := range eventTypeResults { overall.EventTypeCounts[r.EventType] += r.Count } for _, r := range appNameResults { overall.AppNameCounts[r.AppName] += r.Count } for _, r := range flavorTypeResults { overall.FlavorTypeCounts[r.FlavorType] += r.Count } if overall.CompletedCount > 0 { overall.AvgDurationMs = float64(overall.TotalDurationMs) / float64(overall.CompletedCount) } writeSuccess(c, http.StatusOK, "query success", gin.H{ "overall": overall, "regions": regions, }) } // @Summary AI分析时间线统计 // @Description 按日期统计AI分析的使用情况趋势,含总体和分区域数据 // @Tags 统计管理 // @Produce json // @Param regionId query int false "区域ID" // @Param startTime query int false "开始时间(毫秒时间戳)" // @Param endTime query int false "结束时间(毫秒时间戳)" // @Security BearerAuth // @Success 200 {object} SwagAPIResponse "查询成功" // @Router /admin/statistics/ai-analysis-timeline [get] func (sc *StatisticsController) TimelineStatistics(c *gin.Context) { regionIDStr := c.Query("regionId") startTimeStr := c.Query("startTime") endTimeStr := c.Query("endTime") query := sc.DB.Model(&models.AIAnalysisRecord{}) if regionIDStr != "" { if regionID, err := strconv.ParseUint(regionIDStr, 10, 32); err == nil { query = query.Where("region_id = ?", uint32(regionID)) } } if startTimeStr != "" { if startTime, err := strconv.ParseInt(startTimeStr, 10, 64); err == nil { query = query.Where("upload_time >= ?", startTime) } } if endTimeStr != "" { if endTime, err := strconv.ParseInt(endTimeStr, 10, 64); err == nil { query = query.Where("upload_time <= ?", endTime) } } type timelineItem struct { Date string `json:"date"` Count int64 `json:"count"` InputTokens int64 `json:"inputTokens"` OutputTokens int64 `json:"outputTokens"` CacheHitTokens int64 `json:"cacheHitTokens"` CacheMissTokens int64 `json:"cacheMissTokens"` TotalCost float64 `json:"totalCost"` } type rawRegionTimeline struct { RegionID *uint32 Date string Count int64 InputTokens int64 OutputTokens int64 CacheHitTokens int64 CacheMissTokens int64 TotalCost float64 } var rawResults []rawRegionTimeline err := query.Select(` region_id, DATE(TO_TIMESTAMP(upload_time / 1000.0)) as date, COUNT(*) as count, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(cache_hit_tokens), 0) as cache_hit_tokens, COALESCE(SUM(cache_miss_tokens), 0) as cache_miss_tokens, COALESCE(SUM(total_cost), 0) as total_cost `).Group("region_id, DATE(TO_TIMESTAMP(upload_time / 1000.0))").Order("region_id, date ASC").Scan(&rawResults).Error if err != nil { writeError(c, http.StatusInternalServerError, "failed to query timeline statistics") return } overallMap := make(map[string]*timelineItem) regionItemsMap := make(map[string][]timelineItem) regionIDs := make([]uint32, 0) regionIDSet := make(map[uint32]struct{}) for _, r := range rawResults { if overallMap[r.Date] == nil { overallMap[r.Date] = &timelineItem{Date: r.Date} } overallMap[r.Date].Count += r.Count overallMap[r.Date].InputTokens += r.InputTokens overallMap[r.Date].OutputTokens += r.OutputTokens overallMap[r.Date].CacheHitTokens += r.CacheHitTokens overallMap[r.Date].CacheMissTokens += r.CacheMissTokens overallMap[r.Date].TotalCost += r.TotalCost regionID := uint32(0) if r.RegionID != nil { regionID = *r.RegionID } regionIDStr := strconv.FormatUint(uint64(regionID), 10) regionItemsMap[regionIDStr] = append(regionItemsMap[regionIDStr], timelineItem{ Date: r.Date, Count: r.Count, InputTokens: r.InputTokens, OutputTokens: r.OutputTokens, CacheHitTokens: r.CacheHitTokens, CacheMissTokens: r.CacheMissTokens, TotalCost: r.TotalCost, }) if _, ok := regionIDSet[regionID]; !ok && regionID > 0 { regionIDSet[regionID] = struct{}{} regionIDs = append(regionIDs, regionID) } } // 查询幼儿园名称 kindergartenMap := make(map[uint32]string) if len(regionIDs) > 0 { var kindergartens []models.Kindergarten if err := sc.DB.Where("region_id IN ?", regionIDs).Find(&kindergartens).Error; err == nil { for _, k := range kindergartens { kindergartenMap[k.RegionID] = k.Name } } } type regionTimeline struct { Name string `json:"name"` Items []timelineItem `json:"items"` } regionsMap := make(map[string]regionTimeline) for regionIDStr, items := range regionItemsMap { name := "" if regionID, err := strconv.ParseUint(regionIDStr, 10, 32); err == nil && regionID > 0 { name = kindergartenMap[uint32(regionID)] } regionsMap[regionIDStr] = regionTimeline{ Name: name, Items: items, } } var overall []timelineItem for _, item := range overallMap { overall = append(overall, *item) } sort.Slice(overall, func(i, j int) bool { return overall[i].Date < overall[j].Date }) writeSuccess(c, http.StatusOK, "query success", gin.H{ "overall": overall, "regions": regionsMap, }) }