Browse Source

feat: 用户倍率前端展示 + 渠道下拉选择 + 上游调试日志

- UserRatioSection 渠道 ID 输入改为带搜索的下拉选择器
- 使用日志详情、计费过程、展开行均显示用户倍率
- renderLogContent suffix 统一追加,消除 4 处重复
- 提取 dumpUpstreamRequest 辅助函数,debug 日志走 SysLog

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 1 day ago
parent
commit
b026f6bc04
5 changed files with 124 additions and 59 deletions
  1. +12
    -10
      relay/channel/api_request.go
  2. +3
    -0
      service/log_info_generate.go
  3. +38
    -5
      web/src/components/table/users/modals/UserRatioSection.jsx
  4. +62
    -44
      web/src/helpers/render.jsx
  5. +9
    -0
      web/src/hooks/usage-logs/useUsageLogsData.jsx

+ 12
- 10
relay/channel/api_request.go View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"bytes"
"io"
"net/http"
"regexp"
@@ -293,13 +294,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c)
if err != nil {
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)
dumpUpstreamRequest(req)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
@@ -319,20 +319,18 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
}
// set form data
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
headers := req.Header
err = a.SetupRequestHeader(c, &headers, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c)
if err != nil {
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)
dumpUpstreamRequest(req)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
@@ -340,6 +338,15 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
return resp, nil
}

func dumpUpstreamRequest(req *http.Request) {
if !common2.DebugEnabled || req == nil {
return
}
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
common2.SysLog(fmt.Sprintf("[UpstreamRequest] URL: %s\nHeaders: %v\nBody: %s", req.URL.String(), req.Header, string(bodyBytes)))
}

