Files
team-api/internal/logic/admin/dashboard.go
T

776 lines
25 KiB
Go

package admin
import (
"context"
"fmt"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/utility/export"
)
// GetDashboardStats 获取管理后台仪表盘统计
func (s *sAdmin) GetDashboardStats(ctx context.Context, req *v1.AdminDashboardReq) (*v1.AdminDashboardRes, error) {
today := time.Now().Format("2006-01-02")
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
monthStart := time.Now().Format("2006-01") + "-01"
// 租户数
tenantCount, _ := dao.TntTenants.Ctx(ctx).
Where("status", "active").
Count()
// 成员数
memberCount, _ := dao.TntUsers.Ctx(ctx).Count()
// 活跃渠道
activeChannels, _ := dao.ChnChannels.Ctx(ctx).
Where("status", "active").
Count()
// 今日统计
type dayStatsRow struct {
Requests int `json:"requests"`
ActiveTenants int `json:"active_tenants"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalCost float64 `json:"total_cost"`
SuccessRate float64 `json:"success_rate"`
}
var todayRow dayStatsRow
dao.BilUsageLogs.Ctx(ctx).
Where("created_at >= ?", today+" 00:00:00").
Fields("COUNT(*) as requests, COUNT(DISTINCT tenant_id) as active_tenants, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(total_cost), 0) as total_cost, ROUND(COUNT(CASE WHEN status = 'success' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) as success_rate").
Scan(&todayRow)
// 昨日统计
var yesterdayRow dayStatsRow
dao.BilUsageLogs.Ctx(ctx).
Where("created_at >= ?", yesterday+" 00:00:00").
Where("created_at < ?", today+" 00:00:00").
Fields("COUNT(*) as requests, COUNT(DISTINCT tenant_id) as active_tenants, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(total_cost), 0) as total_cost, ROUND(COUNT(CASE WHEN status = 'success' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) as success_rate").
Scan(&yesterdayRow)
// 本月统计
var monthRow dayStatsRow
dao.BilUsageLogs.Ctx(ctx).
Where("created_at >= ?", monthStart+" 00:00:00").
Fields("COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(total_cost), 0) as total_cost").
Scan(&monthRow)
// 本月收入(已结算金额)
var revenue struct {
Total float64 `json:"total"`
}
dao.BilRecords.Ctx(ctx).
Where("status", "settled").
Where("settled_at >= ?", monthStart+" 00:00:00").
Fields("COALESCE(SUM(total_cost), 0) as total").
Scan(&revenue)
return &v1.AdminDashboardRes{
Tenants: tenantCount,
Members: memberCount,
ActiveChannels: activeChannels,
Today: &v1.DayStats{
Requests: todayRow.Requests,
ActiveTenants: todayRow.ActiveTenants,
InputTokens: todayRow.InputTokens,
OutputTokens: todayRow.OutputTokens,
TotalCost: todayRow.TotalCost,
SuccessRate: todayRow.SuccessRate,
},
Yesterday: &v1.DayStats{
Requests: yesterdayRow.Requests,
ActiveTenants: yesterdayRow.ActiveTenants,
InputTokens: yesterdayRow.InputTokens,
OutputTokens: yesterdayRow.OutputTokens,
TotalCost: yesterdayRow.TotalCost,
SuccessRate: yesterdayRow.SuccessRate,
},
Month: &v1.MonthStats{
Requests: monthRow.Requests,
InputTokens: monthRow.InputTokens,
OutputTokens: monthRow.OutputTokens,
TotalCost: monthRow.TotalCost,
Revenue: revenue.Total,
},
}, nil
}
// GetDashboardTrends returns daily revenue and request trends for the past N days.
func (s *sAdmin) GetDashboardTrends(ctx context.Context, req *v1.AdminDashboardTrendsReq) (*v1.AdminDashboardTrendsRes, error) {
days := req.Days
if days <= 0 || days > 90 {
days = 30
}
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := g.DB().Ctx(ctx).Raw(fmt.Sprintf(`
SELECT
DATE(created_at) as date,
COUNT(*) as requests,
COUNT(DISTINCT tenant_id) as active_tenants,
COALESCE(SUM(total_cost), 0) as revenue
FROM bil_usage_logs
WHERE created_at >= '%s 00:00:00'
GROUP BY DATE(created_at)
ORDER BY date ASC
`, startDate)).All()
if err != nil {
return nil, err
}
records := result.List()
return &v1.AdminDashboardTrendsRes{List: records}, nil
}
// GetTopTenants returns the top 10 tenants by revenue.
func (s *sAdmin) GetTopTenants(ctx context.Context, req *v1.AdminDashboardTopTenantsReq) (*v1.AdminDashboardTopTenantsRes, error) {
days := req.Days
if days <= 0 || days > 90 {
days = 30
}
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := g.DB().Ctx(ctx).Raw(fmt.Sprintf(`
SELECT
t.id as tenant_id,
t.name as tenant_name,
COALESCE(SUM(ul.total_cost), 0) as total_cost,
COUNT(*) as requests,
COUNT(DISTINCT ul.user_id) as active_members
FROM bil_usage_logs ul
JOIN tnt_tenants t ON t.id = ul.tenant_id
WHERE ul.created_at >= '%s 00:00:00'
GROUP BY t.id, t.name
ORDER BY total_cost DESC
LIMIT 10
`, startDate)).All()
if err != nil {
return nil, err
}
records := result.List()
return &v1.AdminDashboardTopTenantsRes{List: records}, nil
}
// GetModelDistribution returns the model usage distribution.
func (s *sAdmin) GetModelDistribution(ctx context.Context, req *v1.AdminDashboardModelDistributionReq) (*v1.AdminDashboardModelDistributionRes, error) {
days := req.Days
if days <= 0 || days > 90 {
days = 30
}
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := g.DB().Ctx(ctx).Raw(fmt.Sprintf(`
SELECT
model_name,
COUNT(*) as requests,
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens,
COALESCE(SUM(total_cost), 0) as total_cost
FROM bil_usage_logs
WHERE created_at >= '%s 00:00:00'
GROUP BY model_name
ORDER BY total_cost DESC
LIMIT 20
`, startDate)).All()
if err != nil {
return nil, err
}
records := result.List()
return &v1.AdminDashboardModelDistributionRes{List: records}, nil
}
// GetAllUsageLogs 获取所有租户的用量日志(管理后台)
func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.TenantID > 0 {
conditions = append(conditions, "u.tenant_id = ?")
args = append(args, req.TenantID)
}
if req.Username != "" {
conditions = append(conditions, "t.username LIKE ?")
args = append(args, "%"+req.Username+"%")
}
if req.Model != "" {
conditions = append(conditions, "u.model_name = ?")
args = append(args, req.Model)
}
if req.Status != "" {
conditions = append(conditions, "u.status = ?")
args = append(args, req.Status)
}
if req.RequestType > 0 {
conditions = append(conditions, "u.request_type = ?")
args = append(args, req.RequestType)
}
if req.StartDate != "" {
conditions = append(conditions, "u.created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "u.created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id"
countSQL := "SELECT COUNT(*) AS total FROM " + fromClause + where
countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...)
if err != nil {
return nil, err
}
total := 0
if len(countResult) > 0 {
total = countResult[0]["total"].Int()
}
dataSQL := fmt.Sprintf(
`SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.created_at
FROM %s%s ORDER BY u.created_at DESC LIMIT %d OFFSET %d`,
fromClause, where, pageSize, (page-1)*pageSize,
)
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...)
if err != nil {
return nil, err
}
logs := make([]*v1.AdminUsageLogItem, 0, len(result))
for _, row := range result {
item := &v1.AdminUsageLogItem{}
if err := row.Struct(item); err != nil {
continue
}
logs = append(logs, item)
}
return &v1.AdminUsageLogListRes{
Total: total,
Page: page,
PageSize: pageSize,
List: logs,
}, nil
}
// GetAllBillingRecords 获取所有计费记录(管理后台)
func (s *sAdmin) GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
query := dao.BilRecords.Ctx(ctx)
if req.TenantID > 0 {
query = query.Where("tenant_id", req.TenantID)
}
var total int
records := make([]*v1.AdminBillingRecordItem, 0)
err := query.OrderDesc("created_at").
Fields("id, tenant_id, user_id, channel_id, model_name, relay_mode, input_tokens, output_tokens, input_price, output_price, total_cost, currency, status, settled_at, created_at").
Page(page, pageSize).
ScanAndCount(&records, &total, false)
if err != nil {
return nil, err
}
// 批量填充名称
if len(records) > 0 {
tenantIds, userIds, channelIds := make([]int64, 0), make([]int64, 0), make([]int64, 0)
for _, r := range records {
tenantIds = append(tenantIds, r.TenantId)
userIds = append(userIds, r.UserId)
channelIds = append(channelIds, r.ChannelId)
}
tenantNames := make(map[int64]string)
tenantNameEntities := make([]struct {
Id int64 `orm:"id"`
Name string `orm:"name"`
}, 0)
if err := dao.TntTenants.Ctx(ctx).Fields("id, name").WhereIn("id", tenantIds).Scan(&tenantNameEntities); err == nil {
for _, e := range tenantNameEntities {
tenantNames[e.Id] = e.Name
}
}
userNames := make(map[int64]string)
userNameEntities := make([]struct {
Id int64 `orm:"id"`
DisplayName string `orm:"display_name"`
}, 0)
if err := dao.TntUsers.Ctx(ctx).Fields("id, display_name").WhereIn("id", userIds).Scan(&userNameEntities); err == nil {
for _, e := range userNameEntities {
userNames[e.Id] = e.DisplayName
}
}
channelNames := make(map[int64]string)
channelNameEntities := make([]struct {
Id int64 `orm:"id"`
Name string `orm:"name"`
}, 0)
if err := dao.ChnChannels.Ctx(ctx).Fields("id, name").WhereIn("id", channelIds).Scan(&channelNameEntities); err == nil {
for _, e := range channelNameEntities {
channelNames[e.Id] = e.Name
}
}
for _, r := range records {
r.TenantName = tenantNames[r.TenantId]
r.UserName = userNames[r.UserId]
r.ChannelName = channelNames[r.ChannelId]
}
}
return &v1.AdminBillingRecordListRes{
Total: total,
Page: page,
PageSize: pageSize,
List: records,
}, nil
}
// GetTenantWallets 获取所有租户钱包(管理后台)
func (s *sAdmin) GetTenantWallets(ctx context.Context, req *v1.AdminWalletListReq) (*v1.AdminWalletListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
query := dao.BilWallets.Ctx(ctx)
var total int
wallets := make([]*v1.AdminWalletItem, 0)
err := query.OrderDesc("updated_at").
Fields("id, tenant_id, balance, frozen_balance, warning_threshold, currency, created_at, updated_at").
Page(page, pageSize).
ScanAndCount(&wallets, &total, false)
if err != nil {
return nil, err
}
return &v1.AdminWalletListRes{
Total: total,
Page: page,
PageSize: pageSize,
List: wallets,
}, nil
}
// AdjustBalance 调整租户余额(管理后台)
func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error) {
tenantID := req.TenantID
amount := req.Amount
description := req.Description
type walletRow struct {
ID int64 `json:"id"`
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
var w *walletRow
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", tenantID).
Fields("id, balance, frozen_balance").
Scan(&w)
if err != nil || w == nil {
return nil, gerror.Newf("wallet not found for tenant %d", tenantID)
}
// 检查扣减后余额是否为负
if amount < 0 && w.Balance+amount < 0 {
return nil, gerror.Newf("余额不足,当前余额 %.2f,无法扣减 %.2f", w.Balance, -amount)
}
_, err = dao.BilWallets.Ctx(ctx).
Where("id", w.ID).
Data(do.BilWallets{
Balance: w.Balance + amount,
}).Update()
if err != nil {
return nil, err
}
// 记录流水(含变动后余额)
balanceAfter := w.Balance + amount
dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{
TenantId: tenantID,
WalletId: w.ID,
Type: "adjust",
Amount: amount,
BalanceAfter: balanceAfter,
FrozenAfter: w.FrozenBalance,
Description: description,
})
return &v1.AdminWalletAdjustRes{}, nil
}
// GetWalletInfo 获取租户钱包信息(管理后台)
func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) {
type walletRow struct {
ID int64 `json:"id"`
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
WarningThreshold *float64 `json:"warning_threshold"`
}
var w *walletRow
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", req.TenantID).
Fields("id, balance, frozen_balance, warning_threshold").
Scan(&w)
if err != nil || w == nil {
return nil, gerror.Newf("wallet not found for tenant %d", req.TenantID)
}
return &v1.AdminWalletInfoRes{
Balance: w.Balance,
FrozenBalance: w.FrozenBalance,
WarningThreshold: w.WarningThreshold,
}, nil
}
// GetWalletTransactions 获取租户钱包交易流水(管理后台)
func (s *sAdmin) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
// 获取钱包 ID
var w *struct {
ID int64 `json:"id"`
}
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", req.TenantID).
Fields("id").
Scan(&w)
if err != nil || w == nil {
return nil, gerror.Newf("wallet not found for tenant %d", req.TenantID)
}
query := dao.BilTransactions.Ctx(ctx).Where("wallet_id", w.ID)
if req.Type != "" {
query = query.Where("type", req.Type)
}
var total int
records := make([]*v1.AdminWalletTransactionItem, 0)
err = query.OrderDesc("created_at").
Fields("id, type, amount, balance_after, frozen_after, description, user_id, request_id, model_name, created_at").
Page(page, pageSize).
ScanAndCount(&records, &total, false)
if err != nil {
return nil, err
}
return &v1.AdminWalletTransactionListRes{
List: records,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// SetWarningThreshold 设置租户钱包预警阈值(管理后台)
func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error) {
type walletRow struct {
ID int64 `json:"id"`
}
var w *walletRow
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", req.TenantID).
Fields("id").
Scan(&w)
if err != nil || w == nil {
return nil, gerror.Newf("wallet not found for tenant %d", req.TenantID)
}
_, err = dao.BilWallets.Ctx(ctx).
Where("id", w.ID).
Data(do.BilWallets{
WarningThreshold: req.Threshold,
}).Update()
return &v1.AdminWalletSetWarningThresholdRes{}, err
}
// GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道)
func (s *sAdmin) GetDashboardChannelHealth(ctx context.Context, req *v1.AdminDashboardChannelHealthReq) (*v1.AdminDashboardChannelHealthRes, error) {
var items []v1.ChannelHealthItem
err := g.DB().Ctx(ctx).Raw(`
SELECT
c.id as channel_id,
c.name as channel_name,
c.status,
h.health_score,
h.success_rate,
CAST(h.latency_ms AS INTEGER) as latency_ms
FROM chn_health_scores h
JOIN chn_channels c ON c.id = h.channel_id
WHERE c.status IN ('active', 'testing')
ORDER BY h.health_score ASC
LIMIT 5
`).Scan(&items)
if err != nil {
return nil, err
}
return &v1.AdminDashboardChannelHealthRes{List: items}, nil
}
// GetDashboardRecentAlerts 获取最近5条告警
func (s *sAdmin) GetDashboardRecentAlerts(ctx context.Context, req *v1.AdminDashboardRecentAlertsReq) (*v1.AdminDashboardRecentAlertsRes, error) {
var alerts []v1.RecentAlertItem
err := g.DB().Ctx(ctx).Model("ops_alert_events").
Fields("id, rule_name, level, status, trigger_message, created_at").
OrderDesc("created_at").
Limit(5).
Scan(&alerts)
if err != nil {
return nil, err
}
return &v1.AdminDashboardRecentAlertsRes{List: alerts}, nil
}
// ExportUsageLogs exports usage logs to CSV or Excel.
func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExportReq) (*v1.AdminUsageLogExportRes, error) {
columns := []export.Column{
{Field: "id", Header: "ID"},
{Field: "tenant_name", Header: "租户名称"},
{Field: "username", Header: "用户名"},
{Field: "model_name", Header: "模型"},
{Field: "request_type", Header: "请求类型"},
{Field: "input_tokens", Header: "输入Token"},
{Field: "output_tokens", Header: "输出Token"},
{Field: "total_cost", Header: "费用"},
{Field: "status", Header: "状态"},
{Field: "created_at", Header: "创建时间"},
}
config := export.Config{
Format: req.Format,
Filename: "用量日志_" + gtime.Now().Format("Ymd_His"),
Columns: columns,
}
buildUsageWhere := func() (string, []any) {
var conditions []string
var args []any
if req.TenantID > 0 {
conditions = append(conditions, "u.tenant_id = ?")
args = append(args, req.TenantID)
}
if req.Username != "" {
conditions = append(conditions, "t.username LIKE ?")
args = append(args, "%"+req.Username+"%")
}
if req.Model != "" {
conditions = append(conditions, "u.model_name = ?")
args = append(args, req.Model)
}
if req.Status != "" {
conditions = append(conditions, "u.status = ?")
args = append(args, req.Status)
}
if req.RequestType > 0 {
conditions = append(conditions, "u.request_type = ?")
args = append(args, req.RequestType)
}
if req.StartDate != "" {
conditions = append(conditions, "u.created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "u.created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
return where, args
}
fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id"
selectFields := "u.id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, u.model_name, u.request_type, u.input_tokens, u.output_tokens, u.total_cost, u.status, u.created_at"
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
for {
where, args := buildUsageWhere()
sql := fmt.Sprintf("SELECT %s FROM %s%s ORDER BY u.created_at DESC LIMIT 1000 OFFSET %d", selectFields, fromClause, where, offset)
result, err := g.DB().Ctx(ctx).Query(ctx, sql, args...)
if err != nil {
return
}
for _, row := range result {
createdAt := ""
if t, ok := row["created_at"]; ok {
createdAt = fmt.Sprintf("%v", t.Val())
}
if !yield(map[string]any{
"id": row["id"].Val(),
"tenant_name": row["tenant_name"].Val(),
"username": row["username"].Val(),
"model_name": row["model_name"].Val(),
"request_type": row["request_type"].Val(),
"input_tokens": row["input_tokens"].Val(),
"output_tokens": row["output_tokens"].Val(),
"total_cost": row["total_cost"].Val(),
"status": row["status"].Val(),
"created_at": createdAt,
}) {
return
}
}
if len(result) < 1000 {
break
}
offset += 1000
}
})
}
// ExportBillingRecords exports billing records to CSV or Excel.
func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingRecordExportReq) (*v1.AdminBillingRecordExportRes, error) {
columns := []export.Column{
{Field: "id", Header: "ID"},
{Field: "tenant_name", Header: "租户名称"},
{Field: "user_name", Header: "用户名"},
{Field: "channel_name", Header: "渠道名称"},
{Field: "model_name", Header: "模型"},
{Field: "input_tokens", Header: "输入Token"},
{Field: "output_tokens", Header: "输出Token"},
{Field: "total_cost", Header: "费用"},
{Field: "status", Header: "状态"},
{Field: "created_at", Header: "创建时间"},
}
config := export.Config{
Format: req.Format,
Filename: "计费记录_" + gtime.Now().Format("Ymd_His"),
Columns: columns,
}
fetchRecords := func(offset, limit int) ([]map[string]any, error) {
query := dao.BilRecords.Ctx(ctx)
if req.TenantID > 0 {
query = query.Where("tenant_id", req.TenantID)
}
var records []struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
UserId int64 `json:"user_id"`
ChannelId int64 `json:"channel_id"`
ModelName string `json:"model_name"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalCost float64 `json:"total_cost"`
Status string `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
}
if err := query.Fields("id, tenant_id, user_id, channel_id, model_name, input_tokens, output_tokens, total_cost, status, created_at").
OrderDesc("created_at").Limit(limit).Offset(offset).Scan(&records); err != nil {
return nil, err
}
// Batch resolve names
tenantIds, userIds, channelIds := make([]int64, 0), make([]int64, 0), make([]int64, 0)
for _, r := range records {
tenantIds = append(tenantIds, r.TenantId)
userIds = append(userIds, r.UserId)
channelIds = append(channelIds, r.ChannelId)
}
tenantNames := make(map[int64]string)
if len(tenantIds) > 0 {
var entities []struct {
Id int64 `orm:"id"`
Name string `orm:"name"`
}
if err := dao.TntTenants.Ctx(ctx).Fields("id, name").WhereIn("id", tenantIds).Scan(&entities); err == nil {
for _, e := range entities {
tenantNames[e.Id] = e.Name
}
}
}
userNames := make(map[int64]string)
if len(userIds) > 0 {
var entities []struct {
Id int64 `orm:"id"`
DisplayName string `orm:"display_name"`
}
if err := dao.TntUsers.Ctx(ctx).Fields("id, display_name").WhereIn("id", userIds).Scan(&entities); err == nil {
for _, e := range entities {
userNames[e.Id] = e.DisplayName
}
}
}
channelNames := make(map[int64]string)
if len(channelIds) > 0 {
var entities []struct {
Id int64 `orm:"id"`
Name string `orm:"name"`
}
if err := dao.ChnChannels.Ctx(ctx).Fields("id, name").WhereIn("id", channelIds).Scan(&entities); err == nil {
for _, e := range entities {
channelNames[e.Id] = e.Name
}
}
}
data := make([]map[string]any, len(records))
for i, r := range records {
data[i] = map[string]any{
"id": r.Id,
"tenant_name": tenantNames[r.TenantId],
"user_name": userNames[r.UserId],
"channel_name": channelNames[r.ChannelId],
"model_name": r.ModelName,
"input_tokens": r.InputTokens,
"output_tokens": r.OutputTokens,
"total_cost": r.TotalCost,
"status": r.Status,
"created_at": r.CreatedAt.String(),
}
}
return data, nil
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
for {
batch, err := fetchRecords(offset, 1000)
if err != nil {
return
}
for _, row := range batch {
if !yield(row) {
return
}
}
if len(batch) < 1000 {
break
}
offset += 1000
}
})
}