From 84d364d76a30910d0a4b3980c2d831112479f214 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 25 Jun 2026 11:19:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E8=AF=A6=E6=83=85=E5=B1=95=E5=BC=80=E4=B8=8E?= =?UTF-8?q?=20completion=5Ftokens=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端新增 UsageLogExpandedDetail 点击展开面板和 BillingDetailSummary hover 计费详情,后端修复 per_1m_tokens 任务结算日志的 completion_tokens 从零值改为写入 total_tokens 实际值。新增 taskBillingLogMerge 合并 任务预扣/结算日志。 Co-Authored-By: Claude --- controller/relay.go | 6 +- model/log.go | 44 +-- relay/relay_task.go | 22 +- relay/relay_task_test.go | 19 ++ service/task_billing.go | 84 ++++- service/task_billing_test.go | 105 +++++- .../table/task-logs/modals/ContentModal.jsx | 26 +- .../table/usage-logs/BillingDetailSummary.jsx | 207 ++++++++++++ .../usage-logs/BillingDetailSummary.test.jsx | 109 +++++++ .../usage-logs/BillingDetailSummaryData.js | 142 +++++++++ .../usage-logs/UsageLogExpandedDetail.jsx | 80 +++++ .../table/usage-logs/UsageLogsColumnDefs.jsx | 116 +++++-- .../table/usage-logs/UsageLogsTable.jsx | 9 +- web/src/helpers/utils.jsx | 28 +- web/src/helpers/utils.test.js | 64 +++- .../hooks/usage-logs/taskBillingLogMerge.js | 168 ++++++++++ .../usage-logs/taskBillingLogMerge.test.js | 143 +++++++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 301 +++++++++++++++++- 18 files changed, 1585 insertions(+), 88 deletions(-) create mode 100644 web/src/components/table/usage-logs/BillingDetailSummary.jsx create mode 100644 web/src/components/table/usage-logs/BillingDetailSummary.test.jsx create mode 100644 web/src/components/table/usage-logs/BillingDetailSummaryData.js create mode 100644 web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx create mode 100644 web/src/hooks/usage-logs/taskBillingLogMerge.js create mode 100644 web/src/hooks/usage-logs/taskBillingLogMerge.test.js diff --git a/controller/relay.go b/controller/relay.go index 55fd96f..48734d2 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -607,7 +607,11 @@ func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingCont bc.BillingUnit = decision.BillingUnit bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD bc.PerCallBilling = decision.PerCallBilling - bc.ModelPrice = decision.PriceUSD + if decision.BillingUnit == types.BillingUnitPer1MTokens { + bc.ModelPrice = decision.TokenUnitPriceUSD + } else { + bc.ModelPrice = decision.PriceUSD + } bc.GroupRatio = decision.GroupRatioInfo.GroupRatio bc.OtherRatios = types.CloneRatios(decision.OtherRatios) } diff --git a/model/log.go b/model/log.go index 416f26f..3dd6d1b 100644 --- a/model/log.go +++ b/model/log.go @@ -211,15 +211,16 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } type RecordTaskBillingLogParams struct { - UserId int - LogType int - Content string - ChannelId int - ModelName string - Quota int - TokenId int - Group string - Other map[string]interface{} + UserId int + LogType int + Content string + ChannelId int + ModelName string + Quota int + TokenId int + Group string + CompletionTokens int + Other map[string]interface{} } func RecordTaskBillingLog(params RecordTaskBillingLogParams) { @@ -234,18 +235,19 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { } } log := &Log{ - UserId: params.UserId, - Username: username, - CreatedAt: common.GetTimestamp(), - Type: params.LogType, - Content: params.Content, - TokenName: tokenName, - ModelName: params.ModelName, - Quota: params.Quota, - ChannelId: params.ChannelId, - TokenId: params.TokenId, - Group: params.Group, - Other: common.MapToJsonStr(params.Other), + UserId: params.UserId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: params.LogType, + Content: params.Content, + TokenName: tokenName, + ModelName: params.ModelName, + Quota: params.Quota, + ChannelId: params.ChannelId, + TokenId: params.TokenId, + Group: params.Group, + CompletionTokens: params.CompletionTokens, + Other: common.MapToJsonStr(params.Other), } err := LOG_DB.Create(log).Error if err != nil { diff --git a/relay/relay_task.go b/relay/relay_task.go index 4d7c41e..dded3e3 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -521,6 +521,26 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto { Progress: task.Progress, Properties: task.Properties, Username: task.Username, - Data: task.Data, + Data: sanitizeTaskDtoData(task), } } + +func sanitizeTaskDtoData(task *model.Task) []byte { + if task == nil || len(task.Data) == 0 { + return nil + } + payload := map[string]any{} + if err := common.Unmarshal(task.Data, &payload); err != nil { + return task.Data + } + if _, ok := payload["aiping_id"]; !ok { + return task.Data + } + delete(payload, "aiping_id") + payload["id"] = task.TaskID + data, err := common.Marshal(payload) + if err != nil { + return task.Data + } + return data +} diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index c62bf4c..8fb84b0 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -200,6 +200,25 @@ func TestTaskModel2DtoDoesNotExposeUpstreamRequestSnapshot(t *testing.T) { require.NotContains(t, string(data), "hidden") } +func TestTaskModel2DtoSanitizesAipingInternalIDFromData(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Data: []byte(`{ + "id":"cgt-upstream", + "aiping_id":"b7ee658f-acac-433d-897d-5c9ed55ef402", + "status":"succeeded" + }`), + } + + dtoTask := TaskModel2Dto(task) + data, err := common.Marshal(dtoTask) + + require.NoError(t, err) + require.NotContains(t, string(data), "aiping_id") + require.NotContains(t, string(data), "b7ee658f-acac-433d-897d-5c9ed55ef402") + require.Contains(t, string(data), `"id":"task_public"`) +} + func newRelayTaskRemixContext(t *testing.T, originTaskID string) *gin.Context { t.Helper() gin.SetMode(gin.TestMode) diff --git a/service/task_billing.go b/service/task_billing.go index 87eef0b..18fc066 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "strings" @@ -11,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -39,6 +41,21 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { other["request_path"] = c.Request.URL.Path other["model_price"] = info.PriceData.ModelPrice other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio + other["billing_phase"] = "preconsume" + other["pre_consumed_quota"] = info.PriceData.Quota + if info.TaskRelayInfo != nil && info.TaskRelayInfo.PublicTaskID != "" { + other["task_id"] = info.TaskRelayInfo.PublicTaskID + } + if decision := info.PricingDecisionFrozen; decision != nil && decision.BillingMode == types.BillingModeMatrix { + other["billing_mode"] = decision.BillingMode + other["billing_unit"] = decision.BillingUnit + if decision.TokenUnitPriceUSD > 0 { + other["token_unit_price_usd"] = decision.TokenUnitPriceUSD + } + if decision.Snapshot != nil { + other["pricing_snapshot"] = types.CloneMapAny(decision.Snapshot) + } + } if info.PriceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio } @@ -146,6 +163,35 @@ func taskModelName(task *model.Task) string { return task.Properties.OriginModelName } +func taskCompletionTokens(task *model.Task) int { + if bc := task.PrivateData.BillingContext; bc != nil && bc.PricingSnapshot != nil { + if tt, ok := bc.PricingSnapshot["total_tokens"]; ok { + switch v := tt.(type) { + case float64: + return int(v) + case int: + return v + case json.Number: + if n, err := v.Int64(); err == nil { + return int(n) + } + } + } + } + return 0 +} + +func taskSettlementOther(task *model.Task, reason string, preConsumedQuota, actualQuota, quotaDelta int) map[string]interface{} { + other := taskBillingOther(task) + other["task_id"] = task.TaskID + other["billing_phase"] = "settlement" + other["reason"] = reason + other["pre_consumed_quota"] = preConsumedQuota + other["actual_quota"] = actualQuota + other["quota_delta"] = quotaDelta + return other +} + // RefundTaskQuota 统一的任务失败退款逻辑。 // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。 func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { @@ -166,7 +212,9 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { // 3. 记录日志 other := taskBillingOther(task) other["task_id"] = task.TaskID + other["billing_phase"] = "refund" other["reason"] = reason + other["pre_consumed_quota"] = quota model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ UserId: task.UserId, LogType: model.LogTypeRefund, @@ -193,6 +241,18 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int if quotaDelta == 0 { logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)", task.TaskID, logger.LogQuota(actualQuota), reason)) + model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ + UserId: task.UserId, + LogType: model.LogTypeConsume, + Content: "", + ChannelId: task.ChannelId, + ModelName: taskModelName(task), + Quota: 0, + TokenId: task.PrivateData.TokenId, + Group: task.Group, + CompletionTokens: taskCompletionTokens(task), + Other: taskSettlementOther(task, reason, preConsumedQuota, actualQuota, quotaDelta), + }) return } @@ -226,21 +286,17 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int logType = model.LogTypeRefund logQuota = -quotaDelta } - other := taskBillingOther(task) - other["task_id"] = task.TaskID - other["reason"] = reason - other["pre_consumed_quota"] = preConsumedQuota - other["actual_quota"] = actualQuota model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ - UserId: task.UserId, - LogType: logType, - Content: "", - ChannelId: task.ChannelId, - ModelName: taskModelName(task), - Quota: logQuota, - TokenId: task.PrivateData.TokenId, - Group: task.Group, - Other: other, + UserId: task.UserId, + LogType: logType, + Content: "", + ChannelId: task.ChannelId, + ModelName: taskModelName(task), + Quota: logQuota, + TokenId: task.PrivateData.TokenId, + Group: task.Group, + CompletionTokens: taskCompletionTokens(task), + Other: taskSettlementOther(task, reason, preConsumedQuota, actualQuota, quotaDelta), }) } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 1145bba..7cb48d4 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "os" "testing" "time" @@ -11,6 +12,8 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -125,8 +128,8 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc SubscriptionId: subscriptionId, TokenId: tokenId, BillingContext: &model.TaskBillingContext{ - ModelPrice: 0.02, - GroupRatio: 1.0, + ModelPrice: 0.02, + GroupRatio: 1.0, OriginModelName: "test-model", }, }, @@ -175,6 +178,14 @@ func getLastLog(t *testing.T) *model.Log { return &log } +func getLogOtherMap(t *testing.T, log *model.Log) map[string]any { + t.Helper() + require.NotNil(t, log) + other := map[string]any{} + require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) + return other +} + func countLogs(t *testing.T) int64 { t.Helper() var count int64 @@ -217,6 +228,61 @@ func TestRefundTaskQuota_Wallet(t *testing.T) { assert.Equal(t, "test-model", log.ModelName) } +func TestLogTaskConsumption_MatrixPreconsumeIncludesTaskBillingMetadata(t *testing.T) { + truncate(t) + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil) + ctx.Set("token_name", "task-token") + + const userID, channelID = 40, 40 + seedUser(t, userID, 10000) + seedChannel(t, channelID) + + info := &relaycommon.RelayInfo{ + UserId: userID, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelId: channelID, + }, + TokenId: 400, + UsingGroup: "default", + OriginModelName: "matrix-model", + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "submit", PublicTaskID: "task_public_123"}, + PriceData: types.PriceData{ + ModelPrice: 46, + Quota: 1, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 1, + }, + }, + PricingDecisionFrozen: &types.PricingDecision{ + BillingMode: types.BillingModeMatrix, + BillingUnit: types.BillingUnitPer1MTokens, + TokenUnitPriceUSD: 46, + Snapshot: map[string]any{ + "billing_unit": types.BillingUnitPer1MTokens, + "resolution": "1080p", + }, + }, + } + + LogTaskConsumption(ctx, info) + + log := getLastLog(t) + require.NotNil(t, log) + assert.Equal(t, model.LogTypeConsume, log.Type) + assert.Equal(t, 1, log.Quota) + + other := getLogOtherMap(t, log) + assert.Equal(t, "preconsume", other["billing_phase"]) + assert.Equal(t, "task_public_123", other["task_id"]) + assert.Equal(t, float64(1), other["pre_consumed_quota"]) + assert.Equal(t, types.BillingModeMatrix, other["billing_mode"]) + assert.Equal(t, types.BillingUnitPer1MTokens, other["billing_unit"]) + assert.Equal(t, float64(46), other["token_unit_price_usd"]) + require.IsType(t, map[string]any{}, other["pricing_snapshot"]) +} + func TestRefundTaskQuota_Subscription(t *testing.T) { truncate(t) ctx := context.Background() @@ -244,6 +310,11 @@ func TestRefundTaskQuota_Subscription(t *testing.T) { log := getLastLog(t) require.NotNil(t, log) assert.Equal(t, model.LogTypeRefund, log.Type) + + other := getLogOtherMap(t, log) + assert.Equal(t, "refund", other["billing_phase"]) + assert.Equal(t, task.TaskID, other["task_id"]) + assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"]) } func TestRefundTaskQuota_ZeroQuota(t *testing.T) { @@ -322,6 +393,13 @@ func TestRecalculate_PositiveDelta(t *testing.T) { require.NotNil(t, log) assert.Equal(t, model.LogTypeConsume, log.Type) assert.Equal(t, actualQuota-preConsumed, log.Quota) + + other := getLogOtherMap(t, log) + assert.Equal(t, "settlement", other["billing_phase"]) + assert.Equal(t, task.TaskID, other["task_id"]) + assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"]) + assert.Equal(t, float64(actualQuota), other["actual_quota"]) + assert.Equal(t, float64(actualQuota-preConsumed), other["quota_delta"]) } func TestRecalculate_NegativeDelta(t *testing.T) { @@ -363,18 +441,29 @@ func TestRecalculate_ZeroDelta(t *testing.T) { const userID = 12 const initQuota, preConsumed = 10000, 3000 + const totalTokens = 48400 seedUser(t, userID, initQuota) task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0) + task.PrivateData.BillingContext.PricingSnapshot = map[string]any{ + "total_tokens": float64(totalTokens), + } RecalculateTaskQuota(ctx, task, preConsumed, "exact match") // No change to user quota assert.Equal(t, initQuota, getUserQuota(t, userID)) - // No log created (delta is zero) - assert.Equal(t, int64(0), countLogs(t)) + log := getLastLog(t) + require.NotNil(t, log) + assert.Equal(t, model.LogTypeConsume, log.Type) + assert.Equal(t, 0, log.Quota) + assert.Equal(t, totalTokens, log.CompletionTokens) + + other := getLogOtherMap(t, log) + assert.Equal(t, "settlement", other["billing_phase"]) + assert.Equal(t, float64(0), other["quota_delta"]) } func TestRecalculate_ActualQuotaZero(t *testing.T) { @@ -615,9 +704,11 @@ type mockAdaptor struct { adjustReturn int } -func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} -func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { return nil, nil } -func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil } +func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} +func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return nil, nil +} +func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil } func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int { return m.adjustReturn } diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index 3527fd9..01c3731 100644 --- a/web/src/components/table/task-logs/modals/ContentModal.jsx +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -162,7 +162,7 @@ const ContentModal = ({ height: isVideo ? '70vh' : '400px', maxHeight: '80vh', overflow: 'auto', - padding: isVideo && videoError ? '0' : '24px', + padding: isVideo && videoError ? '0' : '18px 20px 20px', }} width={isVideo ? '90vw' : 800} style={isVideo ? { maxWidth: 960 } : undefined} @@ -170,7 +170,29 @@ const ContentModal = ({ {isVideo ? ( renderVideoContent() ) : ( -

{modalContent}

+
+
+            {modalContent}
+          
+
)} ); diff --git a/web/src/components/table/usage-logs/BillingDetailSummary.jsx b/web/src/components/table/usage-logs/BillingDetailSummary.jsx new file mode 100644 index 0000000..4099cad --- /dev/null +++ b/web/src/components/table/usage-logs/BillingDetailSummary.jsx @@ -0,0 +1,207 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Popover, Space, Tag, Typography } from '@douyinfe/semi-ui'; +import { getCurrencyConfig, renderQuota } from '../../../helpers'; +import { + buildMatrixBillingDetail, + formatBillingRatio, +} from './BillingDetailSummaryData'; + +const DetailRow = ({ label, value }) => { + if (value === undefined || value === null || value === '') return null; + return ( +
+ {label} + + {value} + +
+ ); +}; + +const PopoverSection = ({ title, children }) => ( +
+ {title} +
{children}
+
+); + +const BillingDetailSummary = ({ other, record, t }) => { + const detail = buildMatrixBillingDetail({ + other, + t, + currencyConfig: getCurrencyConfig(), + }); + + if (!detail) return null; + const actualQuotaText = + record?.quota !== undefined ? renderQuota(record.quota, 6) : ''; + const preConsumedQuotaText = + other?.pre_consumed_quota !== undefined + ? renderQuota(other.pre_consumed_quota, 6) + : ''; + const settledQuotaText = + other?.actual_quota !== undefined ? renderQuota(other.actual_quota, 6) : ''; + + const popoverContent = ( +
+ + + {detail.title} + + {detail.isPerTokens ? ( + + {t('按完成 tokens 重算')} + + ) : ( + + {t('固定按次')} + + )} + {detail.pricingMode !== '-' && ( + + {detail.pricingMode} + + )} + {detail.usesLegacyPriceField && ( + + {t('旧日志字段')} + + )} + + +
+ {t('价格计算')} + + + + + +
+ + {detail.dimensions.length > 0 && ( +
+ {t('命中维度')} + {detail.dimensions.map((item) => ( + + ))} +
+ )} + +
+ {t('结算信息')} + + + + + +
+
+ ); + + return ( + +
+ + + {detail.isPerTokens ? t('每 1M tokens') : t('按次')} + + {detail.pricingMode !== '-' && ( + + {detail.pricingMode} + + )} + +
+ {detail.unitPriceText} × {formatBillingRatio(detail.groupRatio)} +
+
+ {detail.usageText} → {detail.costText} +
+ {detail.dimensionText && ( +
+ {detail.dimensionText} +
+ )} +
+
+ ); +}; + +export default BillingDetailSummary; diff --git a/web/src/components/table/usage-logs/BillingDetailSummary.test.jsx b/web/src/components/table/usage-logs/BillingDetailSummary.test.jsx new file mode 100644 index 0000000..f8eaf80 --- /dev/null +++ b/web/src/components/table/usage-logs/BillingDetailSummary.test.jsx @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { buildMatrixBillingDetail } from './BillingDetailSummaryData.js'; + +const t = (key) => + ({ + 矩阵计费: '矩阵计费', + '每 1M tokens': '每 1M tokens', + 按次: '按次', + 次: '次', + 单次计费: '单次计费', + '等待实际 tokens': '等待实际 tokens', + 等待完成后重算: '等待完成后重算', + 任意: '任意', + })[key] || key; + +const cny = { symbol: '¥', rate: 7, type: 'CNY' }; + +describe('BillingDetailSummary matrix billing detail', () => { + it('summarizes per_1m_tokens matrix usage with dimensions and token cost', () => { + const detail = buildMatrixBillingDetail({ + t, + currencyConfig: cny, + other: { + billing_mode: 'matrix', + billing_unit: 'per_1m_tokens', + token_unit_price_usd: 37 / 7, + group_ratio: 1, + pricing_snapshot: { + billing_unit: 'per_1m_tokens', + pricing_mode: 'matched', + price: 37 / 7, + resolution: '1080P', + duration: 10, + total_tokens: 2_000_000, + }, + }, + }); + + expect(detail).toMatchObject({ + isPerTokens: true, + title: '矩阵计费 · 每 1M tokens', + unitPriceText: '¥37.000000 / 1M tokens', + finalUnitPriceText: '¥37.000000 / 1M tokens', + usageText: '2,000,000 tokens', + costText: '¥74.000000', + dimensionText: 'resolution=1080P · duration=10', + pricingMode: 'matched', + }); + }); + + it('summarizes per_call matrix billing as fixed per-call cost', () => { + const detail = buildMatrixBillingDetail({ + t, + currencyConfig: cny, + other: { + billing_mode: 'matrix', + billing_unit: 'per_call', + model_price: 2, + group_ratio: 1.5, + pricing_snapshot: { + billing_unit: 'per_call', + pricing_mode: 'matched', + price: 2, + size: 'large', + }, + }, + }); + + expect(detail).toMatchObject({ + isPerTokens: false, + title: '矩阵计费 · 按次', + unitPriceText: '¥14.000000 / 次', + finalUnitPriceText: '¥21.000000 / 次', + usageText: '单次计费', + costText: '¥21.000000', + dimensionText: 'size=large', + }); + }); + + it('marks old per-token logs that only have model_price', () => { + const detail = buildMatrixBillingDetail({ + t, + currencyConfig: cny, + other: { + billing_mode: 'matrix', + billing_unit: 'per_1m_tokens', + model_price: 46, + group_ratio: 1, + pricing_snapshot: { + billing_unit: 'per_1m_tokens', + pricing_mode: 'fallback_max', + fallback_strategy: 'max', + price: 46, + resolution: '*', + }, + }, + }); + + expect(detail).toMatchObject({ + usesLegacyPriceField: true, + unitPriceText: '¥322.000000 / 1M tokens', + usageText: '等待实际 tokens', + costText: '等待完成后重算', + dimensionText: 'resolution=任意', + pricingMode: 'fallback_max', + fallbackStrategy: 'max', + }); + }); +}); diff --git a/web/src/components/table/usage-logs/BillingDetailSummaryData.js b/web/src/components/table/usage-logs/BillingDetailSummaryData.js new file mode 100644 index 0000000..7d84cf1 --- /dev/null +++ b/web/src/components/table/usage-logs/BillingDetailSummaryData.js @@ -0,0 +1,142 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +const SNAPSHOT_META_KEYS = new Set([ + 'price', + 'billing_unit', + 'pricing_mode', + 'fallback_strategy', + 'total_tokens', +]); + +export const formatBillingRatio = (ratio) => { + if (ratio === undefined || ratio === null) return '-'; + const value = Number(ratio); + if (!Number.isFinite(value)) return String(ratio); + return value.toFixed(4); +}; + +const formatCurrency = (usdAmount, currencyConfig, digits = 6) => { + const value = Number(usdAmount || 0); + const rate = Number(currencyConfig?.rate || 1); + const symbol = currencyConfig?.symbol || '$'; + const displayValue = value * (Number.isFinite(rate) && rate > 0 ? rate : 1); + return `${symbol}${displayValue.toFixed(digits)}`; +}; + +const formatTokenCount = (value) => { + const tokens = Number(value || 0); + if (!Number.isFinite(tokens) || tokens <= 0) return ''; + return Math.round(tokens).toLocaleString(); +}; + +const getMatrixDimensions = (snapshot, t = (key) => key) => + Object.entries(snapshot || {}) + .filter(([key]) => !SNAPSHOT_META_KEYS.has(key)) + .map(([key, value]) => ({ + key, + value: value === '*' ? t('任意') : String(value), + })); + +const getBillingUnitLabel = (billingUnit, t = (key) => key) => { + if (billingUnit === 'per_1m_tokens') return '1M tokens'; + if (billingUnit === 'per_call') return t('次'); + return billingUnit || '-'; +}; + +const getMatrixUnitPrice = (other, billingUnit) => { + if (billingUnit === 'per_1m_tokens') { + if (other?.token_unit_price_usd !== undefined) { + return { + value: Number(other.token_unit_price_usd), + source: 'token_unit_price_usd', + }; + } + return { value: Number(other?.model_price), source: 'model_price' }; + } + return { value: Number(other?.model_price), source: 'model_price' }; +}; + +export const buildMatrixBillingDetail = ({ + other, + t = (key) => key, + currencyConfig = { symbol: '$', rate: 1 }, +}) => { + if (!other || other.billing_mode !== 'matrix') return null; + + const snapshot = other.pricing_snapshot || {}; + const billingUnit = other.billing_unit || snapshot.billing_unit || 'per_call'; + const { value: unitPriceUSD, source } = getMatrixUnitPrice( + other, + billingUnit, + ); + if (!Number.isFinite(unitPriceUSD) || unitPriceUSD <= 0) return null; + + const groupRatio = Number(other.group_ratio ?? 1); + const effectiveGroupRatio = Number.isFinite(groupRatio) ? groupRatio : 1; + const finalUnitPriceUSD = unitPriceUSD * effectiveGroupRatio; + const unitLabel = getBillingUnitLabel(billingUnit, t); + const dimensions = getMatrixDimensions(snapshot, t); + const dimensionText = dimensions + .map((item) => `${item.key}=${item.value}`) + .join(' · '); + const totalTokens = snapshot.total_tokens ?? other.total_tokens; + const tokenText = formatTokenCount(totalTokens); + const pricingMode = snapshot.pricing_mode || '-'; + const fallbackStrategy = snapshot.fallback_strategy || ''; + const isPerTokens = billingUnit === 'per_1m_tokens'; + const estimatedCostUSD = + isPerTokens && tokenText + ? (finalUnitPriceUSD * Number(totalTokens || 0)) / 1_000_000 + : finalUnitPriceUSD; + + const title = isPerTokens + ? `${t('矩阵计费')} · ${t('每 1M tokens')}` + : `${t('矩阵计费')} · ${t('按次')}`; + const unitPriceText = `${formatCurrency(unitPriceUSD, currencyConfig)} / ${unitLabel}`; + const finalUnitPriceText = `${formatCurrency(finalUnitPriceUSD, currencyConfig)} / ${unitLabel}`; + const usageText = isPerTokens + ? tokenText + ? `${tokenText} tokens` + : t('等待实际 tokens') + : t('单次计费'); + const costText = + isPerTokens && !tokenText + ? t('等待完成后重算') + : formatCurrency(estimatedCostUSD, currencyConfig); + + return { + billingUnit, + isPerTokens, + title, + unitLabel, + source, + usesLegacyPriceField: isPerTokens && source === 'model_price', + groupRatio: effectiveGroupRatio, + unitPriceText, + finalUnitPriceText, + usageText, + costText, + tokenText, + dimensionText, + dimensions, + pricingMode, + fallbackStrategy, + }; +}; diff --git a/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx b/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx new file mode 100644 index 0000000..df31ab1 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogExpandedDetail.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Descriptions, Typography } from '@douyinfe/semi-ui'; + +const sectionStyle = { + border: '1px solid var(--semi-color-border)', + borderRadius: 6, + background: 'var(--semi-color-bg-1)', + overflow: 'hidden', +}; + +const headerStyle = { + padding: '9px 12px', + borderBottom: '1px solid var(--semi-color-border)', + background: 'var(--semi-color-fill-0)', +}; + +const bodyStyle = { + padding: '10px 12px 12px', +}; + +const gridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', + gap: '10px 16px', +}; + +const itemStyle = { + minWidth: 0, +}; + +const valueStyle = { + marginTop: 3, + lineHeight: 1.55, + wordBreak: 'break-word', +}; + +const DetailItem = ({ item }) => ( +
+ + {item.key} + +
{item.value}
+
+); + +const DetailSection = ({ section }) => { + if (!section?.items?.length) return null; + return ( +
+
+ {section.title} +
+
+
+ {section.items.map((item, index) => ( + + ))} +
+
+
+ ); +}; + +const UsageLogExpandedDetail = ({ detail }) => { + if (detail?.sections?.length) { + return ( +
+
+ {detail.sections.map((section) => ( + + ))} +
+
+ ); + } + + return ; +}; + +export default UsageLogExpandedDetail; diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index a7273e9..a46376c 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -25,7 +25,7 @@ import { Tooltip, Popover, Typography, - Button + Button, } from '@douyinfe/semi-ui'; import { timestamp2string, @@ -43,6 +43,7 @@ import { } from '../../../helpers'; import { IconHelpCircle } from '@douyinfe/semi-icons'; import { Route, Sparkles } from 'lucide-react'; +import BillingDetailSummary from './BillingDetailSummary'; const colors = [ 'amber', @@ -101,7 +102,53 @@ function buildChannelAffinityTooltip(affinity, t) { } // Render functions -function renderType(type, t) { +function renderType(type, t, record = null) { + const other = record ? getLogOther(record.other) : null; + if (other?.billing_mode === 'matrix') { + if (other?.task_billing_status === 'pending') { + return ( + + {t('待结算')} + + ); + } + if (other?.task_billing_status === 'settled') { + return ( + + {t('已结算')} + + ); + } + if (other?.task_billing_status === 'refunded') { + return ( + + {t('已退还')} + + ); + } + if (other?.billing_phase === 'preconsume') { + return ( + + {t('预扣')} + + ); + } + if (other?.billing_phase === 'settlement') { + return ( + + {type === 6 ? t('退还') : t('补扣')} + + ); + } + if (other?.billing_phase === 'refund') { + return ( + + {t('退还')} + + ); + } + } + switch (type) { case 1: return ( @@ -374,7 +421,10 @@ export const getLogsColumns = ({ } return isAdminUser && - (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? ( + (record.type === 0 || + record.type === 2 || + record.type === 5 || + record.type === 6) ? ( @@ -465,7 +515,10 @@ export const getLogsColumns = ({ title: t('令牌'), dataIndex: 'token_name', render: (text, record, index) => { - return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( + return record.type === 0 || + record.type === 2 || + record.type === 5 || + record.type === 6 ? (
{ - return <>{renderType(text, t)}; + return <>{renderType(text, t, record)}; }, }, { @@ -530,7 +583,10 @@ export const getLogsColumns = ({ title: t('模型'), dataIndex: 'model_name', render: (text, record, index) => { - return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( + return record.type === 0 || + record.type === 2 || + record.type === 5 || + record.type === 6 ? ( <>{renderModelName(record, copyText, t)} ) : ( <> @@ -597,7 +653,10 @@ export const getLogsColumns = ({ cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`; } - return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( + return record.type === 0 || + record.type === 2 || + record.type === 5 || + record.type === 6 ? (
{ return parseInt(text) > 0 && - (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? ( + (record.type === 0 || + record.type === 2 || + record.type === 5 || + record.type === 6) ? ( <>{ {text} } ) : ( <> @@ -643,7 +705,14 @@ export const getLogsColumns = ({ title: t('花费'), dataIndex: 'quota', render: (text, record, index) => { - if (!(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6)) { + if ( + !( + record.type === 0 || + record.type === 2 || + record.type === 5 || + record.type === 6 + ) + ) { return <>; } const other = getLogOther(record.other); @@ -710,9 +779,9 @@ export const getLogsColumns = ({ } if (other.admin_info !== undefined) { if ( - other.admin_info.use_channel !== null && - other.admin_info.use_channel !== undefined && - other.admin_info.use_channel !== '' + other.admin_info.use_channel !== null && + other.admin_info.use_channel !== undefined && + other.admin_info.use_channel !== '' ) { let useChannel = other.admin_info.use_channel; let useChannelStr = useChannel.join('->'); @@ -788,6 +857,13 @@ export const getLogsColumns = ({ ); } + const matrixPrice = Number( + other?.token_unit_price_usd ?? other?.model_price ?? 0, + ); + if (other?.billing_mode === 'matrix' && matrixPrice > 0) { + return ; + } + let content = other?.claude ? renderModelPriceSimple( other.model_ratio, @@ -830,14 +906,14 @@ export const getLogsColumns = ({ 'openai', ); return ( - - {content} - + + {content} + ); }, }, diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx index 103dc93..c4ab794 100644 --- a/web/src/components/table/usage-logs/UsageLogsTable.jsx +++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx @@ -18,13 +18,14 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useMemo } from 'react'; -import { Empty, Descriptions } from '@douyinfe/semi-ui'; +import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable'; import { IllustrationNoResult, IllustrationNoResultDark, } from '@douyinfe/semi-illustrations'; import { getLogsColumns } from './UsageLogsColumnDefs'; +import UsageLogExpandedDetail from './UsageLogExpandedDetail'; const LogsTable = (logsData) => { const { @@ -82,7 +83,7 @@ const LogsTable = (logsData) => { }, [compactMode, visibleColumnsList]); const expandRowRender = (record, index) => { - return ; + return ; }; return ( @@ -92,7 +93,9 @@ const LogsTable = (logsData) => { expandedRowRender: expandRowRender, expandRowByClick: true, rowExpandable: (record) => - expandData[record.key] && expandData[record.key].length > 0, + expandData[record.key]?.sections?.length > 0 || + expandData[record.key]?.items?.length > 0 || + expandData[record.key]?.length > 0, })} dataSource={logs} rowKey='key' diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 020f0d7..afc0566 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -693,6 +693,26 @@ export const summarizeMatrixPricing = ({ return { price: min === max ? minText : `${minText} - ${maxText}`, unit: getMatrixPricingUnit(cfg, tokenUnit), + details: (cfg.table || []).slice(0, 3).map((row) => { + const dimensionText = Object.entries(row) + .filter(([key]) => key !== 'price' && key !== 'source') + .map( + ([key, value]) => `${key}=${value === '*' ? '任意' : String(value)}`, + ) + .join(' · '); + return { + dimensionText, + formattedPrice: formatMatrixPrice({ + price: Number(row.price || 0) * usedGroupRatio, + pricingConfig: cfg, + tokenUnit, + displayPrice, + currency, + precision, + }), + }; + }), + hiddenDetailCount: Math.max(0, (cfg.table || []).length - 3), }; }; @@ -706,8 +726,9 @@ export const calculateModelPrice = ({ precision = 4, }) => { // 1. 选择实际使用的分组 + const ratios = groupRatio || {}; let usedGroup = selectedGroup; - let usedGroupRatio = groupRatio[selectedGroup]; + let usedGroupRatio = ratios[selectedGroup]; if (selectedGroup === 'all' || usedGroupRatio === undefined) { // 在模型可用分组中选择倍率最小的分组,若无则使用 1 @@ -717,7 +738,7 @@ export const calculateModelPrice = ({ record.enable_groups.length > 0 ) { record.enable_groups.forEach((g) => { - const r = groupRatio[g]; + const r = ratios[g]; if (r !== undefined && r < minRatio) { minRatio = r; usedGroup = g; @@ -839,7 +860,8 @@ export const formatPriceInfo = (priceData, t) => { if (priceData.isPerToken) { return ( - {t('输入')} {priceData.inputPrice} / {t('输出')} {priceData.completionPrice} / {priceData.unitLabel} tokens + {t('输入')} {priceData.inputPrice} / {t('输出')}{' '} + {priceData.completionPrice} / {priceData.unitLabel} tokens ); } diff --git a/web/src/helpers/utils.test.js b/web/src/helpers/utils.test.js index f37f541..39bfe77 100644 --- a/web/src/helpers/utils.test.js +++ b/web/src/helpers/utils.test.js @@ -15,8 +15,12 @@ globalThis.localStorage = { getItem: () => null, }; -const { calculateModelPrice, formatMatrixPrice, hasMatrixPricing } = - await import('./utils.jsx'); +const { + calculateModelPrice, + formatMatrixPrice, + hasMatrixPricing, + summarizeMatrixPricing, +} = await import('./utils.jsx'); const displayPrice = (value) => `$${Number(value).toFixed(3)}`; @@ -27,8 +31,18 @@ describe('model pricing helpers', () => { pricing_config: { billing_unit: 'per_1m_tokens', table: [ - { resolution: '480p', video_input: true, price: 28, source: 'manual' }, - { resolution: '480p', video_input: false, price: 46, source: 'manual' }, + { + resolution: '480p', + video_input: true, + price: 28, + source: 'manual', + }, + { + resolution: '480p', + video_input: false, + price: 46, + source: 'manual', + }, ], }, }; @@ -50,6 +64,24 @@ describe('model pricing helpers', () => { unit: '1M tokens', usedGroupRatio: 0.5, }); + expect( + summarizeMatrixPricing({ + record, + usedGroupRatio: 0.5, + tokenUnit: 'M', + displayPrice, + currency: 'USD', + }).details, + ).toEqual([ + { + dimensionText: 'resolution=480p · video_input=true', + formattedPrice: '$14.0000', + }, + { + dimensionText: 'resolution=480p · video_input=false', + formattedPrice: '$23.0000', + }, + ]); }); it('formats per 1K matrix token price from per 1M price', () => { @@ -63,4 +95,28 @@ describe('model pricing helpers', () => { }), ).toBe('$0.0280'); }); + + it('falls back to group ratio 1 when group ratios are not provided', () => { + const record = { + enable_groups: ['default'], + pricing_config: { + billing_unit: 'per_1m_tokens', + table: [{ resolution: '720p', price: 46, source: 'manual' }], + }, + }; + + expect( + calculateModelPrice({ + record, + selectedGroup: 'default', + tokenUnit: 'M', + displayPrice, + currency: 'USD', + }), + ).toMatchObject({ + isMatrix: true, + price: '$46.0000', + usedGroupRatio: 1, + }); + }); }); diff --git a/web/src/hooks/usage-logs/taskBillingLogMerge.js b/web/src/hooks/usage-logs/taskBillingLogMerge.js new file mode 100644 index 0000000..828848f --- /dev/null +++ b/web/src/hooks/usage-logs/taskBillingLogMerge.js @@ -0,0 +1,168 @@ +const MATRIX_BILLING_MODE = 'matrix'; +const PHASE_PRECONSUME = 'preconsume'; +const PHASE_SETTLEMENT = 'settlement'; +const PHASE_REFUND = 'refund'; + +const parseOther = (other) => { + if (!other) return {}; + if (typeof other === 'object') return other; + try { + const parsed = JSON.parse(other); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +}; + +const numberOrFallback = (value, fallback) => { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : fallback; +}; + +const getMergeKey = (log) => { + const other = parseOther(log?.other); + if (other.billing_mode !== MATRIX_BILLING_MODE) return ''; + if (!other.task_id || !other.billing_phase) return ''; + if ( + ![PHASE_PRECONSUME, PHASE_SETTLEMENT, PHASE_REFUND].includes( + other.billing_phase, + ) + ) { + return ''; + } + return other.task_id; +}; + +const summarizeBillingLog = (log) => { + const other = parseOther(log.other); + return { + id: log.id, + type: log.type, + quota: log.quota, + created_at: log.created_at, + billing_phase: other.billing_phase, + pre_consumed_quota: other.pre_consumed_quota, + actual_quota: other.actual_quota, + quota_delta: other.quota_delta, + }; +}; + +const pickMergedRecord = (group) => { + const refund = group.find( + (item) => item.other.billing_phase === PHASE_REFUND, + ); + if (refund) { + return { + item: refund, + status: 'refunded', + type: 6, + quota: refund.log.quota, + }; + } + + const settlement = group.find( + (item) => item.other.billing_phase === PHASE_SETTLEMENT, + ); + if (settlement) { + const preConsumed = numberOrFallback( + settlement.other.pre_consumed_quota, + 0, + ); + const actualQuota = numberOrFallback( + settlement.other.actual_quota, + preConsumed + numberOrFallback(settlement.other.quota_delta, 0), + ); + return { + item: settlement, + status: 'settled', + type: 2, + quota: actualQuota, + }; + } + + const preconsume = + group.find((item) => item.other.billing_phase === PHASE_PRECONSUME) || + group[0]; + return { + item: preconsume, + status: 'pending', + type: 2, + quota: preconsume.log.quota, + }; +}; + +const buildMergedRecord = (taskId, group) => { + const picked = pickMergedRecord(group); + const preconsume = group.find( + (item) => item.other.billing_phase === PHASE_PRECONSUME, + ); + const settlement = group.find( + (item) => item.other.billing_phase === PHASE_SETTLEMENT, + ); + const refund = group.find( + (item) => item.other.billing_phase === PHASE_REFUND, + ); + + const other = { + ...picked.item.other, + task_id: taskId, + task_billing_merged: true, + task_billing_status: picked.status, + task_billing_logs: group.map((item) => summarizeBillingLog(item.log)), + }; + + if (preconsume) { + other.pre_consumed_quota = numberOrFallback( + preconsume.other.pre_consumed_quota, + preconsume.log.quota, + ); + } + if (settlement) { + other.actual_quota = numberOrFallback( + settlement.other.actual_quota, + picked.quota, + ); + other.quota_delta = numberOrFallback(settlement.other.quota_delta, 0); + } + if (refund) { + other.refund_quota = refund.log.quota; + } + + return { + ...picked.item.log, + key: `task-billing-${taskId}`, + type: picked.type, + quota: picked.quota, + other: JSON.stringify(other), + }; +}; + +export const mergeTaskBillingLogs = (logs = []) => { + const groups = new Map(); + const passthrough = []; + + logs.forEach((log, index) => { + const taskId = getMergeKey(log); + if (!taskId) { + passthrough.push({ index, log }); + return; + } + if (!groups.has(taskId)) { + groups.set(taskId, { firstIndex: index, items: [] }); + } + groups.get(taskId).items.push({ + log, + other: parseOther(log.other), + index, + }); + }); + + const merged = Array.from(groups.entries()).map(([taskId, group]) => ({ + index: group.firstIndex, + log: buildMergedRecord(taskId, group.items), + })); + + return [...passthrough, ...merged] + .sort((a, b) => a.index - b.index) + .map((item) => item.log); +}; diff --git a/web/src/hooks/usage-logs/taskBillingLogMerge.test.js b/web/src/hooks/usage-logs/taskBillingLogMerge.test.js new file mode 100644 index 0000000..e2df3c1 --- /dev/null +++ b/web/src/hooks/usage-logs/taskBillingLogMerge.test.js @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { mergeTaskBillingLogs } from './taskBillingLogMerge.js'; + +const withOther = (log, other) => ({ + ...log, + other: JSON.stringify(other), +}); + +const readOther = (log) => JSON.parse(log.other); + +describe('mergeTaskBillingLogs', () => { + it('merges matrix preconsume and settlement logs into one final consumption row', () => { + const logs = [ + withOther( + { id: 11, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, + { + billing_mode: 'matrix', + billing_phase: 'preconsume', + task_id: 'task_1', + pre_consumed_quota: 1, + }, + ), + withOther( + { id: 12, type: 2, quota: 36, created_at: 200, model_name: 'seedance' }, + { + billing_mode: 'matrix', + billing_phase: 'settlement', + task_id: 'task_1', + pre_consumed_quota: 1, + actual_quota: 37, + quota_delta: 36, + }, + ), + ]; + + const merged = mergeTaskBillingLogs(logs); + + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ + id: 12, + key: 'task-billing-task_1', + type: 2, + quota: 37, + }); + expect(readOther(merged[0])).toMatchObject({ + billing_mode: 'matrix', + billing_phase: 'settlement', + task_id: 'task_1', + pre_consumed_quota: 1, + actual_quota: 37, + quota_delta: 36, + task_billing_merged: true, + task_billing_status: 'settled', + }); + expect(readOther(merged[0]).task_billing_logs).toHaveLength(2); + }); + + it('keeps pending preconsume rows when no settlement is present', () => { + const logs = [ + withOther( + { id: 21, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, + { + billing_mode: 'matrix', + billing_phase: 'preconsume', + task_id: 'task_2', + pre_consumed_quota: 1, + }, + ), + ]; + + const merged = mergeTaskBillingLogs(logs); + + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ + id: 21, + key: 'task-billing-task_2', + type: 2, + quota: 1, + }); + expect(readOther(merged[0])).toMatchObject({ + task_billing_merged: true, + task_billing_status: 'pending', + }); + }); + + it('does not merge old matrix logs that do not have billing phase metadata', () => { + const logs = [ + withOther( + { id: 31, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, + { + billing_mode: 'matrix', + task_id: 'task_3', + }, + ), + withOther( + { id: 32, type: 2, quota: 9, created_at: 200, model_name: 'seedance' }, + { + billing_mode: 'matrix', + task_id: 'task_3', + }, + ), + ]; + + expect(mergeTaskBillingLogs(logs)).toHaveLength(2); + }); + + it('merges preconsume and refund logs into one refund row', () => { + const logs = [ + withOther( + { id: 41, type: 2, quota: 20, created_at: 100, model_name: 'seedance' }, + { + billing_mode: 'matrix', + billing_phase: 'preconsume', + task_id: 'task_4', + pre_consumed_quota: 20, + }, + ), + withOther( + { id: 42, type: 6, quota: 20, created_at: 200, model_name: 'seedance' }, + { + billing_mode: 'matrix', + billing_phase: 'refund', + task_id: 'task_4', + pre_consumed_quota: 20, + }, + ), + ]; + + const merged = mergeTaskBillingLogs(logs); + + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ + id: 42, + key: 'task-billing-task_4', + type: 6, + quota: 20, + }); + expect(readOther(merged[0])).toMatchObject({ + task_billing_merged: true, + task_billing_status: 'refunded', + }); + }); +}); diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 50977b0..01d1617 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -38,6 +38,7 @@ import { } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; import { useTableCompactMode } from '../common/useTableCompactMode'; +import { mergeTaskBillingLogs } from './taskBillingLogMerge'; export const useLogsData = () => { const { t } = useTranslation(); @@ -248,8 +249,12 @@ export const useLogsData = () => { logType: formLogType, } = getFormValues(); const currentLogType = formLogType !== undefined ? formLogType : logType; - let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; - let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; + let localStartTimestamp = start_timestamp + ? Date.parse(start_timestamp) / 1000 + : 0; + let localEndTimestamp = end_timestamp + ? Date.parse(end_timestamp) / 1000 + : 0; const params = new URLSearchParams({ type: currentLogType }); if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); @@ -278,8 +283,12 @@ export const useLogsData = () => { logType: formLogType, } = getFormValues(); const currentLogType = formLogType !== undefined ? formLogType : logType; - let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; - let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; + let localStartTimestamp = start_timestamp + ? Date.parse(start_timestamp) / 1000 + : 0; + let localEndTimestamp = end_timestamp + ? Date.parse(end_timestamp) / 1000 + : 0; const params = new URLSearchParams({ type: currentLogType }); if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); @@ -340,6 +349,208 @@ export const useLogsData = () => { // Format logs data const setLogsFormat = (logs) => { + logs = mergeTaskBillingLogs(logs || []); + + const pushDetail = (sections, sectionKey, item) => { + if ( + item?.value === undefined || + item?.value === null || + item?.value === '' + ) { + return; + } + if (!sections[sectionKey]) { + sections[sectionKey] = []; + } + sections[sectionKey].push(item); + }; + + const compactJsonBlock = (value) => ( +
+        {typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
+      
+ ); + + const buildSectionedExpandData = (flatItems, log, other) => { + const sections = {}; + const knownKeys = new Set(); + const addKnown = (sectionKey, key, value) => { + knownKeys.add(key); + pushDetail(sections, sectionKey, { key, value }); + }; + + addKnown('basic', t('时间'), log.timestamp2string); + addKnown('basic', t('模型'), log.model_name); + addKnown('basic', t('令牌'), log.token_name); + addKnown('basic', t('分组'), log.group); + if (isAdminUser) { + addKnown('basic', t('用户'), log.username); + } + if (other?.task_id) { + addKnown('basic', t('任务ID'), other.task_id); + } + if (other?.request_path) { + addKnown('basic', t('请求路径'), other.request_path); + } + + addKnown('billing', t('花费'), renderQuota(log.quota, 6)); + addKnown('billing', t('Prompt Tokens'), log.prompt_tokens); + addKnown('billing', t('Completion Tokens'), log.completion_tokens); + if (other?.model_price !== undefined) { + const quotaPerUnit = parseFloat( + localStorage.getItem('quota_per_unit') || '500000', + ); + addKnown( + 'billing', + t('模型价格'), + renderQuota(other.model_price * quotaPerUnit, 6), + ); + } + if (other?.token_unit_price_usd !== undefined) { + const quotaPerUnit = parseFloat( + localStorage.getItem('quota_per_unit') || '500000', + ); + addKnown( + 'billing', + t('Token 单价'), + renderQuota(other.token_unit_price_usd * quotaPerUnit, 6), + ); + } + if (other?.group_ratio !== undefined) { + addKnown('billing', t('分组倍率'), other.group_ratio); + } + if (other?.user_group_ratio !== undefined) { + addKnown('billing', t('用户分组倍率'), other.user_group_ratio); + } + if (other?.billing_mode) { + addKnown('billing', t('计费模式'), other.billing_mode); + } + if (other?.billing_unit) { + addKnown('billing', t('计费单位'), other.billing_unit); + } + if (other?.billing_phase) { + addKnown('billing', t('计费阶段'), other.billing_phase); + } + if (other?.pre_consumed_quota !== undefined) { + addKnown( + 'billing', + t('预扣金额'), + renderQuota(other.pre_consumed_quota, 6), + ); + } + if (other?.actual_quota !== undefined) { + addKnown('billing', t('实际消费'), renderQuota(other.actual_quota, 6)); + } + if (other?.quota_delta !== undefined) { + const delta = Number(other.quota_delta); + addKnown( + 'billing', + t('结算差额'), + `${delta > 0 ? '+' : ''}${renderQuota(delta, 6)}`, + ); + } + if (other?.refund_quota !== undefined) { + addKnown('billing', t('退还金额'), renderQuota(other.refund_quota, 6)); + } + if (other?.total_tokens !== undefined) { + addKnown('billing', t('总 Tokens'), other.total_tokens); + } + + if (other?.pricing_snapshot) { + const snapshot = other.pricing_snapshot || {}; + const excluded = new Set([ + 'price', + 'billing_unit', + 'pricing_mode', + 'fallback_strategy', + 'total_tokens', + ]); + Object.entries(snapshot).forEach(([key, value]) => { + if (!excluded.has(key)) { + addKnown('matrix', key, String(value)); + } + }); + addKnown('matrix', t('命中价格 USD'), snapshot.price); + addKnown('matrix', t('命中模式'), snapshot.pricing_mode); + addKnown('matrix', 'Fallback', snapshot.fallback_strategy); + } + + if (log.request_id) { + addKnown('context', 'Request ID', log.request_id); + } + if (isAdminUser && log.chat_id) { + addKnown('context', 'Chat ID', log.chat_id); + } + if (isAdminUser && log.upstream_id) { + addKnown('context', 'Upstream ID', log.upstream_id); + } + if (other?.request_conversion) { + addKnown( + 'context', + t('请求转换'), + requestConversionDisplayValue(other.request_conversion), + ); + } + if (other?.is_model_mapped) { + addKnown('context', t('请求并计费模型'), log.model_name); + addKnown('context', t('实际模型'), other.upstream_model_name); + } + + if (other?.reason) { + addKnown( + 'state', + t('原因'), +
+ {other.reason} +
, + ); + } + if (other?.reject_reason) { + addKnown('state', t('拦截原因'), other.reject_reason); + } + if (other?.task_billing_status) { + const statusMap = { + pending: t('待结算'), + settled: t('已结算'), + refunded: t('已退还'), + }; + addKnown( + 'state', + t('任务账单状态'), + statusMap[other.task_billing_status] || other.task_billing_status, + ); + } + + const sectionOrder = [ + ['basic', t('基础信息')], + ['billing', t('计费信息')], + ['matrix', t('矩阵计费')], + ['context', t('请求上下文')], + ['state', t('状态与原因')], + ]; + + return { + items: flatItems, + sections: sectionOrder + .filter(([key]) => sections[key]?.length) + .map(([key, title]) => ({ title, items: sections[key] })), + }; + }; + const requestConversionDisplayValue = (conversionChain) => { const chain = Array.isArray(conversionChain) ? conversionChain.filter(Boolean) @@ -357,7 +568,10 @@ export const useLogsData = () => { let other = getLogOther(logs[i].other); let expandDataLocal = []; - if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) { + if ( + isAdminUser && + (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6) + ) { expandDataLocal.push({ key: t('渠道信息'), value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`, @@ -571,13 +785,63 @@ export const useLogsData = () => { expandDataLocal.push({ key: t('失败原因'), value: ( -
+
{other.reason}
), }); } } + if (other?.task_billing_merged) { + const statusMap = { + pending: t('待结算'), + settled: t('已结算'), + refunded: t('已退还'), + }; + expandDataLocal.push({ + key: t('任务账单状态'), + value: + statusMap[other.task_billing_status] || other.task_billing_status, + }); + if (other?.task_id && logs[i].type !== 6) { + expandDataLocal.push({ + key: t('任务ID'), + value: other.task_id, + }); + } + if (other?.pre_consumed_quota !== undefined) { + expandDataLocal.push({ + key: t('预扣金额'), + value: renderQuota(other.pre_consumed_quota, 6), + }); + } + if (other?.actual_quota !== undefined) { + expandDataLocal.push({ + key: t('实际消费'), + value: renderQuota(other.actual_quota, 6), + }); + } + if (other?.quota_delta !== undefined) { + const delta = Number(other.quota_delta); + expandDataLocal.push({ + key: t('结算差额'), + value: `${delta > 0 ? '+' : ''}${renderQuota(delta, 6)}`, + }); + } + if (other?.refund_quota !== undefined) { + expandDataLocal.push({ + key: t('退还金额'), + value: renderQuota(other.refund_quota, 6), + }); + } + } if (other?.request_path) { expandDataLocal.push({ key: t('请求路径'), @@ -651,7 +915,11 @@ export const useLogsData = () => { value: localCountMode, }); } - expandDatesLocal[logs[i].key] = expandDataLocal; + expandDatesLocal[logs[i].key] = buildSectionedExpandData( + expandDataLocal, + logs[i], + other, + ); } setExpandData(expandDatesLocal); @@ -684,8 +952,12 @@ export const useLogsData = () => { ? formLogType : logType; - let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; - let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; + let localStartTimestamp = start_timestamp + ? Date.parse(start_timestamp) / 1000 + : 0; + let localEndTimestamp = end_timestamp + ? Date.parse(end_timestamp) / 1000 + : 0; const params = new URLSearchParams({ p: startIdx, @@ -778,9 +1050,14 @@ export const useLogsData = () => { // Check if any record has expandable content const hasExpandableRows = () => { - return logs.some( - (log) => expandData[log.key] && expandData[log.key].length > 0, - ); + return logs.some((log) => { + const detail = expandData[log.key]; + return ( + detail?.sections?.length > 0 || + detail?.items?.length > 0 || + detail?.length > 0 + ); + }); }; return {