feat(usage):用量记录添加统计信息
This commit is contained in:
1 parent
330d02f19d
commit
54ccfb9b80
8 files changed
+330
-146
No files matched your search
@@ -83,6 +83,7 @@ type IAdminV1 interface {
|
||||
AdminDashboardRecentAlerts(ctx context.Context, req *v1.AdminDashboardRecentAlertsReq) (res *v1.AdminDashboardRecentAlertsRes, err error)
|
||||
AdminDashboardModelHourly(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (res *v1.AdminDashboardModelHourlyRes, err error)
|
||||
AdminUsageLogList(ctx context.Context, req *v1.AdminUsageLogListReq) (res *v1.AdminUsageLogListRes, err error)
|
||||
AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error)
|
||||
AdminBillingRecordList(ctx context.Context, req *v1.AdminBillingRecordListReq) (res *v1.AdminBillingRecordListRes, err error)
|
||||
AdminWalletList(ctx context.Context, req *v1.AdminWalletListReq) (res *v1.AdminWalletListRes, err error)
|
||||
AdminWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (res *v1.AdminWalletInfoRes, err error)
|
||||
|
||||
+25
-17
@@ -118,10 +118,8 @@ type AdminDashboardModelHourlyRes struct {
|
||||
|
||||
// === 用量日志 ===
|
||||
|
||||
type AdminUsageLogListReq struct {
|
||||
g.Meta `path:"/usage-logs" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志列表"`
|
||||
Page int `json:"page" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"`
|
||||
// AdminUsageLogFilter 用量日志共用筛选条件(列表 / 统计汇总 / 导出三个接口共用,保证筛选口径一致)
|
||||
type AdminUsageLogFilter struct {
|
||||
ID int64 `json:"id" dc:"用量记录ID"`
|
||||
TenantID int64 `json:"tenant_id" dc:"租户ID"`
|
||||
UserID int64 `json:"user_id" dc:"用户ID"`
|
||||
@@ -135,6 +133,13 @@ type AdminUsageLogListReq struct {
|
||||
EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
|
||||
}
|
||||
|
||||
type AdminUsageLogListReq struct {
|
||||
g.Meta `path:"/usage-logs" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志列表"`
|
||||
Page int `json:"page" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"`
|
||||
AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件
|
||||
}
|
||||
|
||||
type AdminUsageLogItem struct {
|
||||
Id int64 `json:"id"`
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
@@ -203,6 +208,19 @@ type AdminUsageLogListRes struct {
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// AdminUsageLogSummaryReq 用量日志统计请求(复用筛选条件,不包含分页)
|
||||
type AdminUsageLogSummaryReq struct {
|
||||
g.Meta `path:"/usage-logs/summary" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志统计汇总"`
|
||||
AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件
|
||||
}
|
||||
|
||||
// AdminUsageLogSummary 用量日志统计汇总
|
||||
type AdminUsageLogSummaryRes struct {
|
||||
TotalCost float64 `json:"total_cost" dc:"总费用(USD)"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens" dc:"总输出token数"`
|
||||
CacheReadRatio float64 `json:"cache_read_ratio" dc:"缓存读取占比(%)"`
|
||||
}
|
||||
|
||||
// === 计费记录 ===
|
||||
|
||||
type AdminBillingRecordListReq struct {
|
||||
@@ -391,19 +409,9 @@ type AdminWalletSetWarningThresholdRes struct{}
|
||||
|
||||
// AdminUsageLogExportReq 导出用量日志请求
|
||||
type AdminUsageLogExportReq struct {
|
||||
g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"管理后台-用量" summary:"导出用量日志"`
|
||||
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
|
||||
ID int64 `json:"id" in:"query" dc:"用量记录ID"`
|
||||
TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID"`
|
||||
UserID int64 `json:"user_id" in:"query" dc:"用户ID"`
|
||||
Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"`
|
||||
ApiKeyID int64 `json:"api_key_id" in:"query" dc:"API Key ID"`
|
||||
ChannelID int64 `json:"channel_id" in:"query" dc:"渠道ID"`
|
||||
Model string `json:"model" in:"query" dc:"模型名称"`
|
||||
Status string `json:"status" in:"query" dc:"状态"`
|
||||
RequestType int `json:"request_type" in:"query" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"`
|
||||
StartDate string `json:"start_date" in:"query" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
|
||||
EndDate string `json:"end_date" in:"query" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
|
||||
g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"管理后台-用量" summary:"导出用量日志"`
|
||||
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
|
||||
AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件
|
||||
}
|
||||
|
||||
type AdminUsageLogExportRes struct{}
|
||||
|
||||
@@ -70,3 +70,6 @@ func (c *ControllerV1) AdminWalletFrozenRelease(ctx context.Context, req *v1.Adm
|
||||
func (c *ControllerV1) AdminWalletFrozenReleaseAll(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (res *v1.AdminWalletFrozenReleaseAllRes, err error) {
|
||||
return service.Admin().ReleaseAllWalletFrozenItems(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error) {
|
||||
return service.Admin().GetUsageLogSummary(ctx, req)
|
||||
}
|
||||
+127
-118
@@ -349,69 +349,78 @@ func (s *sAdmin) GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardM
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateUsageLogDateRange 校验用量日志筛选的起止时间参数(列表 / 统计 / 导出共用)
|
||||
func validateUsageLogDateRange(start, end string) error {
|
||||
if err := common.ValidateDateTimeParam(start, "开始时间"); err != nil {
|
||||
return err
|
||||
}
|
||||
return common.ValidateDateTimeParam(end, "结束时间")
|
||||
}
|
||||
|
||||
// buildUsageLogFilter 构建用量日志查询的 WHERE 条件与绑定参数(列表 / 统计 / 导出共用,保证筛选口径一致)。
|
||||
// 注意:Username 筛选条件引用 t.username,调用方需按需 JOIN tnt_users(别名 t)。
|
||||
func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
|
||||
var conditions []string
|
||||
|
||||
if f.ID > 0 {
|
||||
conditions = append(conditions, "u.id = ?")
|
||||
args = append(args, f.ID)
|
||||
}
|
||||
if f.TenantID > 0 {
|
||||
conditions = append(conditions, "u.tenant_id = ?")
|
||||
args = append(args, f.TenantID)
|
||||
}
|
||||
if f.UserID > 0 {
|
||||
conditions = append(conditions, "u.user_id = ?")
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
if f.Username != "" {
|
||||
conditions = append(conditions, "t.username LIKE ?")
|
||||
args = append(args, "%"+f.Username+"%")
|
||||
}
|
||||
if f.ApiKeyID > 0 {
|
||||
conditions = append(conditions, "u.api_key_id = ?")
|
||||
args = append(args, f.ApiKeyID)
|
||||
}
|
||||
if f.ChannelID > 0 {
|
||||
conditions = append(conditions, "u.channel_id = ?")
|
||||
args = append(args, f.ChannelID)
|
||||
}
|
||||
if f.Model != "" {
|
||||
conditions = append(conditions, "u.model_name = ?")
|
||||
args = append(args, f.Model)
|
||||
}
|
||||
if f.Status != "" {
|
||||
conditions = append(conditions, "u.status = ?")
|
||||
args = append(args, f.Status)
|
||||
}
|
||||
if f.RequestType > 0 {
|
||||
conditions = append(conditions, "u.request_type = ?")
|
||||
args = append(args, f.RequestType)
|
||||
}
|
||||
if f.StartDate != "" {
|
||||
conditions = append(conditions, "u.created_at >= ?")
|
||||
args = append(args, common.StartOfRange(f.StartDate))
|
||||
}
|
||||
if f.EndDate != "" {
|
||||
conditions = append(conditions, "u.created_at <= ?")
|
||||
args = append(args, common.EndOfRange(f.EndDate))
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
// GetAllUsageLogs 获取所有租户的用量日志(管理后台)
|
||||
func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) {
|
||||
if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil {
|
||||
if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
if req.ID > 0 {
|
||||
conditions = append(conditions, "u.id = ?")
|
||||
args = append(args, req.ID)
|
||||
}
|
||||
if req.TenantID > 0 {
|
||||
conditions = append(conditions, "u.tenant_id = ?")
|
||||
args = append(args, req.TenantID)
|
||||
}
|
||||
if req.UserID > 0 {
|
||||
conditions = append(conditions, "u.user_id = ?")
|
||||
args = append(args, req.UserID)
|
||||
}
|
||||
if req.Username != "" {
|
||||
conditions = append(conditions, "t.username LIKE ?")
|
||||
args = append(args, "%"+req.Username+"%")
|
||||
}
|
||||
if req.ApiKeyID > 0 {
|
||||
conditions = append(conditions, "u.api_key_id = ?")
|
||||
args = append(args, req.ApiKeyID)
|
||||
}
|
||||
if req.ChannelID > 0 {
|
||||
conditions = append(conditions, "u.channel_id = ?")
|
||||
args = append(args, req.ChannelID)
|
||||
}
|
||||
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, common.StartOfRange(req.StartDate))
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
conditions = append(conditions, "u.created_at <= ?")
|
||||
args = append(args, common.EndOfRange(req.EndDate))
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
where, args := buildUsageLogFilter(req.AdminUsageLogFilter)
|
||||
|
||||
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"
|
||||
|
||||
@@ -427,8 +436,8 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
|
||||
|
||||
dataSQL := `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 ` + fromClause + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, pageSize, (page-1)*pageSize)
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...)
|
||||
dataArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, dataArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -450,6 +459,61 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径)
|
||||
func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error) {
|
||||
if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where, args := buildUsageLogFilter(req.AdminUsageLogFilter)
|
||||
|
||||
// 统计聚合不展示名称,仅在按用户名筛选时才 JOIN 用户表,避免大表 SUM 背负无关 JOIN
|
||||
fromClause := "bil_usage_logs u"
|
||||
if req.Username != "" {
|
||||
fromClause += " LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id"
|
||||
}
|
||||
|
||||
summarySQL := `SELECT
|
||||
COALESCE(SUM(u.total_cost), 0) AS total_cost,
|
||||
COALESCE(SUM(u.output_tokens), 0) AS total_output_tokens,
|
||||
COALESCE(SUM(u.input_tokens), 0) AS total_input_tokens,
|
||||
COALESCE(SUM(u.cache_read_tokens), 0) AS total_cache_read
|
||||
FROM ` + fromClause + where
|
||||
|
||||
summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 无 GROUP BY 的聚合必返回一行;空结果兜底为零值
|
||||
if len(summaryResult) == 0 {
|
||||
return &v1.AdminUsageLogSummaryRes{}, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalCacheRead int64 `json:"total_cache_read"`
|
||||
}
|
||||
if err := summaryResult[0].Struct(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算缓存读取占比 = cache_read / input_tokens。
|
||||
// input_tokens 入库口径为「含缓存的总输入」(见 relay/common/usage.go TotalInputTokens),
|
||||
// cache_read 是其子集,分母直接取总和即可,禁止再加 cache_read(会重复计入导致占比偏低)。
|
||||
// 口径与租户个人仪表盘 HitRatio(personal_dashboard.go)保持一致。
|
||||
var cacheReadRatio float64
|
||||
if row.TotalInputTokens > 0 {
|
||||
cacheReadRatio = float64(row.TotalCacheRead) / float64(row.TotalInputTokens) * 100
|
||||
}
|
||||
|
||||
return &v1.AdminUsageLogSummaryRes{
|
||||
TotalCost: row.TotalCost,
|
||||
TotalOutputTokens: row.TotalOutputTokens,
|
||||
CacheReadRatio: cacheReadRatio,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllBillingRecords 获取所有计费记录(管理后台)
|
||||
func (s *sAdmin) GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error) {
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
@@ -1185,10 +1249,7 @@ func (s *sAdmin) GetDashboardRecentAlerts(ctx context.Context, req *v1.AdminDash
|
||||
|
||||
// ExportUsageLogs exports usage logs to CSV or Excel.
|
||||
func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExportReq) (*v1.AdminUsageLogExportRes, error) {
|
||||
if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil {
|
||||
if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1211,59 +1272,7 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor
|
||||
Columns: columns,
|
||||
}
|
||||
|
||||
buildUsageWhere := func() (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
if req.ID > 0 {
|
||||
conditions = append(conditions, "u.id = ?")
|
||||
args = append(args, req.ID)
|
||||
}
|
||||
if req.TenantID > 0 {
|
||||
conditions = append(conditions, "u.tenant_id = ?")
|
||||
args = append(args, req.TenantID)
|
||||
}
|
||||
if req.UserID > 0 {
|
||||
conditions = append(conditions, "u.user_id = ?")
|
||||
args = append(args, req.UserID)
|
||||
}
|
||||
if req.Username != "" {
|
||||
conditions = append(conditions, "t.username LIKE ?")
|
||||
args = append(args, "%"+req.Username+"%")
|
||||
}
|
||||
if req.ApiKeyID > 0 {
|
||||
conditions = append(conditions, "u.api_key_id = ?")
|
||||
args = append(args, req.ApiKeyID)
|
||||
}
|
||||
if req.ChannelID > 0 {
|
||||
conditions = append(conditions, "u.channel_id = ?")
|
||||
args = append(args, req.ChannelID)
|
||||
}
|
||||
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, common.StartOfRange(req.StartDate))
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
conditions = append(conditions, "u.created_at <= ?")
|
||||
args = append(args, common.EndOfRange(req.EndDate))
|
||||
}
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
where, filterArgs := buildUsageLogFilter(req.AdminUsageLogFilter)
|
||||
|
||||
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"
|
||||
@@ -1271,9 +1280,9 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor
|
||||
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
|
||||
offset := 0
|
||||
for {
|
||||
where, args := buildUsageWhere()
|
||||
sql := "SELECT " + selectFields + " FROM " + fromClause + where + " ORDER BY u.created_at DESC LIMIT ? OFFSET ?"
|
||||
batchArgs := append(args, 1000, offset)
|
||||
// 复制一份再追加分页参数,避免污染循环外构建的共用筛选参数
|
||||
batchArgs := append(append([]any{}, filterArgs...), 1000, offset)
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, sql, batchArgs...)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "ExportUsageLogs: query batch at offset %d failed: %v", offset, err)
|
||||
|
||||
@@ -129,6 +129,7 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "GET", path: "/api/admin/billing-records", perm: "billing:view"},
|
||||
{method: "GET", path: "/api/admin/billing-records/export", perm: "billing:export"},
|
||||
{method: "GET", path: "/api/admin/usage-logs", perm: "billing:view"},
|
||||
{method: "GET", path: "/api/admin/usage-logs/summary", perm: "billing:view"},
|
||||
{method: "GET", path: "/api/admin/usage-logs/export", perm: "billing:export"},
|
||||
{method: "GET", path: "/api/admin/transactions", perm: "billing:view"},
|
||||
{method: "GET", path: "/api/admin/wallets", perm: "billing:view"},
|
||||
|
||||
@@ -159,6 +159,8 @@ type (
|
||||
GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (*v1.AdminDashboardModelHourlyRes, error)
|
||||
// GetAllUsageLogs 获取所有租户的用量日志(管理后台)
|
||||
GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error)
|
||||
// GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径)
|
||||
GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error)
|
||||
// GetAllBillingRecords 获取所有计费记录(管理后台)
|
||||
GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error)
|
||||
// GetTenantWallets 获取所有租户钱包(管理后台)
|
||||
|
||||
@@ -155,6 +155,7 @@ type (
|
||||
// ListMemberApiKeys returns a paginated list of API keys belonging to a specific member.
|
||||
ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error)
|
||||
// ExportMembers exports the tenant member list as CSV or Excel.
|
||||
// 导出列与成员列表页表格保持一致(用户/角色/状态/额度限制/可用模型/本月消费/加入时间/最后更新)。
|
||||
ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error)
|
||||
// MemberImport parses CSV content, validates, creates an import record.
|
||||
MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error)
|
||||
|
||||
@@ -13,10 +13,13 @@ import request from '@/utils/request'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { useDateRange } from '@/composables/useDateRange'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange()
|
||||
const isMobileView = useIsMobile()
|
||||
|
||||
const loading = ref(false)
|
||||
const summaryLoading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
@@ -25,6 +28,14 @@ const pagination = reactive({
|
||||
showPageSize: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
})
|
||||
const summary = ref<{
|
||||
total_cost: number
|
||||
total_output_tokens: number
|
||||
cache_read_ratio: number
|
||||
} | null>(null)
|
||||
|
||||
// 用于判断是否需要重新加载统计数据的筛选条件快照
|
||||
let lastSummaryFilters = ''
|
||||
|
||||
const filterId = ref<number | undefined>(undefined)
|
||||
const filterTenantId = ref<number | undefined>(undefined)
|
||||
@@ -167,6 +178,18 @@ function formatTime(s: string): string {
|
||||
return s.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
// 格式化金额(保留2位小数)
|
||||
function formatMoney(amount: number): string {
|
||||
if (amount == null || isNaN(amount)) return '$0.00'
|
||||
return '$' + amount.toFixed(2)
|
||||
}
|
||||
|
||||
// 格式化数字(添加千分位)
|
||||
function formatNumber(num: number): string {
|
||||
if (num == null || isNaN(num)) return '0'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
function hasUpstreamModel(log: any): boolean {
|
||||
return log.upstream_model && log.upstream_model !== log.model_name && log.upstream_model !== ''
|
||||
}
|
||||
@@ -437,9 +460,49 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据(独立接口异步加载)
|
||||
async function fetchSummary() {
|
||||
// 构建筛选条件参数(不包含分页)
|
||||
const params: Record<string, any> = {}
|
||||
if (filterId.value) params.id = filterId.value
|
||||
if (filterTenantId.value) params.tenant_id = filterTenantId.value
|
||||
if (filterUserId.value) params.user_id = filterUserId.value
|
||||
if (filterModel.value) params.model = filterModel.value
|
||||
if (filterApiKeyId.value) params.api_key_id = filterApiKeyId.value
|
||||
if (filterRequestType.value) params.request_type = filterRequestType.value
|
||||
if (filterChannelId.value) params.channel_id = filterChannelId.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterDateRange.value && filterDateRange.value.length === 2) {
|
||||
params.start_date = filterDateRange.value[0]
|
||||
if (filterDateRange.value[1] && filterDateRange.value[1] !== defaultEnd) {
|
||||
params.end_date = filterDateRange.value[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 生成筛选条件的唯一标识
|
||||
const currentFilters = JSON.stringify(params)
|
||||
|
||||
// 如果筛选条件没有变化,不重新加载统计数据
|
||||
if (currentFilters === lastSummaryFilters && summary.value !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
summaryLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/usage-logs/summary', { params })
|
||||
summary.value = res.data?.data || null
|
||||
lastSummaryFilters = currentFilters
|
||||
} catch {
|
||||
summary.value = null
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilter() {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
fetchSummary() // 筛选条件变化时重新加载统计数据
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
@@ -454,6 +517,7 @@ function handleReset() {
|
||||
filterDateRange.value = defaultTodayRange()
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
fetchSummary() // 重置后重新加载统计数据
|
||||
}
|
||||
|
||||
// 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在)
|
||||
@@ -465,6 +529,7 @@ onMounted(() => {
|
||||
fetchTenantOptions()
|
||||
fetchModelOptions()
|
||||
fetchData()
|
||||
fetchSummary() // 初始加载时获取统计数据
|
||||
})
|
||||
|
||||
const { exporting, exportFile } = useExport({
|
||||
@@ -605,17 +670,45 @@ const { exporting, exportFile } = useExport({
|
||||
card-badge-key="status"
|
||||
:card-fields="[{ key: 'username' }, { key: 'channel_name' }, { key: 'tokens', full: true }, { key: 'request_type' }, { key: 'cost' }, { key: 'latency_ms' }, { key: 'created_at' }]"
|
||||
/>
|
||||
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="pagination.pageSizeOptions"
|
||||
show-page-size
|
||||
@change="fetchData"
|
||||
@page-size-change="(size: number) => { pagination.pageSize = size; pagination.current = 1; fetchData() }"
|
||||
/>
|
||||
<div class="table-footer-left">
|
||||
<!-- 统计汇总 -->
|
||||
<a-space v-if="summary" :size="16" wrap>
|
||||
<div class="summary-item summary-cost">
|
||||
<span class="summary-label">总费用:</span>
|
||||
<span class="summary-value">{{ formatMoney(summary.total_cost) }}</span>
|
||||
</div>
|
||||
|
||||
<a-divider direction="vertical" style="height: 16px; margin: 0" />
|
||||
|
||||
<div class="summary-item summary-tokens">
|
||||
<span class="summary-label">总输出Token:</span>
|
||||
<span class="summary-value">{{ formatNumber(summary.total_output_tokens) }}</span>
|
||||
</div>
|
||||
|
||||
<a-divider direction="vertical" style="height: 16px; margin: 0" />
|
||||
|
||||
<div class="summary-item summary-cache">
|
||||
<span class="summary-label">缓存读取占比:</span>
|
||||
<span class="summary-value">{{ (summary.cache_read_ratio || 0).toFixed(2) }}%</span>
|
||||
</div>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="table-footer-right">
|
||||
<TableStats :total="pagination.total" />
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-size-options="pagination.pageSizeOptions"
|
||||
:simple="isMobileView"
|
||||
show-page-size
|
||||
@change="fetchData"
|
||||
@page-size-change="(size: number) => { pagination.pageSize = size; pagination.current = 1; fetchData() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -1401,15 +1494,81 @@ const { exporting, exportFile } = useExport({
|
||||
.text-success { color: #00b42a !important; }
|
||||
.text-warning { color: #ff7d00 !important; }
|
||||
|
||||
/* 统计汇总样式 */
|
||||
.summary-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--color-text-2);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 不同指标使用不同颜色 */
|
||||
.summary-cost .summary-value {
|
||||
color: rgb(var(--primary-6)); /* 蓝色 - 总费用 */
|
||||
}
|
||||
|
||||
.summary-tokens .summary-value {
|
||||
color: rgb(var(--success-6)); /* 绿色 - Token数 */
|
||||
}
|
||||
|
||||
.summary-cache .summary-value {
|
||||
color: rgb(var(--warning-6)); /* 橙色 - 缓存占比 */
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border-light, #e5e6eb);
|
||||
}
|
||||
|
||||
.table-footer-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 移动端:统计栏和分页栏分两行显示 */
|
||||
@media (max-width: 768px) {
|
||||
.table-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-footer-right {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-item .summary-value {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.table-footer :deep(.table-stats) {
|
||||
margin-bottom: 0;
|
||||
|
||||
Reference in new issue
Block a user