Files
ttstd-infinite-canvas/backend/internal/app/analytics.go

1217 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"log"
"sort"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/gorm"
)
type AnalyticsQuery struct {
From string
To string
UserID string
Model string
ChannelID string
Capability string
}
type AnalyticsOverview struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
KPI AnalyticsKPI `json:"kpi"`
Trend []AnalyticsTrendPoint `json:"trend"`
Models []AnalyticsModelRow `json:"models"`
Users []AnalyticsUserRow `json:"users"`
Failures []AnalyticsFailureRow `json:"failures"`
}
type AnalyticsKPI struct {
ActiveUsers int `json:"activeUsers"`
DAU int `json:"dau"`
WAU int `json:"wau"`
MAU int `json:"mau"`
GenerationTasks int `json:"generationTasks"`
UpstreamRequests int `json:"upstreamRequests"`
SuccessRate float64 `json:"successRate"`
P95DurationMs int64 `json:"p95DurationMs"`
CurrentQueuedTasks int64 `json:"currentQueuedTasks"`
EstimatedCostMicros int64 `json:"estimatedCostMicros"`
CostAvailable bool `json:"costAvailable"`
Currency string `json:"currency"`
}
type AnalyticsTrendPoint struct {
Day string `json:"day"`
Tasks int `json:"tasks"`
Requests int `json:"requests"`
ActiveUsers int `json:"activeUsers"`
RequestSuccessRate float64 `json:"requestSuccessRate"`
}
type AnalyticsModelRow struct {
Model string `json:"model"`
Capability string `json:"capability"`
Tasks int `json:"tasks"`
Requests int `json:"requests"`
UniqueUsers int `json:"uniqueUsers"`
TaskSuccessRate float64 `json:"taskSuccessRate"`
RequestSuccessRate float64 `json:"requestSuccessRate"`
P50DurationMs int64 `json:"p50DurationMs"`
P95DurationMs int64 `json:"p95DurationMs"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedTokens int64 `json:"cachedTokens"`
UsageAvailable bool `json:"usageAvailable"`
MediaCount int `json:"mediaCount"`
VideoSeconds int `json:"videoSeconds"`
EstimatedCostMicros int64 `json:"estimatedCostMicros"`
CostAvailable bool `json:"costAvailable"`
Currency string `json:"currency"`
}
type AnalyticsUserRow struct {
UserID string `json:"userId"`
Name string `json:"name"`
ActiveDays int `json:"activeDays"`
Tasks int `json:"tasks"`
AgentMessages int `json:"agentMessages"`
CanvasDays int `json:"canvasDays"`
Assets int `json:"assets"`
Resources int `json:"resources"`
CommonModel string `json:"commonModel"`
}
type AnalyticsFailureRow struct {
Type string `json:"type"`
Model string `json:"model"`
Count int `json:"count"`
LastError string `json:"lastError"`
LastSeenAt time.Time `json:"lastSeenAt"`
}
type APICallLogQuery struct {
AnalyticsQuery
RecordType string
Keyword string
Status string
IDs []string
Page int
Limit int
}
type APICallLogPage struct {
Logs []model.ApiCallLog `json:"logs"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
type ModelPricingRequest struct {
ChannelID string `json:"channelId"`
Model string `json:"model"`
Capability string `json:"capability"`
Currency string `json:"currency"`
InputPerMillionMicros int64 `json:"inputPerMillionMicros"`
OutputPerMillionMicros int64 `json:"outputPerMillionMicros"`
CachedPerMillionMicros int64 `json:"cachedPerMillionMicros"`
PerRequestMicros int64 `json:"perRequestMicros"`
PerMediaMicros int64 `json:"perMediaMicros"`
PerVideoSecondMicros int64 `json:"perVideoSecondMicros"`
}
func (s *Service) AdminAnalytics(actor *model.User, query AnalyticsQuery) (*AnalyticsOverview, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
filter := normalizeAnalyticsFilter(query)
tasks, err := s.repo.AnalyticsTasks(filter)
if err != nil {
return nil, err
}
logs, err := s.repo.AnalyticsAPICallLogs(filter)
if err != nil {
return nil, err
}
if filter.ChannelID != "" {
tasks = tasksWithLoggedRequests(tasks, logs)
}
activityFilter := filter
rollingFrom := filter.To.AddDate(0, 0, -30)
if rollingFrom.Before(activityFilter.From) {
activityFilter.From = rollingFrom
}
activities, err := s.repo.AnalyticsActivities(activityFilter)
if err != nil {
return nil, err
}
rollingTasks := tasks
rollingLogs := logs
if activityFilter.From.Before(filter.From) {
rollingTasks, err = s.repo.AnalyticsTasks(activityFilter)
if err != nil {
return nil, err
}
if hasCreationDimensionFilter(filter) {
rollingLogs, err = s.repo.AnalyticsAPICallLogs(activityFilter)
if err != nil {
return nil, err
}
}
if filter.ChannelID != "" {
rollingTasks = tasksWithLoggedRequests(rollingTasks, rollingLogs)
}
}
users, err := s.repo.Users()
if err != nil {
return nil, err
}
queued, err := s.repo.CurrentQueuedTaskCount()
if err != nil {
return nil, err
}
result := buildAnalyticsOverview(filter, tasks, rollingTasks, rollingLogs, logs, activities, users)
result.KPI.CurrentQueuedTasks = queued
return result, nil
}
func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*APICallLogPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" {
return nil, BadAuthRequest("请求明细类型无效")
}
filter := normalizeAnalyticsFilter(query.AnalyticsQuery)
logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit})
if err != nil {
return nil, err
}
if err := s.decorateAPICallLogs(logs); err != nil {
return nil, err
}
for index := range logs {
// 原始报文只允许通过详情接口按单条读取。
logs[index].RequestBody = ""
logs[index].ResponseBody = ""
}
page, limit := query.Page, query.Limit
if page <= 0 {
page = 1
}
if limit <= 0 || limit > 200 {
limit = 50
}
return &APICallLogPage{Logs: logs, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
// 历史日志允许读取逻辑删除渠道的名称,但不读取或返回渠道密钥。
channels, err := s.repo.HistoricalSystemChannelReferences()
if err != nil {
return err
}
channelNames := make(map[string]string, len(channels))
for _, channel := range channels {
channelNames[channel.ID] = channel.Name
}
users, err := s.repo.Users()
if err != nil {
return err
}
userByID := make(map[string]model.User, len(users))
for _, user := range users {
userByID[user.ID] = user
}
billingOrderIDs := make([]string, 0, len(logs))
seenBillingOrderIDs := make(map[string]struct{}, len(logs))
taskIDs := make([]string, 0, len(logs))
seenTaskIDs := make(map[string]struct{}, len(logs))
for _, log := range logs {
if log.Billable && log.BillingOrderID != "" {
if _, exists := seenBillingOrderIDs[log.BillingOrderID]; !exists {
seenBillingOrderIDs[log.BillingOrderID] = struct{}{}
billingOrderIDs = append(billingOrderIDs, log.BillingOrderID)
}
}
if (log.Capability != "image" && log.Capability != "video") || log.TaskID == "" {
continue
}
if _, exists := seenTaskIDs[log.TaskID]; exists {
continue
}
seenTaskIDs[log.TaskID] = struct{}{}
taskIDs = append(taskIDs, log.TaskID)
}
tasks, err := s.repo.APICallLogTasks(taskIDs)
if err != nil {
return err
}
taskByID := make(map[string]model.Task, len(tasks))
for _, task := range tasks {
taskByID[task.ID] = task
}
billingOrderByID, err := s.repo.BillingOrdersByIDs(billingOrderIDs)
if err != nil {
return err
}
for index := range logs {
if logs[index].StartedAt.IsZero() {
logs[index].StartedAt = logs[index].CreatedAt
}
if logs[index].ChannelID == "" {
logs[index].ChannelName = "自定义渠道"
} else if name := channelNames[logs[index].ChannelID]; name != "" {
logs[index].ChannelName = name
} else {
logs[index].ChannelName = "已删除渠道"
}
if user, exists := userByID[logs[index].UserID]; exists {
logs[index].UserDisplayName = user.DisplayName
logs[index].UserAccount = user.Username
}
if logs[index].Billable {
if order, exists := billingOrderByID[logs[index].BillingOrderID]; exists && order.UserID == logs[index].UserID {
logs[index].BillingAvailable = true
logs[index].BillingStatus = order.Status
if order.Status == model.BillingStatusSettled {
logs[index].BillingAmount = order.ActualAmountMicrocredits
} else if order.Status != model.BillingStatusRefunded {
logs[index].BillingAmount = order.ReservedAmountMicrocredits
}
}
}
if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID {
logs[index].TaskStatus = task.Status
previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type)
if canvasResourceID(previewURL) != "" {
logs[index].MediaPreviewURL = "/api/admin/api-logs/" + logs[index].ID + "/media"
logs[index].MediaPreviewKind = previewKind
} else if strings.HasPrefix(previewURL, "https://") || strings.HasPrefix(previewURL, "http://") {
logs[index].MediaPreviewURL = previewURL
logs[index].MediaPreviewKind = previewKind
}
}
}
return nil
}
// 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。
func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) {
userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
if err != nil {
return nil, err
}
return s.openResourceRange(userID, resource, rangeHeader)
}
func (s *Service) PrepareAdminAPICallLogMediaDelivery(actor *model.User, logID string, rangeHeader string) (*ResourceDelivery, error) {
userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
if err != nil {
return nil, err
}
delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{})
if err != nil || delivery.RedirectURL != "" {
return delivery, err
}
stream, err := s.openResourceRange(userID, resource, rangeHeader)
if err != nil {
return nil, err
}
delivery.Stream = stream
return delivery, nil
}
func (s *Service) adminAPICallLogMediaResource(actor *model.User, logID string) (string, *model.Resource, error) {
if err := s.RequireAdmin(actor); err != nil {
return "", nil, err
}
log, err := s.repo.APICallLog(strings.TrimSpace(logID))
if err != nil {
return "", nil, err
}
if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") {
return "", nil, BadAuthRequest("该请求没有可预览媒体")
}
task, err := s.repo.Task(log.TaskID)
if err != nil {
return "", nil, err
}
if task.UserID != log.UserID {
return "", nil, BadAuthRequest("请求与媒体归属不一致")
}
previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type)
resourceID := canvasResourceID(previewURL)
if resourceID == "" {
return "", nil, BadAuthRequest("该请求没有已持久化媒体")
}
resource, err := s.repo.ResourceForUser(log.UserID, resourceID)
if err != nil {
return "", nil, err
}
return log.UserID, resource, nil
}
func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
log, err := s.repo.APICallLog(strings.TrimSpace(id))
if err != nil {
return nil, err
}
logs := []model.ApiCallLog{*log}
if err := s.decorateAPICallLogs(logs); err != nil {
return nil, err
}
return &logs[0], nil
}
func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) ([]byte, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" {
return nil, BadAuthRequest("请求明细类型无效")
}
filter := normalizeAnalyticsFilter(query.AnalyticsQuery)
ids := uniqueNonEmpty(query.IDs)
if len(query.IDs) > 0 && len(ids) == 0 {
return nil, BadAuthRequest("请选择要导出的请求明细")
}
if len(ids) > 200 {
return nil, BadAuthRequest("单次最多导出 200 条已选请求明细")
}
logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000)
if err != nil {
return nil, err
}
if err := s.decorateAPICallLogs(logs); err != nil {
return nil, err
}
var buffer bytes.Buffer
buffer.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buffer)
_ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "积分计费(微积分)", "积分计费状态", "上游估算费用(微单位)", "币种", "错误码", "错误"})
for _, log := range logs {
startedAt := log.StartedAt
if startedAt.IsZero() {
startedAt = log.CreatedAt
}
billingAmount, billingStatus := "", ""
if log.BillingAvailable {
billingAmount = strconv.FormatInt(log.BillingAmount, 10)
billingStatus = string(log.BillingStatus)
}
upstreamCost := ""
if log.CostAvailable {
upstreamCost = strconv.FormatInt(log.EstimatedCostMicros, 10)
}
_ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, upstreamCost, log.Currency, log.ErrorCode, log.Error})
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([]byte, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
filter := normalizeAnalyticsFilter(query)
logs, err := s.repo.AnalyticsAPICallLogs(filter)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
buffer.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buffer)
_ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "估算费用(微单位)", "币种", "错误类型"})
for _, log := range logs {
cost := ""
if log.CostAvailable {
cost = strconv.FormatInt(log.EstimatedCostMicros, 10)
}
inputTokens, outputTokens, cachedTokens := "", "", ""
if log.UsageAvailable {
inputTokens = strconv.FormatInt(log.InputTokens, 10)
outputTokens = strconv.FormatInt(log.OutputTokens, 10)
cachedTokens = strconv.FormatInt(log.CachedTokens, 10)
}
_ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), cost, log.Currency, classifyAPICallError(log)})
}
writer.Flush()
return buffer.Bytes(), writer.Error()
}
func (s *Service) AdminModelPricings(actor *model.User) ([]model.ModelPricing, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
return s.repo.ModelPricings()
}
func (s *Service) SaveModelPricing(actor *model.User, id string, req ModelPricingRequest) (*model.ModelPricing, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
req.Model = strings.TrimSpace(req.Model)
req.Capability = normalizeCapability(req.Capability)
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
if req.Model == "" || req.Capability == "" {
return nil, BadAuthRequest("请填写模型并选择能力类型")
}
if req.Currency == "" {
req.Currency = "USD"
}
if len(req.Currency) > 12 || hasNegativePricing(req) {
return nil, BadAuthRequest("价格配置格式无效")
}
pricing := &model.ModelPricing{ID: newID(), CreatedAt: time.Now()}
if id != "" {
current, err := s.repo.ModelPricingByID(id)
if err != nil {
return nil, err
}
pricing = current
}
pricing.ChannelID = strings.TrimSpace(req.ChannelID)
pricing.Model = req.Model
pricing.Capability = req.Capability
pricing.Currency = req.Currency
pricing.InputPerMillionMicros = req.InputPerMillionMicros
pricing.OutputPerMillionMicros = req.OutputPerMillionMicros
pricing.CachedPerMillionMicros = req.CachedPerMillionMicros
pricing.PerRequestMicros = req.PerRequestMicros
pricing.PerMediaMicros = req.PerMediaMicros
pricing.PerVideoSecondMicros = req.PerVideoSecondMicros
pricing.UpdatedAt = time.Now()
if err := s.repo.Save(pricing); err != nil {
return nil, err
}
return pricing, nil
}
func (s *Service) DeleteModelPricing(actor *model.User, id string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
return s.repo.DeleteModelPricing(id)
}
func hasNegativePricing(req ModelPricingRequest) bool {
return req.InputPerMillionMicros < 0 || req.OutputPerMillionMicros < 0 || req.CachedPerMillionMicros < 0 || req.PerRequestMicros < 0 || req.PerMediaMicros < 0 || req.PerVideoSecondMicros < 0
}
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)
currency := ""
for _, log := range logs {
durations = append(durations, log.DurationMs)
if log.CostAvailable {
result.KPI.CostAvailable = true
result.KPI.EstimatedCostMicros += log.EstimatedCostMicros
currency = mergeCurrency(currency, log.Currency)
}
}
result.KPI.Currency = currency
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++
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
if log.CostAvailable {
item.row.CostAvailable = true
item.row.EstimatedCostMicros += log.EstimatedCostMicros
item.row.Currency = mergeCurrency(item.row.Currency, log.Currency)
}
}
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 mergeCurrency(current string, next string) string {
if next == "" {
return current
}
if current == "" || current == next {
return next
}
return "MIXED"
}
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
}
func (s *Service) estimateCallCost(log *model.ApiCallLog) {
if log.Status == model.ApiCallStatusFailed && !log.UsageAvailable {
return
}
pricing, err := s.repo.ModelPricing(log.ChannelID, log.Model, log.Capability)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return
}
return
}
cost := int64(0)
if log.Billable {
cost = pricing.PerRequestMicros
}
cost += log.InputTokens * pricing.InputPerMillionMicros / 1_000_000
cost += log.OutputTokens * pricing.OutputPerMillionMicros / 1_000_000
cost += log.CachedTokens * pricing.CachedPerMillionMicros / 1_000_000
cost += int64(log.MediaCount) * pricing.PerMediaMicros
cost += int64(log.VideoSeconds) * pricing.PerVideoSecondMicros
log.EstimatedCostMicros = cost
log.CostAvailable = true
log.Currency = pricing.Currency
}
func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) {
if log == nil {
return
}
if log.ProviderRequestID == "" {
log.ProviderRequestID = providerRequestIDFromPath(log.Path)
}
payloads := providerResponsePayloads(responseBody)
for _, payload := range payloads {
s.enrichAPICallLogPayload(log, payload)
}
s.enrichAPICallLogFailureSummary(log, responseBody)
}
func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, responseBody []byte) {
if log.Status != model.ApiCallStatusFailed || log.StatusCode < 400 {
return
}
userMessage := providerUserFacingErrorMessage(providerHTTPError{
StatusCode: log.StatusCode,
Body: string(responseBody),
})
detail := strings.TrimSpace(log.Error)
if detail == "" || detail == userMessage {
log.Error = userMessage
return
}
if strings.Contains(detail, userMessage) {
return
}
log.Error = truncateRunes(userMessage+";上游:"+detail, 2_000)
}
func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) {
nestedTaskID := ""
if data, ok := payload["data"].(map[string]any); ok {
if log.Capability == "video" && strings.Contains(log.Path, "/v1/video/generations") {
if extracted, err := firstJSONString(data, "task_id", "taskId"); err == nil {
nestedTaskID = extracted
}
}
for key, value := range data {
if _, exists := payload[key]; !exists {
payload[key] = value
}
}
}
// Responses API 的终态 SSE 把实际响应(包括 usage)放在 response 字段中。
if response, ok := payload["response"].(map[string]any); ok {
for key, value := range response {
if _, exists := payload[key]; !exists {
payload[key] = value
}
}
}
if log.Status == model.ApiCallStatusFailed {
errorCode, errorMessage := providerFailureDetails(payload)
log.ErrorCode = errorCode
if errorMessage != "" {
log.Error = errorMessage
}
}
arkVideo := log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks")
usage, _ := payload["usage"].(map[string]any)
if log.Capability == "video" {
log.InputTokens, log.CachedTokens = 0, 0
log.OutputTokens, log.UsageAvailable = videoCompletionTokens(payload, arkVideo)
} else if usage != nil {
inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens")
outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens")
if inputAvailable {
log.InputTokens = inputTokens
}
if outputAvailable {
log.OutputTokens = outputTokens
}
if inputAvailable || outputAvailable {
log.UsageAvailable = true
}
if details, ok := usage["input_tokens_details"].(map[string]any); ok {
log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens")
}
if details, ok := usage["prompt_tokens_details"].(map[string]any); ok && log.CachedTokens == 0 {
log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens")
}
if log.CachedTokens == 0 {
log.CachedTokens = firstInt64(usage, "cached_tokens", "cache_read_input_tokens", "prompt_cache_hit_tokens")
}
}
if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok && log.Capability != "video" {
inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount")
outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount")
if inputAvailable {
log.InputTokens = inputTokens
}
if outputAvailable {
log.OutputTokens = outputTokens
}
if inputAvailable || outputAvailable {
log.UsageAvailable = true
}
log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount")
}
if extracted, err := firstJSONString(payload, "task_id", "id", "request_id", "name"); err == nil {
log.ProviderRequestID = firstNonEmpty(nestedTaskID, extracted, log.ProviderRequestID)
} else {
log.ProviderRequestID = firstNonEmpty(nestedTaskID, log.ProviderRequestID)
}
log.ProviderStatus = strings.ToLower(firstNonEmpty(stringField(payload, "status"), log.ProviderStatus))
if log.ProviderStatus == "failed" || log.ProviderStatus == "cancelled" || log.ProviderStatus == "expired" {
log.Status = model.ApiCallStatusFailed
errorCode, errorMessage := providerFailureDetails(payload)
log.ErrorCode = firstNonEmpty(errorCode, log.ErrorCode)
log.Error = firstNonEmpty(errorMessage, log.Error)
}
if log.Capability == "image" {
if data, ok := payload["data"].([]any); ok {
log.MediaCount = len(data)
} else if images, ok := payload["images"].([]any); ok {
log.MediaCount = len(images)
}
}
}
func videoCompletionTokens(payload map[string]any, arkProtocol bool) (int64, bool) {
if status, exists := payload["status"]; exists {
text, ok := status.(string)
status := strings.ToLower(strings.TrimSpace(text))
if !ok || (status != "succeeded" && (arkProtocol || (status != "completed" && status != "success"))) {
return 0, false
}
}
usage, _ := payload["usage"].(map[string]any)
value, exists := usage["completion_tokens"]
if !exists && !arkProtocol {
value, exists = usage["output_tokens"]
}
if !exists {
value = usage["total_tokens"]
}
// completion_tokens 一旦返回就是唯一结算依据,非法值不能用 total_tokens 掩盖。
// JSON Number 保留整数精度,避免浮点取整把小数或超出 int64 的值变成可计费用量。
number, ok := value.(json.Number)
if !ok {
return 0, false
}
tokens, err := number.Int64()
if err != nil || tokens <= 0 {
return 0, false
}
return tokens, true
}
func providerResponsePayloads(responseBody []byte) []map[string]any {
if len(responseBody) == 0 {
return nil
}
if payload, ok := decodeProviderResponsePayload(responseBody); ok {
return []map[string]any{payload}
}
// 流式文本的用量只出现在最后一个 SSE data 事件中,不能把整段响应当作 JSON。
result := make([]map[string]any, 0)
scanner := bufio.NewScanner(bytes.NewReader(responseBody))
scanner.Buffer(make([]byte, 64<<10), max(len(responseBody)+1, 64<<10))
dataLines := make([]string, 0, 1)
flush := func() {
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
dataLines = dataLines[:0]
if raw == "" || raw == "[DONE]" {
return
}
if event, ok := decodeProviderResponsePayload([]byte(raw)); ok {
result = append(result, event)
}
}
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
flush()
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " "))
}
}
flush()
return result
}
func decodeProviderResponsePayload(data []byte) (map[string]any, bool) {
if !json.Valid(data) {
return nil, false
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var payload map[string]any
err := decoder.Decode(&payload)
return payload, err == nil && payload != nil
}
func providerRequestIDFromPath(path string) string {
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
for index := len(parts) - 1; index >= 0; index-- {
part := strings.TrimSpace(parts[index])
if part == "" || part == "content" || part == "download" {
continue
}
if index > 0 && (parts[index-1] == "videos" || parts[index-1] == "tasks") {
return part
}
break
}
return ""
}
func firstInt64(values map[string]any, keys ...string) int64 {
value, _ := firstInt64Value(values, keys...)
return value
}
func firstInt64Value(values map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
switch value := values[key].(type) {
case float64:
return int64(value), true
case int64:
return value, true
case json.Number:
parsed, err := value.Int64()
return parsed, err == nil
}
}
return 0, false
}
func (s *Service) recordActivity(userID string, event string, count int) {
if err := s.repo.RecordUserActivity(userID, event, count, time.Now()); err != nil {
log.Printf("record user activity failed: event=%s count=%d error=%v", event, count, err)
}
}