304 lines
11 KiB
Go
304 lines
11 KiB
Go
// 管理后台的 API 调用日志:分页、详情、CSV 导出、媒体预览与上游响应摘要补全。
|
||
//
|
||
// 日志只保存脱敏后的请求摘要;上游响应体中的媒体通过资源访问合同读取,不暴露原始地址。
|
||
|
||
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/assets"
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/repository"
|
||
)
|
||
|
||
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.Capability != "audio") || 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
|
||
logs[index].MediaStage = task.MediaStage
|
||
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, options ResourceAccessOptions, rangeHeader string) (*ResourceDelivery, error) {
|
||
userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if options.Purpose == "" {
|
||
options.Purpose = assets.PurposeDisplay
|
||
}
|
||
return s.prepareResourceDelivery(userID, resource, options, rangeHeader)
|
||
}
|
||
|
||
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()
|
||
}
|