// 运营统计的聚合计算:概览 KPI、趋势、模型与用户排行、失败归类。 // // 全部是对已查询出的任务与调用日志做内存聚合的纯函数,便于单测。 package app import ( "sort" "strings" "time" "yingce/backend/internal/model" "yingce/backend/internal/repository" ) func normalizeAnalyticsFilter(query AnalyticsQuery) repository.AnalyticsFilter { now := time.Now().UTC() to := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) from := to.AddDate(0, 0, -30) if parsed, ok := parseAnalyticsTime(query.From); ok { from = parsed } if parsed, ok := parseAnalyticsTime(query.To); ok { to = parsed if len(strings.TrimSpace(query.To)) == len("2006-01-02") { to = to.AddDate(0, 0, 1) } } if !to.After(from) { to = from.AddDate(0, 0, 1) } if to.Sub(from) > 366*24*time.Hour { from = to.AddDate(-1, 0, 0) } return repository.AnalyticsFilter{From: from, To: to, UserID: strings.TrimSpace(query.UserID), Model: strings.TrimSpace(query.Model), ChannelID: strings.TrimSpace(query.ChannelID), Capability: normalizeCapability(query.Capability)} } func parseAnalyticsTime(value string) (time.Time, bool) { value = strings.TrimSpace(value) for _, layout := range []string{time.RFC3339, "2006-01-02"} { if parsed, err := time.Parse(layout, value); err == nil { return parsed.UTC(), true } } return time.Time{}, false } func normalizeCapability(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "text", "image", "video", "audio": return strings.ToLower(strings.TrimSpace(value)) default: return "" } } func buildAnalyticsOverview(filter repository.AnalyticsFilter, tasks []model.Task, rollingTasks []model.Task, rollingLogs []model.ApiCallLog, logs []model.ApiCallLog, activities []model.UserDailyActivity, users []model.User) *AnalyticsOverview { result := &AnalyticsOverview{From: filter.From, To: filter.To, Trend: []AnalyticsTrendPoint{}, Models: []AnalyticsModelRow{}, Users: []AnalyticsUserRow{}, Failures: []AnalyticsFailureRow{}} result.KPI.GenerationTasks = len(tasks) result.KPI.UpstreamRequests = len(logs) result.KPI.SuccessRate = successRateLogs(logs) durations := make([]int64, 0, len(logs)) activeUsers := map[string]bool{} if !hasCreationDimensionFilter(filter) { for _, activity := range activities { if activity.Day.Before(filter.From) || !activity.Day.Before(filter.To) || !meaningfulActivity(activity) { continue } activeUsers[activity.UserID] = true } } for _, task := range tasks { activeUsers[task.UserID] = true } for _, log := range logs { activeUsers[log.UserID] = true } result.KPI.ActiveUsers = len(activeUsers) rollingActivities := activities if hasCreationDimensionFilter(filter) { rollingActivities = nil } result.KPI.DAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -1), filter.To) result.KPI.WAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -7), filter.To) result.KPI.MAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -30), filter.To) for _, log := range logs { durations = append(durations, log.DurationMs) } result.KPI.P95DurationMs = percentile(durations, 0.95) result.Trend = buildAnalyticsTrend(filter, tasks, logs, activities) result.Models = buildAnalyticsModels(tasks, logs) result.Users = buildAnalyticsUsers(filter, tasks, logs, activities, users) result.Failures = buildAnalyticsFailures(logs) return result } func buildAnalyticsTrend(filter repository.AnalyticsFilter, tasks []model.Task, logs []model.ApiCallLog, activities []model.UserDailyActivity) []AnalyticsTrendPoint { points := map[string]*AnalyticsTrendPoint{} for day := time.Date(filter.From.Year(), filter.From.Month(), filter.From.Day(), 0, 0, 0, 0, time.UTC); day.Before(filter.To); day = day.AddDate(0, 0, 1) { key := day.Format("2006-01-02") points[key] = &AnalyticsTrendPoint{Day: key} } requestTotals := map[string]int{} requestSuccess := map[string]int{} activeByDay := map[string]map[string]bool{} for _, task := range tasks { key := task.CreatedAt.UTC().Format("2006-01-02") if point := points[key]; point != nil { point.Tasks++ if activeByDay[key] == nil { activeByDay[key] = map[string]bool{} } activeByDay[key][task.UserID] = true } } for _, log := range logs { key := log.CreatedAt.UTC().Format("2006-01-02") if point := points[key]; point != nil { point.Requests++ if log.BillingAmount > 0 { point.ConsumedMicrocredits += log.BillingAmount } requestTotals[key]++ if log.Status == model.ApiCallStatusSucceeded { requestSuccess[key]++ } if activeByDay[key] == nil { activeByDay[key] = map[string]bool{} } activeByDay[key][log.UserID] = true } } if !hasCreationDimensionFilter(filter) { for _, activity := range activities { key := activity.Day.UTC().Format("2006-01-02") if points[key] == nil || !meaningfulActivity(activity) { continue } if activeByDay[key] == nil { activeByDay[key] = map[string]bool{} } activeByDay[key][activity.UserID] = true } } keys := make([]string, 0, len(points)) for key := range points { keys = append(keys, key) } sort.Strings(keys) result := make([]AnalyticsTrendPoint, 0, len(keys)) for _, key := range keys { point := points[key] point.ActiveUsers = len(activeByDay[key]) point.RequestSuccessRate = ratio(requestSuccess[key], requestTotals[key]) result = append(result, *point) } return result } func buildAnalyticsModels(tasks []model.Task, logs []model.ApiCallLog) []AnalyticsModelRow { type accumulator struct { row AnalyticsModelRow users map[string]bool taskSuccess int taskTotal int requestSuccess int durations []int64 } items := map[string]*accumulator{} get := func(modelName string, capability string) *accumulator { if modelName == "" { modelName = "未识别" } key := modelName + "\x00" + capability if items[key] == nil { items[key] = &accumulator{row: AnalyticsModelRow{Model: modelName, Capability: capability}, users: map[string]bool{}} } return items[key] } for _, task := range tasks { capability := capabilityFromTaskType(task.Type) item := get(task.Model, capability) item.row.Tasks++ item.users[task.UserID] = true if task.Status != model.TaskStatusCancelled { item.taskTotal++ if task.Status == model.TaskStatusSucceeded { item.taskSuccess++ } } } for _, log := range logs { item := get(log.Model, log.Capability) item.row.Requests++ item.users[log.UserID] = true item.durations = append(item.durations, log.DurationMs) if log.Status == model.ApiCallStatusSucceeded { item.requestSuccess++ } if log.UsageAvailable { item.row.UsageAvailable = true item.row.InputTokens += log.InputTokens item.row.OutputTokens += log.OutputTokens item.row.CachedTokens += log.CachedTokens } item.row.MediaCount += log.MediaCount item.row.VideoSeconds += log.VideoSeconds } result := make([]AnalyticsModelRow, 0, len(items)) for _, item := range items { item.row.UniqueUsers = len(item.users) item.row.TaskSuccessRate = ratio(item.taskSuccess, item.taskTotal) item.row.RequestSuccessRate = ratio(item.requestSuccess, item.row.Requests) item.row.P50DurationMs = percentile(item.durations, 0.5) item.row.P95DurationMs = percentile(item.durations, 0.95) result = append(result, item.row) } sort.Slice(result, func(i, j int) bool { if result[i].Tasks == result[j].Tasks { return result[i].Requests > result[j].Requests } return result[i].Tasks > result[j].Tasks }) return result } func buildAnalyticsUsers(filter repository.AnalyticsFilter, tasks []model.Task, logs []model.ApiCallLog, activities []model.UserDailyActivity, users []model.User) []AnalyticsUserRow { names := map[string]string{} for _, user := range users { names[user.ID] = firstNonEmpty(user.DisplayName, user.Username) } rows := map[string]*AnalyticsUserRow{} models := map[string]map[string]int{} get := func(userID string) *AnalyticsUserRow { if rows[userID] == nil { rows[userID] = &AnalyticsUserRow{UserID: userID, Name: firstNonEmpty(names[userID], userID)} } return rows[userID] } if !hasCreationDimensionFilter(filter) { for _, activity := range activities { if activity.Day.Before(filter.From) || !activity.Day.Before(filter.To) || !meaningfulActivity(activity) { continue } row := get(activity.UserID) row.ActiveDays++ row.AgentMessages += activity.AgentMessageCount if activity.CanvasActive { row.CanvasDays++ } row.Assets += activity.AssetCount row.Resources += activity.ResourceCount } } for _, task := range tasks { if models[task.UserID] == nil { models[task.UserID] = map[string]int{} } models[task.UserID][task.Model]++ get(task.UserID).Tasks++ } if hasCreationDimensionFilter(filter) { activeDays := map[string]map[string]bool{} for _, log := range logs { get(log.UserID) if activeDays[log.UserID] == nil { activeDays[log.UserID] = map[string]bool{} } activeDays[log.UserID][log.CreatedAt.UTC().Format("2006-01-02")] = true } for userID, days := range activeDays { get(userID).ActiveDays = len(days) } } result := make([]AnalyticsUserRow, 0, len(rows)) for userID, row := range rows { bestCount := 0 for modelName, count := range models[userID] { if modelName != "" && count > bestCount { row.CommonModel, bestCount = modelName, count } } result = append(result, *row) } sort.Slice(result, func(i, j int) bool { if result[i].Tasks == result[j].Tasks { return result[i].ActiveDays > result[j].ActiveDays } return result[i].Tasks > result[j].Tasks }) return result } func buildAnalyticsFailures(logs []model.ApiCallLog) []AnalyticsFailureRow { items := map[string]*AnalyticsFailureRow{} for _, log := range logs { if log.Status != model.ApiCallStatusFailed { continue } typeName := classifyAPICallError(log) modelName := firstNonEmpty(log.Model, "未识别") key := typeName + "\x00" + modelName if items[key] == nil { items[key] = &AnalyticsFailureRow{Type: typeName, Model: modelName} } item := items[key] item.Count++ if log.CreatedAt.After(item.LastSeenAt) { item.LastSeenAt = log.CreatedAt item.LastError = truncateRunes(log.Error, 180) } } result := make([]AnalyticsFailureRow, 0, len(items)) for _, item := range items { result = append(result, *item) } sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count }) return result } func classifyAPICallError(log model.ApiCallLog) string { value := strings.ToLower(log.Error) switch { case log.ErrorCode == contentModerationErrorCode: return "内容审核" case strings.Contains(value, "timeout"), strings.Contains(value, "超时"), log.StatusCode == 408, log.StatusCode == 504, log.StatusCode == 524: return "超时" case log.StatusCode == 401 || log.StatusCode == 403 || strings.Contains(value, "unauthorized"): return "鉴权失败" case log.StatusCode == 429 || strings.Contains(value, "rate limit"): return "限流" case log.StatusCode >= 400 && log.StatusCode < 500: return "请求参数" case log.StatusCode >= 500: return "上游服务" case value != "": return "网络或客户端" default: return "未知错误" } } func meaningfulActivity(activity model.UserDailyActivity) bool { return activity.TaskCount > 0 || activity.AgentMessageCount > 0 || activity.CanvasActive || activity.AssetCount > 0 || activity.ResourceCount > 0 } func rollingActiveUsers(activities []model.UserDailyActivity, tasks []model.Task, logs []model.ApiCallLog, from time.Time, to time.Time) int { users := map[string]bool{} for _, activity := range activities { if !activity.Day.Before(from) && activity.Day.Before(to) && meaningfulActivity(activity) { users[activity.UserID] = true } } for _, task := range tasks { if !task.CreatedAt.Before(from) && task.CreatedAt.Before(to) { users[task.UserID] = true } } for _, log := range logs { if !log.CreatedAt.Before(from) && log.CreatedAt.Before(to) { users[log.UserID] = true } } return len(users) } func successRateLogs(logs []model.ApiCallLog) float64 { succeeded, failed := 0, 0 for _, log := range logs { if log.Status == model.ApiCallStatusSucceeded { succeeded++ } else if log.Status == model.ApiCallStatusFailed { failed++ } } return ratio(succeeded, succeeded+failed) } func ratio(value int, total int) float64 { if total == 0 { return 0 } return float64(value) * 100 / float64(total) } func percentile(values []int64, quantile float64) int64 { if len(values) == 0 { return 0 } items := append([]int64(nil), values...) sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) index := int(float64(len(items)-1)*quantile + 0.5) return items[index] } func capabilityFromTaskType(taskType string) string { value := strings.ToLower(taskType) for _, capability := range []string{"video", "image", "audio", "text"} { if strings.Contains(value, capability) { return capability } } if strings.Contains(value, "storyboard") || strings.Contains(value, "agent") { return "text" } return "" } func hasCreationDimensionFilter(filter repository.AnalyticsFilter) bool { return filter.Model != "" || filter.ChannelID != "" || filter.Capability != "" } func tasksWithLoggedRequests(tasks []model.Task, logs []model.ApiCallLog) []model.Task { ids := map[string]bool{} for _, log := range logs { if log.TaskID != "" { ids[log.TaskID] = true } } result := make([]model.Task, 0, len(tasks)) for _, task := range tasks { if ids[task.ID] { result = append(result, task) } } return result }