1216 lines
40 KiB
Go
1216 lines
40 KiB
Go
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"`
|
||
Finance AnalyticsFinance `json:"finance"`
|
||
}
|
||
|
||
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"`
|
||
Finance AnalyticsFinance `json:"finance"`
|
||
}
|
||
|
||
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)
|
||
records, err := s.analyticsFinancialRecords(logs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
applyAnalyticsFinance(result, records)
|
||
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.ChannelID == logs[index].ChannelID {
|
||
logs[index].CreditCostConfigured = order.CostPricing.Configured
|
||
cost, costErr := billingCreditCost(order)
|
||
if costErr != nil {
|
||
return costErr
|
||
}
|
||
logs[index].CreditCostMicrocredits = cost
|
||
}
|
||
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 := ""
|
||
creditCost := ""
|
||
if log.CreditCostMicrocredits != nil {
|
||
creditCost = strconv.FormatInt(*log.CreditCostMicrocredits, 10)
|
||
}
|
||
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, creditCost, 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
|
||
}
|
||
records, err := s.analyticsFinancialRecords(logs)
|
||
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 {
|
||
revenue, cost, profit := "", "", ""
|
||
if record, exists := records[log.ID]; exists {
|
||
revenue = strconv.FormatInt(record.Revenue, 10)
|
||
if record.Cost != nil {
|
||
cost = strconv.FormatInt(*record.Cost, 10)
|
||
profit = strconv.FormatInt(record.Revenue-*record.Cost, 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), revenue, cost, profit, 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)
|
||
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++
|
||
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
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|