feat(admin): 管理后台 - 完善配置运营页面并修复导航、筛选与积分计费
This commit is contained in:
@@ -93,6 +93,9 @@ type ApiCallLog struct {
|
||||
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
|
||||
TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"`
|
||||
BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"`
|
||||
BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"`
|
||||
BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"`
|
||||
BillingAvailable bool `json:"billingAvailable" gorm:"-"`
|
||||
Source string `json:"source" gorm:"index;size:64"`
|
||||
Capability string `json:"capability" gorm:"index;size:32"`
|
||||
Operation string `json:"operation" gorm:"size:64"`
|
||||
|
||||
@@ -477,6 +477,21 @@ func (r *Repository) BillingOrder(id string) (*model.BillingOrder, error) {
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (r *Repository) BillingOrdersByIDs(ids []string) (map[string]model.BillingOrder, error) {
|
||||
result := make(map[string]model.BillingOrder, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var orders []model.BillingOrder
|
||||
if err := r.db.Where("id IN ?", ids).Find(&orders).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, order := range orders {
|
||||
result[order.ID] = order
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) BillingOrdersByTaskIDs(userID string, taskIDs []string) (map[string]model.BillingOrder, error) {
|
||||
result := make(map[string]model.BillingOrder, len(taskIDs))
|
||||
if len(taskIDs) == 0 {
|
||||
|
||||
@@ -230,9 +230,17 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
|
||||
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.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
|
||||
}
|
||||
@@ -250,6 +258,10 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
|
||||
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
|
||||
@@ -265,6 +277,15 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
|
||||
logs[index].UserDisplayName = user.DisplayName
|
||||
logs[index].UserAccount = user.Username
|
||||
}
|
||||
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)
|
||||
@@ -373,13 +394,22 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery)
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString("\xEF\xBB\xBF")
|
||||
writer := csv.NewWriter(&buffer)
|
||||
_ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "计费", "币种", "错误码", "错误"})
|
||||
_ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "积分计费(微积分)", "积分计费状态", "上游估算费用(微单位)", "币种", "错误码", "错误"})
|
||||
for _, log := range logs {
|
||||
startedAt := log.StartedAt
|
||||
if startedAt.IsZero() {
|
||||
startedAt = log.CreatedAt
|
||||
}
|
||||
_ = 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), strconv.FormatInt(log.EstimatedCostMicros, 10), log.Currency, log.ErrorCode, log.Error})
|
||||
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 {
|
||||
|
||||
66
backend/internal/service/api_log_billing_test.go
Normal file
66
backend/internal/service/api_log_billing_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDecorateAPICallLogsUsesBillingOrderSnapshot(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.ModelChannel{}, &model.BillingOrder{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.User{ID: "user-1", Username: "creator", DisplayName: "创作者"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orders := []model.BillingOrder{
|
||||
{ID: "settled", UserID: "user-1", IdempotencyKey: "settled", Status: model.BillingStatusSettled, ReservedAmountMicrocredits: 1_000_000, ActualAmountMicrocredits: 780_000},
|
||||
{ID: "reserved", UserID: "user-1", IdempotencyKey: "reserved", Status: model.BillingStatusReserved, ReservedAmountMicrocredits: 900_000},
|
||||
{ID: "uncertain", UserID: "user-1", IdempotencyKey: "uncertain", Status: model.BillingStatusUncertain, ReservedAmountMicrocredits: 1_200_000},
|
||||
{ID: "refunded", UserID: "user-1", IdempotencyKey: "refunded", Status: model.BillingStatusRefunded, ReservedAmountMicrocredits: 650_000, RefundedAmountMicrocredits: 650_000},
|
||||
{ID: "other-user", UserID: "user-2", IdempotencyKey: "other-user", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 500_000},
|
||||
}
|
||||
if err := db.Create(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
logs := []model.ApiCallLog{
|
||||
{ID: "log-settled", UserID: "user-1", BillingOrderID: "settled", Capability: "text"},
|
||||
{ID: "log-reserved", UserID: "user-1", BillingOrderID: "reserved", Capability: "text"},
|
||||
{ID: "log-uncertain", UserID: "user-1", BillingOrderID: "uncertain", Capability: "text"},
|
||||
{ID: "log-refunded", UserID: "user-1", BillingOrderID: "refunded", Capability: "text"},
|
||||
{ID: "log-missing", UserID: "user-1", BillingOrderID: "missing", Capability: "text"},
|
||||
{ID: "log-other-user", UserID: "user-1", BillingOrderID: "other-user", Capability: "text"},
|
||||
}
|
||||
|
||||
svc := &Service{repo: repository.New(db)}
|
||||
if err := svc.decorateAPICallLogs(logs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertBillingSnapshot(t, logs[0], true, model.BillingStatusSettled, 780_000)
|
||||
assertBillingSnapshot(t, logs[1], true, model.BillingStatusReserved, 900_000)
|
||||
assertBillingSnapshot(t, logs[2], true, model.BillingStatusUncertain, 1_200_000)
|
||||
assertBillingSnapshot(t, logs[3], true, model.BillingStatusRefunded, 0)
|
||||
assertBillingSnapshot(t, logs[4], false, "", 0)
|
||||
assertBillingSnapshot(t, logs[5], false, "", 0)
|
||||
}
|
||||
|
||||
func assertBillingSnapshot(t *testing.T, log model.ApiCallLog, available bool, status model.BillingStatus, amount int64) {
|
||||
t.Helper()
|
||||
if log.BillingAvailable != available || log.BillingStatus != status || log.BillingAmount != amount {
|
||||
t.Fatalf("billing snapshot for %s = {available:%v status:%q amount:%d}, want {available:%v status:%q amount:%d}", log.ID, log.BillingAvailable, log.BillingStatus, log.BillingAmount, available, status, amount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user