func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) {
fullRequestURL, err := a.GetRequestURL(info)
if err != nil {
@@ -347,11 +354,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
}
targetHeader := http.Header{}
err = a.SetupRequestHeader(c, &targetHeader, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c)
if err != nil {
return nil, err


+ 3
- 0
service/log_info_generate.go View File

@@ -41,6 +41,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["cache_ratio"] = cacheRatio
other["model_price"] = modelPrice
other["user_group_ratio"] = userGroupRatio
if relayInfo != nil && relayInfo.PriceData.UserChannelRatio != 1.0 {
other["user_channel_ratio"] = relayInfo.PriceData.UserChannelRatio
}
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.ReasoningEffort != "" {
other["reasoning_effort"] = relayInfo.ReasoningEffort


+ 38
- 5
web/src/components/table/users/modals/UserRatioSection.jsx View File

@@ -20,6 +20,7 @@ const UserRatioSection = ({ userId }) => {
const [ratios, setRatios] = useState([]);
const [loading, setLoading] = useState(false);
const [addModalVisible, setAddModalVisible] = useState(false);
const [channelOptions, setChannelOptions] = useState([]);
const formApiRef = useRef(null);

const loadRatios = async () => {
@@ -39,8 +40,29 @@ const UserRatioSection = ({ userId }) => {
setLoading(false);
};

const loadChannels = async () => {
try {
const res = await API.get('/api/channel/?p=0&page_size=500');
const { success, data, message } = res.data;
if (success) {
const options = (data?.items || []).map((ch) => ({
label: ch.remark
? `${ch.name} (${ch.remark})`
: `${ch.name} (ID: ${ch.id})`,
value: ch.id,
}));
setChannelOptions(options);
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
};

useEffect(() => {
loadRatios();
loadChannels();
}, [userId]);

const handleAdd = async (values) => {
@@ -86,7 +108,15 @@ const UserRatioSection = ({ userId }) => {

const columns = [
{ title: t('模型'), dataIndex: 'model_name', key: 'model_name' },
{ title: t('渠道 ID'), dataIndex: 'channel_id', key: 'channel_id' },
{
title: t('渠道'),
dataIndex: 'channel_id',
key: 'channel_id',
render: (channelId) => {
const ch = channelOptions.find((c) => c.value === channelId);
return ch ? ch.label : channelId;
},
},
{
title: t('倍率'),
dataIndex: 'ratio',
@@ -166,11 +196,14 @@ const UserRatioSection = ({ userId }) => {
placeholder='gpt-4o'
rules={[{ required: true, message: t('请输入模型名称') }]}
/>
<Form.InputNumber
<Form.Select
field='channel_id'
label={t('渠道 ID')}
placeholder={t('请输入渠道 ID')}
rules={[{ required: true, message: t('请输入渠道 ID') }]}
label={t('渠道')}
placeholder={t('请选择渠道')}
optionList={channelOptions}
filter
showClear
rules={[{ required: true, message: t('请选择渠道') }]}
style={{ width: '100%' }}
/>
<Form.InputNumber


+ 62
- 44
web/src/helpers/render.jsx View File

@@ -1312,6 +1312,7 @@ export function renderModelPrice(
audioInputPrice = 0,
imageGenerationCall = false,
imageGenerationCallPrice = 0,
userChannelRatio,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1322,17 +1323,19 @@ export function renderModelPrice(
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();

const ucr = (userChannelRatio != null && userChannelRatio !== 1.0) ? userChannelRatio : null;

if (modelPrice !== -1) {
const displayPrice = (modelPrice * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * (ucr || 1) * rate).toFixed(6);
const ratioParts = `${ratioLabel}:${groupRatio}` + (ucr ? ` * 用户倍率:${ucr}` : '');
return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
'模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}',
{
symbol: symbol,
price: displayPrice,
ratio: groupRatio,
ratioParts,
total: displayTotal,
ratioType: ratioLabel,
},
);
} else {
@@ -1363,6 +1366,10 @@ export function renderModelPrice(
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio;

if (ucr) {
price *= ucr;
}

return (
<>
<article>
@@ -1490,13 +1497,14 @@ export function renderModelPrice(

// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}{{userRatio}}',
{
completion: completionTokens,
symbol: symbol,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
userRatio: ucr ? ` * 用户倍率 ${ucr}` : '',
},
);

@@ -1571,6 +1579,7 @@ export function renderLogContent(
webSearchCallCount = 0,
fileSearch = false,
fileSearchCallCount = 0,
userChannelRatio,
) {
const {
ratio,
@@ -1581,51 +1590,55 @@ export function renderLogContent(
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();

const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0)
? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio })
: '';

let result;
if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
result = i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel,
ratio,
});
} else if (image) {
result = i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
imageRatio: imageRatio,
ratioType: ratioLabel,
ratio,
},
);
} else if (webSearch) {
result = i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
webSearchCallCount,
},
);
} else {
if (image) {
return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
imageRatio: imageRatio,
ratioType: ratioLabel,
ratio,
},
);
} else if (webSearch) {
return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
webSearchCallCount,
},
);
} else {
return i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
},
);
}
result = i18next.t(
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
},
);
}
return result + userRatioSuffix;
}

export function renderModelPriceSimple(
@@ -2148,6 +2161,7 @@ export function renderClaudeLogContent(
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
userChannelRatio,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -2158,13 +2172,17 @@ export function renderClaudeLogContent(
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();

const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0)
? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio })
: '';

if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel,
ratio: groupRatio,
});
}) + userRatioSuffix;
} else {
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
@@ -2217,7 +2235,7 @@ export function renderClaudeLogContent(
}),
];

return parts.join(',');
return parts.join(',') + userRatioSuffix;
}
}



+ 9
- 0
web/src/hooks/usage-logs/useUsageLogsData.jsx View File

@@ -422,6 +422,7 @@ export const useLogsData = () => {
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
other?.user_channel_ratio,
)
: renderLogContent(
other?.model_ratio,
@@ -436,6 +437,7 @@ export const useLogsData = () => {
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
other?.user_channel_ratio,
),
});
if (logs[i]?.content) {
@@ -537,6 +539,7 @@ export const useLogsData = () => {
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
other?.user_channel_ratio,
);
}
expandDataLocal.push({
@@ -550,6 +553,12 @@ export const useLogsData = () => {
value: other.reasoning_effort,
});
}
if (other?.user_channel_ratio) {
expandDataLocal.push({
key: t('用户倍率'),
value: other.user_channel_ratio,
});
}
}
if (logs[i].type === 6) {
if (other?.task_id) {


Loading…
Cancel
Save