fix(usage): 用户使用记录不下发账号计费倍率
- 将 usage log DTO 拆分为用户/管理员两类 - 用户接口不返回 account_rate_multiplier/ip_address/account - 管理员接口保留管理员字段 - 补充契约测试防止回归
This commit is contained in:
@@ -163,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]dto.UsageLog, 0, len(records))
|
out := make([]dto.AdminUsageLog, 0, len(records))
|
||||||
for i := range records {
|
for i := range records {
|
||||||
out = append(out, *dto.UsageLogFromServiceAdmin(&records[i]))
|
out = append(out, *dto.UsageLogFromServiceAdmin(&records[i]))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,14 +302,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageLogFromServiceBase is a helper that converts service UsageLog to DTO.
|
func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||||
// The account parameter allows caller to control what Account info is included.
|
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
|
||||||
// The includeIPAddress parameter controls whether to include the IP address (admin-only).
|
return UsageLog{
|
||||||
func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, includeIPAddress bool) *UsageLog {
|
|
||||||
if l == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := &UsageLog{
|
|
||||||
ID: l.ID,
|
ID: l.ID,
|
||||||
UserID: l.UserID,
|
UserID: l.UserID,
|
||||||
APIKeyID: l.APIKeyID,
|
APIKeyID: l.APIKeyID,
|
||||||
@@ -331,7 +326,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
|
|||||||
TotalCost: l.TotalCost,
|
TotalCost: l.TotalCost,
|
||||||
ActualCost: l.ActualCost,
|
ActualCost: l.ActualCost,
|
||||||
RateMultiplier: l.RateMultiplier,
|
RateMultiplier: l.RateMultiplier,
|
||||||
AccountRateMultiplier: l.AccountRateMultiplier,
|
|
||||||
BillingType: l.BillingType,
|
BillingType: l.BillingType,
|
||||||
Stream: l.Stream,
|
Stream: l.Stream,
|
||||||
DurationMs: l.DurationMs,
|
DurationMs: l.DurationMs,
|
||||||
@@ -342,30 +336,33 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
|
|||||||
CreatedAt: l.CreatedAt,
|
CreatedAt: l.CreatedAt,
|
||||||
User: UserFromServiceShallow(l.User),
|
User: UserFromServiceShallow(l.User),
|
||||||
APIKey: APIKeyFromService(l.APIKey),
|
APIKey: APIKeyFromService(l.APIKey),
|
||||||
Account: account,
|
|
||||||
Group: GroupFromServiceShallow(l.Group),
|
Group: GroupFromServiceShallow(l.Group),
|
||||||
Subscription: UserSubscriptionFromService(l.Subscription),
|
Subscription: UserSubscriptionFromService(l.Subscription),
|
||||||
}
|
}
|
||||||
// IP 地址仅对管理员可见
|
|
||||||
if includeIPAddress {
|
|
||||||
result.IPAddress = l.IPAddress
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageLogFromService converts a service UsageLog to DTO for regular users.
|
// UsageLogFromService converts a service UsageLog to DTO for regular users.
|
||||||
// It excludes Account details and IP address - users should not see these.
|
// It excludes Account details and IP address - users should not see these.
|
||||||
func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||||
return usageLogFromServiceBase(l, nil, false)
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u := usageLogFromServiceUser(l)
|
||||||
|
return &u
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
|
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
|
||||||
// It includes minimal Account info (ID, Name only) and IP address.
|
// It includes minimal Account info (ID, Name only) and IP address.
|
||||||
func UsageLogFromServiceAdmin(l *service.UsageLog) *UsageLog {
|
func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account), true)
|
return &AdminUsageLog{
|
||||||
|
UsageLog: usageLogFromServiceUser(l),
|
||||||
|
AccountRateMultiplier: l.AccountRateMultiplier,
|
||||||
|
IPAddress: l.IPAddress,
|
||||||
|
Account: AccountSummaryFromService(l.Account),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTask {
|
func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTask {
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ type RedeemCode struct {
|
|||||||
Group *Group `json:"group,omitempty"`
|
Group *Group `json:"group,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。
|
||||||
type UsageLog struct {
|
type UsageLog struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
@@ -209,14 +210,13 @@ type UsageLog struct {
|
|||||||
CacheCreation5mTokens int `json:"cache_creation_5m_tokens"`
|
CacheCreation5mTokens int `json:"cache_creation_5m_tokens"`
|
||||||
CacheCreation1hTokens int `json:"cache_creation_1h_tokens"`
|
CacheCreation1hTokens int `json:"cache_creation_1h_tokens"`
|
||||||
|
|
||||||
InputCost float64 `json:"input_cost"`
|
InputCost float64 `json:"input_cost"`
|
||||||
OutputCost float64 `json:"output_cost"`
|
OutputCost float64 `json:"output_cost"`
|
||||||
CacheCreationCost float64 `json:"cache_creation_cost"`
|
CacheCreationCost float64 `json:"cache_creation_cost"`
|
||||||
CacheReadCost float64 `json:"cache_read_cost"`
|
CacheReadCost float64 `json:"cache_read_cost"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
ActualCost float64 `json:"actual_cost"`
|
ActualCost float64 `json:"actual_cost"`
|
||||||
RateMultiplier float64 `json:"rate_multiplier"`
|
RateMultiplier float64 `json:"rate_multiplier"`
|
||||||
AccountRateMultiplier *float64 `json:"account_rate_multiplier"`
|
|
||||||
|
|
||||||
BillingType int8 `json:"billing_type"`
|
BillingType int8 `json:"billing_type"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
@@ -230,18 +230,28 @@ type UsageLog struct {
|
|||||||
// User-Agent
|
// User-Agent
|
||||||
UserAgent *string `json:"user_agent"`
|
UserAgent *string `json:"user_agent"`
|
||||||
|
|
||||||
// IP 地址(仅管理员可见)
|
|
||||||
IPAddress *string `json:"ip_address,omitempty"`
|
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
User *User `json:"user,omitempty"`
|
User *User `json:"user,omitempty"`
|
||||||
APIKey *APIKey `json:"api_key,omitempty"`
|
APIKey *APIKey `json:"api_key,omitempty"`
|
||||||
Account *AccountSummary `json:"account,omitempty"` // Use minimal AccountSummary to prevent data leakage
|
|
||||||
Group *Group `json:"group,omitempty"`
|
Group *Group `json:"group,omitempty"`
|
||||||
Subscription *UserSubscription `json:"subscription,omitempty"`
|
Subscription *UserSubscription `json:"subscription,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminUsageLog 是管理员接口使用的 usage log DTO(包含管理员字段)。
|
||||||
|
type AdminUsageLog struct {
|
||||||
|
UsageLog
|
||||||
|
|
||||||
|
// AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理)
|
||||||
|
AccountRateMultiplier *float64 `json:"account_rate_multiplier"`
|
||||||
|
|
||||||
|
// IPAddress 用户请求 IP(仅管理员可见)
|
||||||
|
IPAddress *string `json:"ip_address,omitempty"`
|
||||||
|
|
||||||
|
// Account 最小账号信息(避免泄露敏感字段)
|
||||||
|
Account *AccountSummary `json:"account,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type UsageCleanupFilters struct {
|
type UsageCleanupFilters struct {
|
||||||
StartTime time.Time `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
EndTime time.Time `json:"end_time"`
|
EndTime time.Time `json:"end_time"`
|
||||||
|
|||||||
@@ -190,24 +190,25 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
deps.usageRepo.SetUserLogs(1, []service.UsageLog{
|
deps.usageRepo.SetUserLogs(1, []service.UsageLog{
|
||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
APIKeyID: 100,
|
APIKeyID: 100,
|
||||||
AccountID: 200,
|
AccountID: 200,
|
||||||
RequestID: "req_123",
|
AccountRateMultiplier: ptr(0.5),
|
||||||
Model: "claude-3",
|
RequestID: "req_123",
|
||||||
InputTokens: 10,
|
Model: "claude-3",
|
||||||
OutputTokens: 20,
|
InputTokens: 10,
|
||||||
CacheCreationTokens: 1,
|
OutputTokens: 20,
|
||||||
CacheReadTokens: 2,
|
CacheCreationTokens: 1,
|
||||||
TotalCost: 0.5,
|
CacheReadTokens: 2,
|
||||||
ActualCost: 0.5,
|
TotalCost: 0.5,
|
||||||
RateMultiplier: 1,
|
ActualCost: 0.5,
|
||||||
BillingType: service.BillingTypeBalance,
|
RateMultiplier: 1,
|
||||||
Stream: true,
|
BillingType: service.BillingTypeBalance,
|
||||||
DurationMs: ptr(100),
|
Stream: true,
|
||||||
FirstTokenMs: ptr(50),
|
DurationMs: ptr(100),
|
||||||
CreatedAt: deps.now,
|
FirstTokenMs: ptr(50),
|
||||||
|
CreatedAt: deps.now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -238,10 +239,9 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"output_cost": 0,
|
"output_cost": 0,
|
||||||
"cache_creation_cost": 0,
|
"cache_creation_cost": 0,
|
||||||
"cache_read_cost": 0,
|
"cache_read_cost": 0,
|
||||||
"total_cost": 0.5,
|
"total_cost": 0.5,
|
||||||
"actual_cost": 0.5,
|
"actual_cost": 0.5,
|
||||||
"rate_multiplier": 1,
|
"rate_multiplier": 1,
|
||||||
"account_rate_multiplier": null,
|
|
||||||
"billing_type": 0,
|
"billing_type": 0,
|
||||||
"stream": true,
|
"stream": true,
|
||||||
"duration_ms": 100,
|
"duration_ms": 100,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
// ==================== Types ====================
|
// ==================== Types ====================
|
||||||
|
|
||||||
@@ -85,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
|||||||
export async function list(
|
export async function list(
|
||||||
params: AdminUsageQueryParams,
|
params: AdminUsageQueryParams,
|
||||||
options?: { signal?: AbortSignal }
|
options?: { signal?: AbortSignal }
|
||||||
): Promise<PaginatedResponse<UsageLog>> {
|
): Promise<PaginatedResponse<AdminUsageLog>> {
|
||||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
|
const { data } = await apiClient.get<PaginatedResponse<AdminUsageLog>>('/admin/usage', {
|
||||||
params,
|
params,
|
||||||
signal: options?.signal
|
signal: options?.signal
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
|
|||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { UsageLog } from '@/types'
|
import type { AdminUsageLog } from '@/types'
|
||||||
|
|
||||||
defineProps(['data', 'loading'])
|
defineProps(['data', 'loading'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -247,12 +247,12 @@ const { t } = useI18n()
|
|||||||
// Tooltip state - cost
|
// Tooltip state - cost
|
||||||
const tooltipVisible = ref(false)
|
const tooltipVisible = ref(false)
|
||||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tooltipData = ref<UsageLog | null>(null)
|
const tooltipData = ref<AdminUsageLog | null>(null)
|
||||||
|
|
||||||
// Tooltip state - token
|
// Tooltip state - token
|
||||||
const tokenTooltipVisible = ref(false)
|
const tokenTooltipVisible = ref(false)
|
||||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tokenTooltipData = ref<UsageLog | null>(null)
|
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||||
|
|
||||||
const cols = computed(() => [
|
const cols = computed(() => [
|
||||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||||
@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cost tooltip functions
|
// Cost tooltip functions
|
||||||
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
||||||
const target = event.currentTarget as HTMLElement
|
const target = event.currentTarget as HTMLElement
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect()
|
||||||
tooltipData.value = row
|
tooltipData.value = row
|
||||||
@@ -311,7 +311,7 @@ const hideTooltip = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Token tooltip functions
|
// Token tooltip functions
|
||||||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
||||||
const target = event.currentTarget as HTMLElement
|
const target = event.currentTarget as HTMLElement
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect()
|
||||||
tokenTooltipData.value = row
|
tokenTooltipData.value = row
|
||||||
|
|||||||
@@ -636,7 +636,6 @@ export interface UsageLog {
|
|||||||
total_cost: number
|
total_cost: number
|
||||||
actual_cost: number
|
actual_cost: number
|
||||||
rate_multiplier: number
|
rate_multiplier: number
|
||||||
account_rate_multiplier?: number | null
|
|
||||||
billing_type: number
|
billing_type: number
|
||||||
|
|
||||||
stream: boolean
|
stream: boolean
|
||||||
@@ -650,18 +649,30 @@ export interface UsageLog {
|
|||||||
// User-Agent
|
// User-Agent
|
||||||
user_agent: string | null
|
user_agent: string | null
|
||||||
|
|
||||||
// IP 地址(仅管理员可见)
|
|
||||||
ip_address: string | null
|
|
||||||
|
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|
||||||
user?: User
|
user?: User
|
||||||
api_key?: ApiKey
|
api_key?: ApiKey
|
||||||
account?: Account
|
|
||||||
group?: Group
|
group?: Group
|
||||||
subscription?: UserSubscription
|
subscription?: UserSubscription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UsageLogAccountSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUsageLog extends UsageLog {
|
||||||
|
// 账号计费倍率(仅管理员可见)
|
||||||
|
account_rate_multiplier?: number | null
|
||||||
|
|
||||||
|
// 用户请求 IP(仅管理员可见)
|
||||||
|
ip_address?: string | null
|
||||||
|
|
||||||
|
// 最小账号信息(仅管理员接口返回)
|
||||||
|
account?: UsageLogAccountSummary
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageCleanupFilters {
|
export interface UsageCleanupFilters {
|
||||||
start_time: string
|
start_time: string
|
||||||
end_time: string
|
end_time: string
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo
|
|||||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<UsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
||||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||||
@@ -92,7 +92,7 @@ const exportToExcel = async () => {
|
|||||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||||
const c = new AbortController(); exportAbortController = c
|
const c = new AbortController(); exportAbortController = c
|
||||||
try {
|
try {
|
||||||
const all: UsageLog[] = []; let p = 1; let total = pagination.total
|
const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total
|
||||||
while (true) {
|
while (true) {
|
||||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
||||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||||
|
|||||||
Reference in New Issue
Block a user