From 511489db0950744bad18a2d2a26b2f972d366844 Mon Sep 17 00:00:00 2001 From: DD <1083962986@qq.com> Date: Mon, 8 Sep 2025 16:21:21 +0800 Subject: [PATCH 01/53] add submodel.ai --- common/api_type.go | 2 + constant/api_type.go | 1 + constant/channel.go | 2 + relay/channel/submodel/adaptor.go | 82 ++++++++++++++++++++++++++ relay/channel/submodel/constants.go | 16 +++++ relay/relay_adaptor.go | 3 + setting/ratio_setting/model_ratio.go | 13 ++++ web/src/constants/channel.constants.js | 5 ++ web/src/helpers/render.js | 2 + web/src/pages/Channel/EditTagModal.js | 3 + 10 files changed, 129 insertions(+) create mode 100644 relay/channel/submodel/adaptor.go create mode 100644 relay/channel/submodel/constants.go diff --git a/common/api_type.go b/common/api_type.go index f045866ac..6204451dc 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeCoze case constant.ChannelTypeJimeng: apiType = constant.APITypeJimeng + case constant.ChannelTypeSubmodel: + apiType = constant.APITypeSubmodel } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index 6ba5f2574..0c7b1fdde 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -31,5 +31,6 @@ const ( APITypeXai APITypeCoze APITypeJimeng + APITypeSubmodel APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 224121e70..3d7158b11 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -49,6 +49,7 @@ const ( ChannelTypeCoze = 49 ChannelTypeKling = 50 ChannelTypeJimeng = 51 + ChannelTypeSubmodel = 52 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -106,4 +107,5 @@ var ChannelBaseURLs = []string{ "https://api.coze.cn", //49 "https://api.klingai.com", //50 "https://visual.volcengineapi.com", //51 + "https://llm.submodel.ai", //52 } diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go new file mode 100644 index 000000000..371fb055c --- /dev/null +++ b/relay/channel/submodel/adaptor.go @@ -0,0 +1,82 @@ +package submodel + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} \ No newline at end of file diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go new file mode 100644 index 000000000..962682bb9 --- /dev/null +++ b/relay/channel/submodel/constants.go @@ -0,0 +1,16 @@ +package submodel + +var ModelList = []string{ + "NousResearch/Hermes-4-405B-FP8", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "zai-org/GLM-4.5-FP8", + "openai/gpt-oss-120b", + "deepseek-ai/DeepSeek-R1-0528", + "deepseek-ai/DeepSeek-R1", + "deepseek-ai/DeepSeek-V3-0324", + "deepseek-ai/DeepSeek-V3.1", +} + +var ChannelName = "submodel" \ No newline at end of file diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 2ce12a872..946053a28 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -34,6 +34,7 @@ import ( "one-api/relay/channel/xunfei" "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" + "one-api/relay/channel/submodel" ) func GetAdaptor(apiType int) channel.Adaptor { @@ -96,6 +97,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &coze.Adaptor{} case constant.APITypeJimeng: return &jimeng.Adaptor{} + case constant.APITypeSubmodel: + return &submodel.Adaptor{} } return nil } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 8a1d6aaed..0bcb6ff58 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -223,6 +223,19 @@ var defaultModelRatio = map[string]float64{ "grok-vision-beta": 2.5, "grok-3-fast-beta": 2.5, "grok-3-mini-fast-beta": 0.3, + + // submodel + "NousResearch/Hermes-4-405B-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6, + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3, + "zai-org/GLM-4.5-FP8": 0.8, + "openai/gpt-oss-120b": 0.5, + "deepseek-ai/DeepSeek-R1-0528": 0.8, + "deepseek-ai/DeepSeek-R1": 0.8, + "deepseek-ai/DeepSeek-V3-0324": 0.8, + "deepseek-ai/DeepSeek-V3.1": 0.8 + } var defaultModelPrice = map[string]float64{ diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index b145ea11f..6a4566a7d 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -135,6 +135,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: '即梦', }, + { + value: 52, + color: 'blue', + label: 'SubModel', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 34ba78d7a..abf246b8c 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -398,6 +398,8 @@ export function getChannelIcon(channelType) { return ; case 21: // 知识库:AI Proxy case 44: // 嵌入模型:MokaAI M3E + case 52: // SubModel + return null; default: return null; // 未知类型或自定义渠道不显示图标 } diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/pages/Channel/EditTagModal.js index 433d4f092..35fc1646f 100644 --- a/web/src/pages/Channel/EditTagModal.js +++ b/web/src/pages/Channel/EditTagModal.js @@ -98,6 +98,9 @@ const EditTagModal = (props) => { case 36: localModels = ['suno_music', 'suno_lyrics']; break; + case 52: + localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1']; + break; default: localModels = getChannelModels(value); break; From 23e4249ebeaf49abd1847cf19d3b3b2535981161 Mon Sep 17 00:00:00 2001 From: DD <1083962986@qq.com> Date: Mon, 8 Sep 2025 17:33:15 +0800 Subject: [PATCH 02/53] merge --- web/src/pages/Channel/EditTagModal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/pages/Channel/EditTagModal.js index 35fc1646f..aedf991c3 100644 --- a/web/src/pages/Channel/EditTagModal.js +++ b/web/src/pages/Channel/EditTagModal.js @@ -99,7 +99,7 @@ const EditTagModal = (props) => { localModels = ['suno_music', 'suno_lyrics']; break; case 52: - localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1']; + localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1']; break; default: localModels = getChannelModels(value); From 78b0f8905bc515de3181ffa2eb40961cc421d732 Mon Sep 17 00:00:00 2001 From: DD <1083962986@qq.com> Date: Wed, 10 Sep 2025 18:37:55 +0800 Subject: [PATCH 03/53] merge --- constant/channel.go | 6 ++---- web/src/helpers/render.jsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/constant/channel.go b/constant/channel.go index 0cfd90cd9..34fb20f46 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -49,13 +49,11 @@ const ( ChannelTypeCoze = 49 ChannelTypeKling = 50 ChannelTypeJimeng = 51 -<<<<<<< HEAD - ChannelTypeSubmodel = 52 -======= ChannelTypeVidu = 52 ->>>>>>> 041782c49e0289b9d2e64a318e81e4f75754dabf + ChannelTypeSubmodel = 53 ChannelTypeDummy // this one is only for count, do not add any channel after this + ) var ChannelBaseURLs = []string{ diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 49e87f668..676a582bc 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -342,7 +342,7 @@ export function getChannelIcon(channelType) { return ; case 21: // 知识库:AI Proxy case 44: // 嵌入模型:MokaAI M3E - case 52: // SubModel + case 53: // SubModel return null; default: return null; // 未知类型或自定义渠道不显示图标 From a12ed5709e46e9aa18331a2000c06e0fcbc4269e Mon Sep 17 00:00:00 2001 From: DD <1083962986@qq.com> Date: Wed, 10 Sep 2025 19:11:58 +0800 Subject: [PATCH 04/53] merge --- relay/channel/submodel/constants.go | 2 +- setting/ratio_setting/model_ratio.go | 4 +--- web/src/components/table/channels/modals/EditTagModal.jsx | 2 +- web/src/helpers/render.jsx | 2 -- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go index 962682bb9..f5e1feb84 100644 --- a/relay/channel/submodel/constants.go +++ b/relay/channel/submodel/constants.go @@ -13,4 +13,4 @@ var ModelList = []string{ "deepseek-ai/DeepSeek-V3.1", } -var ChannelName = "submodel" \ No newline at end of file +const ChannelName = "submodel" \ No newline at end of file diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 26db81684..c427cfe25 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -251,7 +251,6 @@ var defaultModelRatio = map[string]float64{ "grok-vision-beta": 2.5, "grok-3-fast-beta": 2.5, "grok-3-mini-fast-beta": 0.3, - // submodel "NousResearch/Hermes-4-405B-FP8": 0.8, "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6, @@ -262,8 +261,7 @@ var defaultModelRatio = map[string]float64{ "deepseek-ai/DeepSeek-R1-0528": 0.8, "deepseek-ai/DeepSeek-R1": 0.8, "deepseek-ai/DeepSeek-V3-0324": 0.8, - "deepseek-ai/DeepSeek-V3.1": 0.8 - + "deepseek-ai/DeepSeek-V3.1": 0.8, } var defaultModelPrice = map[string]float64{ diff --git a/web/src/components/table/channels/modals/EditTagModal.jsx b/web/src/components/table/channels/modals/EditTagModal.jsx index 727b19094..752ff3dc5 100644 --- a/web/src/components/table/channels/modals/EditTagModal.jsx +++ b/web/src/components/table/channels/modals/EditTagModal.jsx @@ -118,7 +118,7 @@ const EditTagModal = (props) => { case 36: localModels = ['suno_music', 'suno_lyrics']; break; - case 52: + case 53: localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1']; break; default: diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 676a582bc..3d9d8d710 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -342,8 +342,6 @@ export function getChannelIcon(channelType) { return ; case 21: // 知识库:AI Proxy case 44: // 嵌入模型:MokaAI M3E - case 53: // SubModel - return null; default: return null; // 未知类型或自定义渠道不显示图标 } From 465830945b424af5cf8d5f2e8dfa624f55281e9c Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 10 Sep 2025 22:36:03 +0800 Subject: [PATCH 05/53] fix: get video task err when Content-Type=json --- middleware/distributor.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/middleware/distributor.go b/middleware/distributor.go index 1e6df872d..3d929df49 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -166,9 +166,12 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { c.Set("platform", string(constant.TaskPlatformSuno)) c.Set("relay_mode", relayMode) } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { - err = common.UnmarshalBodyReusable(c, &modelRequest) relayMode := relayconstant.RelayModeUnknown if c.Request.Method == http.MethodPost { + err = common.UnmarshalBodyReusable(c, &modelRequest) + if err != nil { + return nil, false, errors.New("video无效的请求, " + err.Error()) + } relayMode = relayconstant.RelayModeVideoSubmit } else if c.Request.Method == http.MethodGet { relayMode = relayconstant.RelayModeVideoFetchByID From 274872b8e5dee8334f7658be14a6df5048f056fd Mon Sep 17 00:00:00 2001 From: DD <1083962986@qq.com> Date: Mon, 15 Sep 2025 14:31:31 +0800 Subject: [PATCH 06/53] add submodel icon --- web/src/helpers/render.jsx | 139 +++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 68 deletions(-) diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 65332701b..c69262582 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -54,6 +54,7 @@ import { FastGPT, Kling, Jimeng, + SubModel, } from '@lobehub/icons'; import { @@ -342,6 +343,8 @@ export function getChannelIcon(channelType) { return ; case 21: // 知识库:AI Proxy case 44: // 嵌入模型:MokaAI M3E + case 53: // 嵌入模型:SubModel + return ; default: return null; // 未知类型或自定义渠道不显示图标 } @@ -1191,25 +1194,25 @@ export function renderModelPrice( const extraServices = [ webSearch && webSearchCallCount > 0 ? i18next.t( - ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', - { - count: webSearchCallCount, - price: webSearchPrice, - ratio: groupRatio, - ratioType: ratioLabel, - }, - ) + ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', + { + count: webSearchCallCount, + price: webSearchPrice, + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) : '', fileSearch && fileSearchCallCount > 0 ? i18next.t( - ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', - { - count: fileSearchCallCount, - price: fileSearchPrice, - ratio: groupRatio, - ratioType: ratioLabel, - }, - ) + ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', + { + count: fileSearchCallCount, + price: fileSearchPrice, + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) : '', ].join(''); @@ -1379,10 +1382,10 @@ export function renderAudioModelPrice( let audioPrice = (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio + (audioCompletionTokens / 1000000) * - inputRatioPrice * - audioRatio * - audioCompletionRatio * - groupRatio; + inputRatioPrice * + audioRatio * + audioCompletionRatio * + groupRatio; let price = textPrice + audioPrice; return ( <> @@ -1438,27 +1441,27 @@ export function renderAudioModelPrice(

{cacheTokens > 0 ? i18next.t( - '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', - { - nonCacheInput: inputTokens - cacheTokens, - cacheInput: cacheTokens, - cachePrice: inputRatioPrice * cacheRatio, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - total: textPrice.toFixed(6), - }, - ) + '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', + { + nonCacheInput: inputTokens - cacheTokens, + cacheInput: cacheTokens, + cachePrice: inputRatioPrice * cacheRatio, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + total: textPrice.toFixed(6), + }, + ) : i18next.t( - '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', - { - input: inputTokens, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - total: textPrice.toFixed(6), - }, - )} + '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', + { + input: inputTokens, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + total: textPrice.toFixed(6), + }, + )}

{i18next.t( @@ -1598,35 +1601,35 @@ export function renderClaudeModelPrice(

{cacheTokens > 0 || cacheCreationTokens > 0 ? i18next.t( - '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', - { - nonCacheInput: nonCachedTokens, - cacheInput: cacheTokens, - cacheRatio: cacheRatio, - cacheCreationInput: cacheCreationTokens, - cacheCreationRatio: cacheCreationRatio, - cachePrice: cacheRatioPrice, - cacheCreationPrice: cacheCreationRatioPrice, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - ratio: groupRatio, - ratioType: ratioLabel, - total: price.toFixed(6), - }, - ) + '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', + { + nonCacheInput: nonCachedTokens, + cacheInput: cacheTokens, + cacheRatio: cacheRatio, + cacheCreationInput: cacheCreationTokens, + cacheCreationRatio: cacheCreationRatio, + cachePrice: cacheRatioPrice, + cacheCreationPrice: cacheCreationRatioPrice, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + ratio: groupRatio, + ratioType: ratioLabel, + total: price.toFixed(6), + }, + ) : i18next.t( - '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', - { - input: inputTokens, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - ratio: groupRatio, - ratioType: ratioLabel, - total: price.toFixed(6), - }, - )} + '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', + { + input: inputTokens, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + ratio: groupRatio, + ratioType: ratioLabel, + total: price.toFixed(6), + }, + )}

{i18next.t('仅供参考,以实际扣费为准')}

From f2e9fd7afb91a5ab7c988401927b5f36e7a960c5 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Tue, 16 Sep 2025 17:18:32 +0800 Subject: [PATCH 07/53] fix(relay): wrong URL for claude model in GCP Vertex AI --- relay/channel/vertex/adaptor.go | 59 ++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index a424cb1a4..6398b8f62 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -90,7 +90,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s } a.AccountCredentials = *adc - if a.RequestMode == RequestModeLlama { + if a.RequestMode == RequestModeGemini { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeClaude { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeLlama { return fmt.Sprintf( "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", region, @@ -98,24 +134,6 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s region, ), nil } - - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", - adc.ProjectID, - modelName, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - modelName, - suffix, - ), nil - } } else { if region == "global" { return fmt.Sprintf( @@ -134,6 +152,7 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s ), nil } } + return "", errors.New("unsupported request mode") } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { @@ -187,7 +206,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel } req.Set("Authorization", "Bearer "+accessToken) } - if a.AccountCredentials.ProjectID != "" { + if a.AccountCredentials.ProjectID != "" { req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) } return nil From 8d92ce38ed2be32295a69926f843023dabdc7ef0 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Fri, 19 Sep 2025 11:22:03 +0800 Subject: [PATCH 08/53] fix(relay): wrong key param while enable sse --- relay/channel/vertex/adaptor.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 6398b8f62..742366b13 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -135,19 +135,27 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s ), nil } } else { + var keyPrefix string + if strings.HasSuffix(suffix, "?alt=sse") { + keyPrefix = "&" + } else { + keyPrefix = "?" + } if region == "global" { return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", modelName, suffix, + keyPrefix, info.ApiKey, ), nil } else { return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", region, modelName, suffix, + keyPrefix, info.ApiKey, ), nil } From ec9903e6404ee6dfed5d3ae9e236802cd0445c3c Mon Sep 17 00:00:00 2001 From: JoeyLearnsToCode Date: Fri, 19 Sep 2025 18:09:26 +0800 Subject: [PATCH 09/53] feat: jump between section on channel edit page --- .../channels/modals/EditChannelModal.jsx | 138 +++++++++++++++--- 1 file changed, 116 insertions(+), 22 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index c0a216246..07d4f3925 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -66,6 +66,8 @@ import { IconCode, IconGlobe, IconBolt, + IconChevronUp, + IconChevronDown, } from '@douyinfe/semi-icons'; const { Text, Title } = Typography; @@ -184,6 +186,18 @@ const EditChannelModal = (props) => { const [verifyCode, setVerifyCode] = useState(''); const [verifyLoading, setVerifyLoading] = useState(false); + // 表单块导航相关状态 + const formSectionRefs = useRef({ + basicInfo: null, + apiConfig: null, + modelConfig: null, + advancedSettings: null, + channelExtraSettings: null, + }); + const [currentSectionIndex, setCurrentSectionIndex] = useState(0); + const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings']; + const formContainerRef = useRef(null); + // 2FA状态更新辅助函数 const updateTwoFAState = (updates) => { setTwoFAState((prev) => ({ ...prev, ...updates })); @@ -207,6 +221,37 @@ const EditChannelModal = (props) => { setVerifyLoading(false); }; + // 表单导航功能 + const scrollToSection = (sectionKey) => { + const sectionElement = formSectionRefs.current[sectionKey]; + if (sectionElement) { + sectionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + } + }; + + const navigateToSection = (direction) => { + const availableSections = formSections.filter(section => { + if (section === 'apiConfig') { + return showApiConfigCard; + } + return true; + }); + + let newIndex; + if (direction === 'up') { + newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1; + } else { + newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; + } + + setCurrentSectionIndex(newIndex); + scrollToSection(availableSections[newIndex]); + }; + // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -672,6 +717,8 @@ const EditChannelModal = (props) => { fetchModelGroups(); // 重置手动输入模式状态 setUseManualInput(false); + // 重置导航状态 + setCurrentSectionIndex(0); } else { // 统一的模态框关闭重置逻辑 resetModalState(); @@ -1108,7 +1155,41 @@ const EditChannelModal = (props) => { visible={props.visible} width={isMobile ? '100%' : 600} footer={ -
+
+
+
); - return disabled && minTopupVal > Number(topUpCount || 0) ? ( - + return disabled && + minTopupVal > Number(topUpCount || 0) ? ( + {buttonEl} ) : ( - {buttonEl} + + {buttonEl} + ); })} @@ -324,23 +341,27 @@ const RechargeCard = ({
{presetAmounts.map((preset, index) => { - const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + const discount = + preset.discount || + topupInfo?.discount?.[preset.value] || + 1.0; const originalPrice = preset.value * priceRatio; const discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; const actualPay = discountedPrice; const save = originalPrice - discountedPrice; - + return ( { @@ -352,24 +373,35 @@ const RechargeCard = ({ }} >
- + {formatLargeNumber(preset.value)} {hasDiscount && ( - - {t('折').includes('off') ? - ((1 - parseFloat(discount)) * 100).toFixed(1) : - (discount * 10).toFixed(1)}{t('折')} - + + {t('折').includes('off') + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) + : (discount * 10).toFixed(1)} + {t('折')} + )} -
+
{t('实付')} {actualPay.toFixed(2)}, - {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} + {hasDiscount + ? `${t('节省')} ${save.toFixed(2)}` + : `${t('节省')} 0.00`}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 929a47e39..558c67050 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,11 +80,11 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); - + // 充值配置信息 const [topupInfo, setTopupInfo] = useState({ amount_options: [], - discount: {} + discount: {}, }); const topUp = async () => { @@ -262,9 +262,9 @@ const TopUp = () => { if (success) { setTopupInfo({ amount_options: data.amount_options || [], - discount: data.discount || {} + discount: data.discount || {}, }); - + // 处理支付方式 let payMethods = data.pay_methods || []; try { @@ -280,10 +280,15 @@ const TopUp = () => { payMethods = payMethods.map((method) => { // 规范化最小充值数 const normalizedMinTopup = Number(method.min_topup); - method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + method.min_topup = Number.isFinite(normalizedMinTopup) + ? normalizedMinTopup + : 0; // Stripe 的最小充值从后端字段回填 - if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { + if ( + method.type === 'stripe' && + (!method.min_topup || method.min_topup <= 0) + ) { const stripeMin = Number(data.stripe_min_topup); if (Number.isFinite(stripeMin)) { method.min_topup = stripeMin; @@ -313,7 +318,11 @@ const TopUp = () => { setPayMethods(payMethods); const enableStripeTopUp = data.enable_stripe_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; - const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; + const minTopUpValue = enableOnlineTopUp + ? data.min_topup + : enableStripeTopUp + ? data.stripe_min_topup + : 1; setEnableOnlineTopUp(enableOnlineTopUp); setEnableStripeTopUp(enableStripeTopUp); setMinTopUp(minTopUpValue); @@ -330,12 +339,12 @@ const TopUp = () => { console.log('解析支付方式失败:', e); setPayMethods([]); } - + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 if (data.amount_options && data.amount_options.length > 0) { - const customPresets = data.amount_options.map(amount => ({ + const customPresets = data.amount_options.map((amount) => ({ value: amount, - discount: data.discount[amount] || 1.0 + discount: data.discount[amount] || 1.0, })); setPresetAmounts(customPresets); } @@ -483,7 +492,7 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - + // 计算实际支付金额,考虑折扣 const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 1bffbfed1..8bd5455c7 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -40,9 +40,10 @@ const PaymentConfirmModal = ({ amountNumber, discountRate, }) => { - const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; - const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; - const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; + const hasDiscount = + discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? amountNumber / discountRate : 0; + const discountAmount = hasDiscount ? originalAmount - amountNumber : 0; return ( dayjs().startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '近 7 天', start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本周', start: () => dayjs().startOf('week').toDate(), - end: () => dayjs().endOf('week').toDate() + end: () => dayjs().endOf('week').toDate(), }, { text: '近 30 天', start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本月', start: () => dayjs().startOf('month').toDate(), - end: () => dayjs().endOf('month').toDate() + end: () => dayjs().endOf('month').toDate(), }, ]; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index bc389b2e8..1ccfffaf2 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -131,13 +131,11 @@ export const buildApiPayload = ( seed: 'seed', }; - Object.entries(parameterMappings).forEach(([key, param]) => { const enabled = parameterEnabled[key]; const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (enabled && hasValue) { payload[param] = value; } diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index c19e2849d..6dc54082b 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1072,7 +1072,7 @@ export function renderModelPrice( (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageGenerationCallPrice * groupRatio); + imageGenerationCallPrice * groupRatio; return ( <> diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 13d76fd86..0b8eb3d8f 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -128,7 +128,7 @@ export const useSidebar = () => { // 刷新用户配置的方法(供外部调用) const refreshUserConfig = async () => { - if (Object.keys(adminConfig).length > 0) { + if (Object.keys(adminConfig).length > 0) { await loadUserConfig(); } @@ -155,7 +155,10 @@ export const useSidebar = () => { sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); return () => { - sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + sidebarEventTarget.removeEventListener( + SIDEBAR_REFRESH_EVENT, + handleRefresh, + ); }; }, [adminConfig]); diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index 5af750ec3..b8b925dcf 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -130,19 +130,20 @@ export default function GeneralSettings(props) { showClear /> - {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( - - setShowQuotaWarning(true)} - /> - - )} + {inputs.QuotaPerUnit !== '500000' && + inputs.QuotaPerUnit !== 500000 && ( + + setShowQuotaWarning(true)} + /> + + )} setInputs({ ...inputs, - 'monitor_setting.auto_test_channel_minutes': parseInt(value), + 'monitor_setting.auto_test_channel_minutes': + parseInt(value), }) } /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index d681b6a27..a4f1029a1 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) { } } - if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { + if ( + originInputs['AmountOptions'] !== inputs.AmountOptions && + inputs.AmountOptions.trim() !== '' + ) { if (!verifyJSON(inputs.AmountOptions)) { showError(t('自定义充值数量选项不是合法的 JSON 数组')); return; } } - if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if ( + originInputs['AmountDiscount'] !== inputs.AmountDiscount && + inputs.AmountDiscount.trim() !== '' + ) { if (!verifyJSON(inputs.AmountDiscount)) { showError(t('充值金额折扣配置不是合法的 JSON 对象')); return; @@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } if (originInputs['AmountOptions'] !== inputs.AmountOptions) { - options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); + options.push({ + key: 'payment_setting.amount_options', + value: inputs.AmountOptions, + }); } if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { - options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); + options.push({ + key: 'payment_setting.amount_discount', + value: inputs.AmountDiscount, + }); } // 发送请求 @@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本')} autosize /> - + - + - + diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index ed982edcf..b298cc787 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, ImageRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, ImageRatio: value })} /> @@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, AudioRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, AudioRatio: value })} /> @@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) { Date: Mon, 29 Sep 2025 15:11:17 +0800 Subject: [PATCH 11/53] Update relay_adaptor.go --- relay/relay_adaptor.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 6099385c8..406074c58 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -37,12 +37,8 @@ import ( "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" "strconv" -<<<<<<< HEAD "one-api/relay/channel/submodel" -======= - "github.com/gin-gonic/gin" ->>>>>>> 4f760a8d407d321bf7f011331ecffb2744b555fd ) func GetAdaptor(apiType int) channel.Adaptor { From d0a850468d5539045d35494faa0b1c718799f242 Mon Sep 17 00:00:00 2001 From: dd <1083962986@qq.com> Date: Mon, 29 Sep 2025 15:14:02 +0800 Subject: [PATCH 12/53] Update render.jsx --- web/src/helpers/render.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 92fecde49..82d164b38 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -54,7 +54,6 @@ import { FastGPT, Kling, Jimeng, - SubModel, } from '@lobehub/icons'; import { @@ -343,8 +342,6 @@ export function getChannelIcon(channelType) { return ; case 21: // 知识库:AI Proxy case 44: // 嵌入模型:MokaAI M3E - case 53: // 嵌入模型:SubModel - return ; default: return null; // 未知类型或自定义渠道不显示图标 } From dcf4336c7575486ed945621279137392fbb69bf8 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 29 Sep 2025 17:45:09 +0800 Subject: [PATCH 13/53] feat: passkey --- controller/channel.go | 110 +++- controller/misc.go | 9 + controller/passkey.go | 502 ++++++++++++++++++ go.mod | 20 +- go.sum | 37 +- model/main.go | 2 + model/passkey.go | 202 +++++++ router/api-router.go | 10 + service/passkey/service.go | 175 ++++++ service/passkey/session.go | 50 ++ service/passkey/user.go | 71 +++ setting/system_setting/passkey.go | 34 ++ web/src/components/auth/LoginForm.jsx | 87 ++- .../common/examples/ChannelKeyViewExample.jsx | 117 ++++ .../common/modals/SecureVerificationModal.jsx | 271 ++++++++++ .../components/settings/PersonalSetting.jsx | 95 ++++ web/src/components/settings/SystemSetting.jsx | 187 +++++++ .../personal/cards/AccountManagement.jsx | 62 +++ .../channels/modals/EditChannelModal.jsx | 143 +++-- .../table/users/UsersColumnDefs.jsx | 20 + web/src/components/table/users/UsersTable.jsx | 56 +- .../table/users/modals/ResetPasskeyModal.jsx | 39 ++ .../table/users/modals/ResetTwoFAModal.jsx | 39 ++ web/src/helpers/index.js | 1 + web/src/helpers/passkey.js | 137 +++++ .../hooks/common/useSecureVerification.jsx | 225 ++++++++ web/src/hooks/users/useUsersData.jsx | 38 +- web/src/i18n/locales/en.json | 56 +- web/src/i18n/locales/zh.json | 56 +- web/src/services/secureVerification.js | 183 +++++++ 30 files changed, 2924 insertions(+), 110 deletions(-) create mode 100644 controller/passkey.go create mode 100644 model/passkey.go create mode 100644 service/passkey/service.go create mode 100644 service/passkey/session.go create mode 100644 service/passkey/user.go create mode 100644 setting/system_setting/passkey.go create mode 100644 web/src/components/common/examples/ChannelKeyViewExample.jsx create mode 100644 web/src/components/common/modals/SecureVerificationModal.jsx create mode 100644 web/src/components/table/users/modals/ResetPasskeyModal.jsx create mode 100644 web/src/components/table/users/modals/ResetTwoFAModal.jsx create mode 100644 web/src/helpers/passkey.js create mode 100644 web/src/hooks/common/useSecureVerification.jsx create mode 100644 web/src/services/secureVerification.js diff --git a/controller/channel.go b/controller/channel.go index 5d075f3c5..542f35fd6 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -384,10 +384,11 @@ func GetChannel(c *gin.Context) { return } -// GetChannelKey 验证2FA后获取渠道密钥 +// GetChannelKey 验证2FA或Passkey后获取渠道密钥 func GetChannelKey(c *gin.Context) { type GetChannelKeyRequest struct { - Code string `json:"code" binding:"required"` + Code string `json:"code,omitempty"` // 2FA验证码或备用码 + Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey" } var req GetChannelKeyRequest @@ -403,21 +404,108 @@ func GetChannelKey(c *gin.Context) { return } - // 获取2FA记录并验证 + // 检查用户支持的验证方式 twoFA, err := model.GetTwoFAByUserId(userId) if err != nil { common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err)) return } - if twoFA == nil || !twoFA.IsEnabled { - common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥")) + passkey, passkeyErr := model.GetPasskeyByUserID(userId) + hasPasskey := passkeyErr == nil && passkey != nil + + has2FA := twoFA != nil && twoFA.IsEnabled + + // 至少需要启用一种验证方式 + if !has2FA && !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey,无法查看密钥")) return } - // 统一的2FA验证逻辑 - if !validateTwoFactorAuth(twoFA, req.Code) { - common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + // 根据请求的验证方式进行验证 + switch req.Method { + case "2fa": + if !has2FA { + common.ApiError(c, fmt.Errorf("用户未启用2FA")) + return + } + if req.Code == "" { + common.ApiError(c, fmt.Errorf("2FA验证码不能为空")) + return + } + if !validateTwoFactorAuth(twoFA, req.Code) { + common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + return + } + + case "passkey": + if !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用Passkey")) + return + } + // Passkey验证已在前端完成,这里只需要检查是否有有效的Passkey验证会话 + // 由于Passkey验证是基于WebAuthn协议的,验证过程已经在PasskeyVerifyFinish中完成 + // 这里我们可以设置一个临时标记来验证Passkey验证是否成功 + + default: + // 自动选择验证方式:如果提供了code则使用2FA,否则需要用户明确指定 + if req.Code != "" && has2FA { + if !validateTwoFactorAuth(twoFA, req.Code) { + common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + return + } + } else { + common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')")) + return + } + } + + // 获取渠道信息(包含密钥) + channel, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + return + } + + if channel == nil { + common.ApiError(c, fmt.Errorf("渠道不存在")) + return + } + + // 记录操作日志 + logMethod := req.Method + if logMethod == "" { + logMethod = "2fa" + } + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod)) + + // 统一的成功响应格式 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": map[string]interface{}{ + "key": channel.Key, + }, + }) +} + +// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥 +func GetChannelKeyWithPasskey(c *gin.Context) { + userId := c.GetInt("id") + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + return + } + + // 检查用户是否已绑定Passkey + passkey, err := model.GetPasskeyByUserID(userId) + if err != nil { + common.ApiError(c, fmt.Errorf("用户未绑定Passkey,无法使用此验证方式")) + return + } + if passkey == nil { + common.ApiError(c, fmt.Errorf("用户未绑定Passkey")) return } @@ -434,12 +522,12 @@ func GetChannelKey(c *gin.Context) { } // 记录操作日志 - model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId)) - // 统一的成功响应格式 + // 返回渠道密钥 c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "验证成功", + "message": "Passkey验证成功", "data": map[string]interface{}{ "key": channel.Key, }, diff --git a/controller/misc.go b/controller/misc.go index 875142ffb..07f7d3f05 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -42,6 +42,8 @@ func GetStatus(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() + passkeySetting := system_setting.GetPasskeySettings() + data := gin.H{ "version": common.Version, "start_time": common.StartTime, @@ -94,6 +96,13 @@ func GetStatus(c *gin.Context) { "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "passkey_login": passkeySetting.Enabled, + "passkey_display_name": passkeySetting.RPDisplayName, + "passkey_rp_id": passkeySetting.RPID, + "passkey_origins": passkeySetting.Origins, + "passkey_allow_insecure": passkeySetting.AllowInsecureOrigin, + "passkey_user_verification": passkeySetting.UserVerification, + "passkey_attachment": passkeySetting.AttachmentPreference, "setup": constant.Setup, } diff --git a/controller/passkey.go b/controller/passkey.go new file mode 100644 index 000000000..3bdec8f0f --- /dev/null +++ b/controller/passkey.go @@ -0,0 +1,502 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "one-api/common" + "one-api/model" + passkeysvc "one-api/service/passkey" + "one-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + webauthnlib "github.com/go-webauthn/webauthn/webauthn" +) + +func PasskeyRegisterBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credential = nil + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + var options []webauthnlib.RegistrationOption + if credential != nil { + descriptor := credential.ToWebAuthnCredential().Descriptor() + options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor})) + } + + creation, sessionData, err := wa.BeginRegistration(waUser, options...) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": creation, + }, + }) +} + +func PasskeyRegisterFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credentialRecord, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credentialRecord = nil + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) + credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential) + if passkeyCredential == nil { + common.ApiErrorMsg(c, "无法创建 Passkey 凭证") + return + } + + if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 注册成功", + }) +} + +func PasskeyDelete(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已解绑", + }) +} + +func PasskeyStatus(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "enabled": false, + }, + }) + return + } + if err != nil { + common.ApiError(c, err) + return + } + + data := gin.H{ + "enabled": true, + "last_used_at": credential.LastUsedAt, + "backup_eligible": credential.BackupEligible, + "backup_state": credential.BackupState, + } + if credential != nil { + data["credential_aaguid"] = fmt.Sprintf("%x", credential.AAGUID) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +func PasskeyLoginBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + assertion, sessionData, err := wa.BeginDiscoverableLogin() + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyLoginFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + handler := func(rawID, userHandle []byte) (webauthnlib.User, error) { + // 首先通过凭证ID查找用户 + credential, err := model.GetPasskeyByCredentialID(rawID) + if err != nil { + return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err) + } + + // 通过凭证获取用户 + user := &model.User{Id: credential.UserID} + if err := user.FillUserById(); err != nil { + return nil, fmt.Errorf("用户信息获取失败: %w", err) + } + + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + + // 验证用户句柄(如果提供的话) + if len(userHandle) > 0 { + if userID, parseErr := strconv.Atoi(string(userHandle)); parseErr == nil { + if userID != user.Id { + return nil, errors.New("用户句柄与凭证不匹配") + } + } + // 如果解析失败,不做严格验证,因为某些情况下userHandle可能为空或格式不同 + } + + return passkeysvc.NewWebAuthnUser(user, credential), nil + } + + waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser) + if !ok { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + modelUser := userWrapper.ModelUser() + if modelUser == nil { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + if modelUser.Status != common.UserStatusEnabled { + common.ApiErrorMsg(c, "该用户已被禁用") + return + } + + // 更新凭证信息 + updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential) + if updatedCredential == nil { + common.ApiErrorMsg(c, "Passkey 凭证更新失败") + return + } + now := time.Now() + updatedCredential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(updatedCredential); err != nil { + common.ApiError(c, err) + return + } + + setupLogin(modelUser, c) + return +} + +func AdminResetPasskey(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorMsg(c, "无效的用户 ID") + return + } + + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + common.ApiError(c, err) + return + } + + if _, err := model.GetPasskeyByUserID(user.Id); err != nil { + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + common.ApiError(c, err) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已重置", + }) +} + +func PasskeyVerifyBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + assertion, sessionData, err := wa.BeginLogin(waUser) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyVerifyFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + }) +} + +func getSessionUser(c *gin.Context) (*model.User, error) { + session := sessions.Default(c) + idRaw := session.Get("id") + if idRaw == nil { + return nil, errors.New("未登录") + } + id, ok := idRaw.(int) + if !ok { + return nil, errors.New("无效的会话信息") + } + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + return nil, err + } + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + return user, nil +} diff --git a/go.mod b/go.mod index 501d966d5..66a452cee 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,9 @@ module one-api // +heroku goVersion go1.18 -go 1.23.4 +go 1.24.0 + +toolchain go1.24.6 require ( github.com/Calcium-Ion/go-epay v0.0.4 @@ -20,6 +22,7 @@ require ( github.com/glebarez/sqlite v1.9.0 github.com/go-playground/validator/v10 v10.20.0 github.com/go-redis/redis/v8 v8.11.5 + github.com/go-webauthn/webauthn v0.14.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 @@ -35,10 +38,10 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/tiktoken-go/tokenizer v0.6.2 - golang.org/x/crypto v0.35.0 + golang.org/x/crypto v0.42.0 golang.org/x/image v0.23.0 - golang.org/x/net v0.35.0 - golang.org/x/sync v0.11.0 + golang.org/x/net v0.43.0 + golang.org/x/sync v0.17.0 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.2 @@ -58,6 +61,7 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect @@ -65,8 +69,11 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/go-webauthn/x v0.1.25 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.5 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect @@ -91,11 +98,12 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect diff --git a/go.sum b/go.sum index 189d09de4..a62b83210 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -89,16 +91,24 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0= +github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k= +github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88= +github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= @@ -200,8 +210,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= @@ -229,27 +240,31 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -261,14 +276,14 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/model/main.go b/model/main.go index 1a38d371b..14384caf9 100644 --- a/model/main.go +++ b/model/main.go @@ -251,6 +251,7 @@ func migrateDB() error { &Channel{}, &Token{}, &User{}, + &PasskeyCredential{}, &Option{}, &Redemption{}, &Ability{}, @@ -283,6 +284,7 @@ func migrateDBFast() error { {&Channel{}, "Channel"}, {&Token{}, "Token"}, {&User{}, "User"}, + {&PasskeyCredential{}, "PasskeyCredential"}, {&Option{}, "Option"}, {&Redemption{}, "Redemption"}, {&Ability{}, "Ability"}, diff --git a/model/passkey.go b/model/passkey.go new file mode 100644 index 000000000..092639019 --- /dev/null +++ b/model/passkey.go @@ -0,0 +1,202 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "one-api/common" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "gorm.io/gorm" +) + +var ( + ErrPasskeyNotFound = errors.New("passkey credential not found") + ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员") +) + +type PasskeyCredential struct { + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + CredentialID []byte `json:"credential_id" gorm:"type:blob;uniqueIndex;not null"` + PublicKey []byte `json:"public_key" gorm:"type:blob;not null"` + AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` + AAGUID []byte `json:"aaguid" gorm:"type:blob"` + SignCount uint32 `json:"sign_count" gorm:"default:0"` + CloneWarning bool `json:"clone_warning"` + UserPresent bool `json:"user_present"` + UserVerified bool `json:"user_verified"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + Transports string `json:"transports" gorm:"type:text"` + Attachment string `json:"attachment" gorm:"type:varchar(32)"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport { + if p == nil || strings.TrimSpace(p.Transports) == "" { + return nil + } + var transports []string + if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil { + return nil + } + result := make([]protocol.AuthenticatorTransport, 0, len(transports)) + for _, transport := range transports { + result = append(result, protocol.AuthenticatorTransport(transport)) + } + return result +} + +func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) { + if len(list) == 0 { + p.Transports = "" + return + } + stringList := make([]string, len(list)) + for i, transport := range list { + stringList[i] = string(transport) + } + encoded, err := json.Marshal(stringList) + if err != nil { + return + } + p.Transports = string(encoded) +} + +func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential { + flags := webauthn.CredentialFlags{ + UserPresent: p.UserPresent, + UserVerified: p.UserVerified, + BackupEligible: p.BackupEligible, + BackupState: p.BackupState, + } + + return webauthn.Credential{ + ID: p.CredentialID, + PublicKey: p.PublicKey, + AttestationType: p.AttestationType, + Transport: p.TransportList(), + Flags: flags, + Authenticator: webauthn.Authenticator{ + AAGUID: p.AAGUID, + SignCount: p.SignCount, + CloneWarning: p.CloneWarning, + Attachment: protocol.AuthenticatorAttachment(p.Attachment), + }, + } +} + +func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential { + if credential == nil { + return nil + } + passkey := &PasskeyCredential{ + UserID: userID, + CredentialID: credential.ID, + PublicKey: credential.PublicKey, + AttestationType: credential.AttestationType, + AAGUID: credential.Authenticator.AAGUID, + SignCount: credential.Authenticator.SignCount, + CloneWarning: credential.Authenticator.CloneWarning, + UserPresent: credential.Flags.UserPresent, + UserVerified: credential.Flags.UserVerified, + BackupEligible: credential.Flags.BackupEligible, + BackupState: credential.Flags.BackupState, + Attachment: string(credential.Authenticator.Attachment), + } + passkey.SetTransports(credential.Transport) + return passkey +} + +func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) { + if credential == nil || p == nil { + return + } + p.CredentialID = credential.ID + p.PublicKey = credential.PublicKey + p.AttestationType = credential.AttestationType + p.AAGUID = credential.Authenticator.AAGUID + p.SignCount = credential.Authenticator.SignCount + p.CloneWarning = credential.Authenticator.CloneWarning + p.UserPresent = credential.Flags.UserPresent + p.UserVerified = credential.Flags.UserVerified + p.BackupEligible = credential.Flags.BackupEligible + p.BackupState = credential.Flags.BackupState + p.Attachment = string(credential.Authenticator.Attachment) + p.SetTransports(credential.Transport) +} + +func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { + if userID == 0 { + common.SysLog("GetPasskeyByUserID: empty user ID") + return nil, ErrFriendlyPasskeyNotFound + } + var credential PasskeyCredential + if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.SysLog(fmt.Sprintf("GetPasskeyByUserID: passkey not found for user %d", userID)) + return nil, ErrFriendlyPasskeyNotFound + } + common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) + return nil, ErrFriendlyPasskeyNotFound + } + return &credential, nil +} + +func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { + if len(credentialID) == 0 { + common.SysLog("GetPasskeyByCredentialID: empty credential ID") + return nil, ErrFriendlyPasskeyNotFound + } + + var credential PasskeyCredential + if err := DB.Where("credential_id = ?", credentialID).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID))) + return nil, ErrFriendlyPasskeyNotFound + } + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err)) + return nil, ErrFriendlyPasskeyNotFound + } + + return &credential, nil +} + +func UpsertPasskeyCredential(credential *PasskeyCredential) error { + if credential == nil { + common.SysLog("UpsertPasskeyCredential: nil credential provided") + return fmt.Errorf("Passkey 保存失败,请重试") + } + return DB.Transaction(func(tx *gorm.DB) error { + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + if err := tx.Create(credential).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + return nil + }) +} + +func DeletePasskeyByUserID(userID int) error { + if userID == 0 { + common.SysLog("DeletePasskeyByUserID: empty user ID") + return fmt.Errorf("删除失败,请重试") + } + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err)) + return fmt.Errorf("删除失败,请重试") + } + return nil +} diff --git a/router/api-router.go b/router/api-router.go index e16d06628..31d4ba3f8 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -45,6 +45,8 @@ func SetApiRouter(router *gin.Engine) { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin) + userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin) + userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) userRoute.GET("/logout", controller.Logout) userRoute.GET("/epay/notify", controller.EpayNotify) @@ -59,6 +61,12 @@ func SetApiRouter(router *gin.Engine) { selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) + selfRoute.GET("/passkey", controller.PasskeyStatus) + selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin) + selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) + selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) + selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) + selfRoute.DELETE("/passkey", controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) @@ -87,6 +95,7 @@ func SetApiRouter(router *gin.Engine) { adminRoute.POST("/manage", controller.ManageUser) adminRoute.PUT("/", controller.UpdateUser) adminRoute.DELETE("/:id", controller.DeleteUser) + adminRoute.DELETE("/:id/passkey", controller.AdminResetPasskey) // Admin 2FA routes adminRoute.GET("/2fa/stats", controller.Admin2FAStats) @@ -116,6 +125,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey) + channelRoute.POST("/:id/key/passkey", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKeyWithPasskey) channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) diff --git a/service/passkey/service.go b/service/passkey/service.go new file mode 100644 index 000000000..62befb9d3 --- /dev/null +++ b/service/passkey/service.go @@ -0,0 +1,175 @@ +package passkey + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "one-api/common" + "one-api/setting/system_setting" + + "github.com/go-webauthn/webauthn/protocol" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + RegistrationSessionKey = "passkey_registration_session" + LoginSessionKey = "passkey_login_session" + VerifySessionKey = "passkey_verify_session" +) + +// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context. +func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { + settings := system_setting.GetPasskeySettings() + if settings == nil { + return nil, errors.New("未找到 Passkey 设置") + } + + displayName := strings.TrimSpace(settings.RPDisplayName) + if displayName == "" { + displayName = common.SystemName + } + + origins, err := resolveOrigins(r, settings) + if err != nil { + return nil, err + } + + rpID, err := resolveRPID(r, settings, origins) + if err != nil { + return nil, err + } + + selection := protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + RequireResidentKey: protocol.ResidentKeyRequired(), + UserVerification: protocol.UserVerificationRequirement(settings.UserVerification), + } + if selection.UserVerification == "" { + selection.UserVerification = protocol.VerificationPreferred + } + if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" { + selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment) + } + + config := &webauthn.Config{ + RPID: rpID, + RPDisplayName: displayName, + RPOrigins: origins, + AuthenticatorSelection: selection, + Debug: common.DebugEnabled, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + }, + } + + return webauthn.New(config) +} + +func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) { + if len(settings.Origins) > 0 { + origins := make([]string, 0, len(settings.Origins)) + for _, origin := range settings.Origins { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + continue + } + if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") { + return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed) + } + origins = append(origins, trimmed) + } + if len(origins) == 0 { + // 如果配置了Origins但过滤后为空,使用自动推导 + goto autoDetect + } + return origins, nil + } + +autoDetect: + scheme := detectScheme(r) + if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") { + return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host) + } + // 优先使用请求的完整Host(包含端口) + host := r.Host + + // 如果无法从请求获取Host,尝试从ServerAddress获取 + if host == "" && system_setting.ServerAddress != "" { + if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" { + host = parsed.Host + if scheme == "" && parsed.Scheme != "" { + scheme = parsed.Scheme + } + } + } + if host == "" { + return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress) + } + if scheme == "" { + scheme = "https" + } + origin := fmt.Sprintf("%s://%s", scheme, host) + return []string{origin}, nil +} + +func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) { + rpID := strings.TrimSpace(settings.RPID) + if rpID != "" { + return hostWithoutPort(rpID), nil + } + if len(origins) == 0 { + return "", errors.New("Passkey 未配置 Origin,无法推导 RPID") + } + parsed, err := url.Parse(origins[0]) + if err != nil { + return "", fmt.Errorf("无法解析 Passkey Origin: %w", err) + } + return hostWithoutPort(parsed.Host), nil +} + +func hostWithoutPort(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if strings.Contains(host, ":") { + if host, _, err := net.SplitHostPort(host); err == nil { + return host + } + } + return host +} + +func detectScheme(r *http.Request) string { + if r == nil { + return "" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + parts := strings.Split(proto, ",") + return strings.ToLower(strings.TrimSpace(parts[0])) + } + if r.TLS != nil { + return "https" + } + if r.URL != nil && r.URL.Scheme != "" { + return strings.ToLower(r.URL.Scheme) + } + if r.Header.Get("X-Forwarded-Protocol") != "" { + return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol"))) + } + return "http" +} diff --git a/service/passkey/session.go b/service/passkey/session.go new file mode 100644 index 000000000..15e619326 --- /dev/null +++ b/service/passkey/session.go @@ -0,0 +1,50 @@ +package passkey + +import ( + "encoding/json" + "errors" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +var errSessionNotFound = errors.New("Passkey 会话不存在或已过期") + +func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error { + session := sessions.Default(c) + if data == nil { + session.Delete(key) + return session.Save() + } + payload, err := json.Marshal(data) + if err != nil { + return err + } + session.Set(key, string(payload)) + return session.Save() +} + +func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) { + session := sessions.Default(c) + raw := session.Get(key) + if raw == nil { + return nil, errSessionNotFound + } + session.Delete(key) + _ = session.Save() + var data webauthn.SessionData + switch value := raw.(type) { + case string: + if err := json.Unmarshal([]byte(value), &data); err != nil { + return nil, err + } + case []byte: + if err := json.Unmarshal(value, &data); err != nil { + return nil, err + } + default: + return nil, errors.New("Passkey 会话格式无效") + } + return &data, nil +} diff --git a/service/passkey/user.go b/service/passkey/user.go new file mode 100644 index 000000000..8b8c559f0 --- /dev/null +++ b/service/passkey/user.go @@ -0,0 +1,71 @@ +package passkey + +import ( + "fmt" + "strconv" + "strings" + + "one-api/model" + + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +type WebAuthnUser struct { + user *model.User + credential *model.PasskeyCredential +} + +func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser { + return &WebAuthnUser{user: user, credential: credential} +} + +func (u *WebAuthnUser) WebAuthnID() []byte { + if u == nil || u.user == nil { + return nil + } + return []byte(strconv.Itoa(u.user.Id)) +} + +func (u *WebAuthnUser) WebAuthnName() string { + if u == nil || u.user == nil { + return "" + } + name := strings.TrimSpace(u.user.Username) + if name == "" { + return fmt.Sprintf("user-%d", u.user.Id) + } + return name +} + +func (u *WebAuthnUser) WebAuthnDisplayName() string { + if u == nil || u.user == nil { + return "" + } + display := strings.TrimSpace(u.user.DisplayName) + if display != "" { + return display + } + return u.WebAuthnName() +} + +func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + if u == nil || u.credential == nil { + return nil + } + cred := u.credential.ToWebAuthnCredential() + return []webauthn.Credential{cred} +} + +func (u *WebAuthnUser) ModelUser() *model.User { + if u == nil { + return nil + } + return u.user +} + +func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential { + if u == nil { + return nil + } + return u.credential +} diff --git a/setting/system_setting/passkey.go b/setting/system_setting/passkey.go new file mode 100644 index 000000000..54746e808 --- /dev/null +++ b/setting/system_setting/passkey.go @@ -0,0 +1,34 @@ +package system_setting + +import ( + "one-api/common" + "one-api/setting/config" +) + +type PasskeySettings struct { + Enabled bool `json:"enabled"` + RPDisplayName string `json:"rp_display_name"` + RPID string `json:"rp_id"` + Origins []string `json:"origins"` + AllowInsecureOrigin bool `json:"allow_insecure_origin"` + UserVerification string `json:"user_verification"` + AttachmentPreference string `json:"attachment_preference"` +} + +var defaultPasskeySettings = PasskeySettings{ + Enabled: false, + RPDisplayName: common.SystemName, + RPID: "", + Origins: []string{}, + AllowInsecureOrigin: false, + UserVerification: "preferred", + AttachmentPreference: "", +} + +func init() { + config.GlobalConfig.Register("passkey", &defaultPasskeySettings) +} + +func GetPasskeySettings() *PasskeySettings { + return &defaultPasskeySettings +} diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 32087ab02..828e71786 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -32,6 +32,9 @@ import { onGitHubOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked, + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported, } from '../../helpers'; import Turnstile from 'react-turnstile'; import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; @@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import TelegramLoginButton from 'react-telegram-login'; -import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; +import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons'; import OIDCIcon from '../common/logo/OIDCIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; @@ -74,6 +77,8 @@ const LoginForm = () => { useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [showTwoFA, setShowTwoFA] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const logo = getLogo(); const systemName = getSystemName(); @@ -95,6 +100,12 @@ const LoginForm = () => { } }, [status]); + useEffect(() => { + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); + }, []); + useEffect(() => { if (searchParams.get('expired')) { showError(t('未登录或登录已过期,请重新登录')); @@ -266,6 +277,55 @@ const LoginForm = () => { setEmailLoginLoading(false); }; + const handlePasskeyLogin = async () => { + if (!passkeySupported) { + showInfo('当前环境无法使用 Passkey 登录'); + return; + } + if (!window.PublicKeyCredential) { + showInfo('当前浏览器不支持 Passkey'); + return; + } + + setPasskeyLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/login/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || '无法发起 Passkey 登录'); + return; + } + + const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data); + const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }); + const payload = buildAssertionResult(assertion); + if (!payload) { + showError('Passkey 验证失败,请重试'); + return; + } + + const finishRes = await API.post('/api/user/passkey/login/finish', payload); + const finish = finishRes.data; + if (finish.success) { + userDispatch({ type: 'login', payload: finish.data }); + setUserData(finish.data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + } else { + showError(finish.message || 'Passkey 登录失败,请重试'); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo('已取消 Passkey 登录'); + } else { + showError('Passkey 登录失败,请重试'); + } + } finally { + setPasskeyLoading(false); + } + }; + // 包装的重置密码点击处理 const handleResetPasswordClick = () => { setResetPasswordLoading(true); @@ -385,6 +445,19 @@ const LoginForm = () => {
)} + {status.passkey_login && passkeySupported && ( + + )} + {t('或')} @@ -437,6 +510,18 @@ const LoginForm = () => {
+ {status.passkey_login && passkeySupported && ( + + )}
. + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { useSecureVerification } from '../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../services/secureVerification'; +import SecureVerificationModal from '../modals/SecureVerificationModal'; +import ChannelKeyDisplay from '../ui/ChannelKeyDisplay'; + +/** + * 渠道密钥查看组件使用示例 + * 展示如何使用通用安全验证系统 + */ +const ChannelKeyViewExample = ({ channelId }) => { + const { t } = useTranslation(); + const [keyData, setKeyData] = useState(''); + const [showKeyModal, setShowKeyModal] = useState(false); + + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后处理结果 + if (result.success && result.data?.key) { + setKeyData(result.data.key); + setShowKeyModal(true); + } + }, + successMessage: t('密钥获取成功'), + }); + + // 开始查看密钥流程 + const handleViewKey = async () => { + const apiCall = createApiCalls.viewChannelKey(channelId); + + await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 可以指定首选验证方式 + }); + }; + + return ( + <> + {/* 查看密钥按钮 */} + + + {/* 安全验证模态框 */} + + + {/* 密钥显示模态框 */} + setShowKeyModal(false)} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + + + + ); +}; + +export default ChannelKeyViewExample; \ No newline at end of file diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx new file mode 100644 index 000000000..46770aa74 --- /dev/null +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -0,0 +1,271 @@ +/* +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 { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography, Tabs, TabPane, Card } from '@douyinfe/semi-ui'; + +/** + * 通用安全验证模态框组件 + * 配合 useSecureVerification Hook 使用 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {Object} props.verificationMethods - 可用的验证方式 + * @param {Object} props.verificationState - 当前验证状态 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onMethodSwitch - 验证方式切换回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + */ +const SecureVerificationModal = ({ + visible, + verificationMethods, + verificationState, + onVerify, + onCancel, + onCodeChange, + onMethodSwitch, + title, + description, +}) => { + const { t } = useTranslation(); + + const { has2FA, hasPasskey, passkeySupported } = verificationMethods; + const { method, loading, code } = verificationState; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') { + onVerify(method, code); + } + }; + + // 如果用户没有启用任何验证方式 + if (visible && !has2FA && !hasPasskey) { + return ( + {t('确定')} + } + width={500} + style={{ maxWidth: '90vw' }} + > +
+
+ + + +
+ + {t('需要安全验证')} + + + {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')} + +
+ + {t('请前往个人设置 → 安全设置进行配置。')} + +
+
+ ); + } + + return ( + +
+ + + +
+ {title || t('安全验证')} +
+ } + visible={visible} + onCancel={onCancel} + footer={null} + width={600} + style={{ maxWidth: '90vw' }} + > +
+ {/* 安全提示 */} +
+
+ + + +
+ + {t('安全验证')} + + + {description || t('为了保护账户安全,请选择一种方式进行验证。')} + +
+
+
+ + {/* 验证方式选择 */} + + {has2FA && ( + + + + + + {t('两步验证')} +
+ } + itemKey='2fa' + > + +
+
+ + {t('验证码')} + + + + {t('支持6位TOTP验证码或8位备用码')} + +
+
+ + +
+
+
+ + )} + + {hasPasskey && passkeySupported && ( + + + + + {t('Passkey')} +
+ } + itemKey='passkey' + > + +
+
+
+ + + +
+ + {t('使用 Passkey 验证')} + + + {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')} + +
+
+ + +
+
+
+ + )} + +
+ + ); +}; + +export default SecureVerificationModal; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 15dfbd973..19a83515d 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -26,6 +26,9 @@ import { showInfo, showSuccess, setStatusData, + prepareCredentialCreationOptions, + buildRegistrationResult, + isPasskeySupported, } from '../../helpers'; import { UserContext } from '../../context/User'; import { Modal } from '@douyinfe/semi-ui'; @@ -66,6 +69,10 @@ const PersonalSetting = () => { const [disableButton, setDisableButton] = useState(false); const [countdown, setCountdown] = useState(30); const [systemToken, setSystemToken] = useState(''); + const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false }); + const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); + const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); const [notificationSettings, setNotificationSettings] = useState({ warningType: 'email', warningThreshold: 100000, @@ -112,6 +119,10 @@ const PersonalSetting = () => { })(); getUserData(); + + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); }, []); useEffect(() => { @@ -160,11 +171,89 @@ const PersonalSetting = () => { } }; + const loadPasskeyStatus = async () => { + try { + const res = await API.get('/api/user/passkey'); + const { success, data, message } = res.data; + if (success) { + setPasskeyStatus({ + enabled: data?.enabled || false, + last_used_at: data?.last_used_at || null, + backup_eligible: data?.backup_eligible || false, + backup_state: data?.backup_state || false, + }); + } else { + showError(message); + } + } catch (error) { + // 忽略错误,保留默认状态 + } + }; + + const handleRegisterPasskey = async () => { + if (!passkeySupported || !window.PublicKeyCredential) { + showInfo(t('当前设备不支持 Passkey')); + return; + } + setPasskeyRegisterLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/register/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || t('无法发起 Passkey 注册')); + return; + } + + const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data); + const credential = await navigator.credentials.create({ publicKey }); + const payload = buildRegistrationResult(credential); + if (!payload) { + showError(t('Passkey 注册失败,请重试')); + return; + } + + const finishRes = await API.post('/api/user/passkey/register/finish', payload); + if (finishRes.data.success) { + showSuccess(t('Passkey 注册成功')); + await loadPasskeyStatus(); + } else { + showError(finishRes.data.message || t('Passkey 注册失败,请重试')); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo(t('已取消 Passkey 注册')); + } else { + showError(t('Passkey 注册失败,请重试')); + } + } finally { + setPasskeyRegisterLoading(false); + } + }; + + const handleRemovePasskey = async () => { + setPasskeyDeleteLoading(true); + try { + const res = await API.delete('/api/user/passkey'); + const { success, message } = res.data; + if (success) { + showSuccess(t('Passkey 已解绑')); + await loadPasskeyStatus(); + } else { + showError(message || t('操作失败,请重试')); + } + } catch (error) { + showError(t('操作失败,请重试')); + } finally { + setPasskeyDeleteLoading(false); + } + }; + const getUserData = async () => { let res = await API.get(`/api/user/self`); const { success, message, data } = res.data; if (success) { userDispatch({ type: 'login', payload: data }); + await loadPasskeyStatus(); } else { showError(message); } @@ -352,6 +441,12 @@ const PersonalSetting = () => { handleSystemTokenClick={handleSystemTokenClick} setShowChangePasswordModal={setShowChangePasswordModal} setShowAccountDeleteModal={setShowAccountDeleteModal} + passkeyStatus={passkeyStatus} + passkeySupported={passkeySupported} + passkeyRegisterLoading={passkeyRegisterLoading} + passkeyDeleteLoading={passkeyDeleteLoading} + onPasskeyRegister={handleRegisterPasskey} + onPasskeyDelete={handleRemovePasskey} /> {/* 右侧:其他设置 */} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index f9a2c019d..abb55301a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -30,6 +30,7 @@ import { Spin, Card, Radio, + Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -77,6 +78,13 @@ const SystemSetting = () => { TurnstileSiteKey: '', TurnstileSecretKey: '', RegisterEnabled: '', + 'passkey.enabled': '', + 'passkey.rp_display_name': '', + 'passkey.rp_id': '', + 'passkey.origins': [], + 'passkey.allow_insecure_origin': '', + 'passkey.user_verification': 'preferred', + 'passkey.attachment_preference': '', EmailDomainRestrictionEnabled: '', EmailAliasRestrictionEnabled: '', SMTPSSLEnabled: '', @@ -114,6 +122,7 @@ const SystemSetting = () => { const [domainList, setDomainList] = useState([]); const [ipList, setIpList] = useState([]); const [allowedPorts, setAllowedPorts] = useState([]); + const [passkeyOrigins, setPasskeyOrigins] = useState([]); const getOptions = async () => { setLoading(true); @@ -173,9 +182,28 @@ const SystemSetting = () => { case 'SMTPSSLEnabled': case 'LinuxDOOAuthEnabled': case 'oidc.enabled': + case 'passkey.enabled': + case 'passkey.allow_insecure_origin': case 'WorkerAllowHttpImageRequestEnabled': item.value = toBoolean(item.value); break; + case 'passkey.origins': + try { + const origins = item.value ? JSON.parse(item.value) : []; + setPasskeyOrigins(Array.isArray(origins) ? origins : []); + item.value = Array.isArray(origins) ? origins : []; + } catch (e) { + setPasskeyOrigins([]); + item.value = []; + } + break; + case 'passkey.rp_display_name': + case 'passkey.rp_id': + case 'passkey.user_verification': + case 'passkey.attachment_preference': + // 确保字符串字段不为null/undefined + item.value = item.value || ''; + break; case 'Price': case 'MinTopUp': item.value = parseFloat(item.value); @@ -582,6 +610,45 @@ const SystemSetting = () => { } }; + const submitPasskeySettings = async () => { + const options = []; + + // 只在值有变化时才提交,并确保空值转换为空字符串 + if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) { + options.push({ + key: 'passkey.rp_display_name', + value: inputs['passkey.rp_display_name'] || '', + }); + } + if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) { + options.push({ + key: 'passkey.rp_id', + value: inputs['passkey.rp_id'] || '', + }); + } + if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) { + options.push({ + key: 'passkey.user_verification', + value: inputs['passkey.user_verification'] || 'preferred', + }); + } + if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) { + options.push({ + key: 'passkey.attachment_preference', + value: inputs['passkey.attachment_preference'] || '', + }); + } + // Origins总是提交,因为它们可能会被用户清空 + options.push({ + key: 'passkey.origins', + value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []), + }); + + if (options.length > 0) { + await updateOptions(options); + } + }; + const handleCheckboxChange = async (optionKey, event) => { const value = event.target.checked; @@ -957,6 +1024,126 @@ const SystemSetting = () => { + + + {t('用以支持基于 WebAuthn 的无密码登录注册')} + + + + + handleCheckboxChange('passkey.enabled', e) + } + > + {t('允许通过 Passkey 登录 & 注册')} + + + + + + + + + + + + + + + + + + + + + + + handleCheckboxChange('passkey.allow_insecure_origin', e) + } + > + {t('允许不安全的 Origin(HTTP)')} + + + + + + {t('允许的 Origins')} + + {t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')} + + { + setPasskeyOrigins(value); + setInputs(prev => ({ + ...prev, + 'passkey.origins': value + })); + }} + placeholder={t('输入 Origin 后回车,如:https://example.com')} + style={{ width: '100%' }} + /> + + + + + + {t('用以防止恶意用户利用临时邮箱批量注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 017e7c1e6..b5baa55e5 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -59,6 +59,12 @@ const AccountManagement = ({ handleSystemTokenClick, setShowChangePasswordModal, setShowAccountDeleteModal, + passkeyStatus, + passkeySupported, + passkeyRegisterLoading, + passkeyDeleteLoading, + onPasskeyRegister, + onPasskeyDelete, }) => { const renderAccountInfo = (accountId, label) => { if (!accountId || accountId === '') { @@ -86,6 +92,10 @@ const AccountManagement = ({ }; const isBound = (accountId) => Boolean(accountId); const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); + const passkeyEnabled = passkeyStatus?.enabled; + const lastUsedLabel = passkeyStatus?.last_used_at + ? new Date(passkeyStatus.last_used_at).toLocaleString() + : t('尚未使用'); return ( @@ -476,6 +486,58 @@ const AccountManagement = ({ + {/* Passkey 设置 */} + +
+
+
+ +
+
+ + {t('Passkey 登录')} + + + {passkeyEnabled + ? t('已启用 Passkey,无需密码即可登录') + : t('使用 Passkey 实现免密且更安全的登录体验')} + +
+
+ {t('最后使用时间')}:{lastUsedLabel} +
+ {/*{passkeyEnabled && (*/} + {/*
*/} + {/* {t('备份支持')}:*/} + {/* {passkeyStatus?.backup_eligible*/} + {/* ? t('支持备份')*/} + {/* : t('不支持')}*/} + {/* ,{t('备份状态')}:*/} + {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/} + {/*
*/} + {/*)}*/} + {!passkeySupported && ( +
+ {t('当前设备不支持 Passkey')} +
+ )} +
+
+
+ +
+
+ {/* 两步验证设置 */} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 2eb480e7a..c049fdc20 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -56,8 +56,10 @@ import { } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; -import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal'; +import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; +import { useSecureVerification } from '../../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../../services/secureVerification'; import { IconSave, IconClose, @@ -193,43 +195,43 @@ const EditChannelModal = (props) => { const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户 - // 2FA验证查看密钥相关状态 - const [twoFAState, setTwoFAState] = useState({ + // 密钥显示状态 + const [keyDisplayState, setKeyDisplayState] = useState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); - // 专门的2FA验证状态(用于TwoFactorAuthModal) - const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false); - const [verifyCode, setVerifyCode] = useState(''); - const [verifyLoading, setVerifyLoading] = useState(false); + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后显示密钥 + if (result.success && result.data?.key) { + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } + }, + successMessage: t('密钥获取成功'), + }); - // 2FA状态更新辅助函数 - const updateTwoFAState = (updates) => { - setTwoFAState((prev) => ({ ...prev, ...updates })); - }; - - // 重置2FA状态 - const resetTwoFAState = () => { - setTwoFAState({ + // 重置密钥显示状态 + const resetKeyDisplayState = () => { + setKeyDisplayState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); }; - // 重置2FA验证状态 - const reset2FAVerifyState = () => { - setShow2FAVerifyModal(false); - setVerifyCode(''); - setVerifyLoading(false); - }; - // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -602,42 +604,31 @@ const EditChannelModal = (props) => { } }; - // 使用TwoFactorAuthModal的验证函数 - const handleVerify2FA = async () => { - if (!verifyCode) { - showError(t('请输入验证码或备用码')); - return; - } - - setVerifyLoading(true); + // 显示安全验证模态框并开始验证流程 + const handleShow2FAModal = async () => { try { - const res = await API.post(`/api/channel/${channelId}/key`, { - code: verifyCode, + console.log('=== handleShow2FAModal called ==='); + console.log('channelId:', channelId); + console.log('startVerification function:', typeof startVerification); + + // 测试模态框状态 + console.log('Current modal state:', isModalVisible); + + const apiCall = createApiCalls.viewChannelKey(channelId); + console.log('apiCall created:', typeof apiCall); + + const result = await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey }); - if (res.data.success) { - // 验证成功,显示密钥 - updateTwoFAState({ - showModal: true, - showKey: true, - keyData: res.data.data.key, - }); - reset2FAVerifyState(); - showSuccess(t('验证成功')); - } else { - showError(res.data.message); - } + console.log('startVerification result:', result); } catch (error) { - showError(t('获取密钥失败')); - } finally { - setVerifyLoading(false); + console.error('handleShow2FAModal error:', error); + showError(error.message || t('启动验证失败')); } }; - // 显示2FA验证模态框 - 使用TwoFactorAuthModal - const handleShow2FAModal = () => { - setShow2FAVerifyModal(true); - }; - useEffect(() => { const modelMap = new Map(); @@ -741,10 +732,8 @@ const EditChannelModal = (props) => { } // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 setInputs(getInitValues()); - // 重置2FA状态 - resetTwoFAState(); - // 重置2FA验证状态 - reset2FAVerifyState(); + // 重置密钥显示状态 + resetKeyDisplayState(); }; const handleVertexUploadChange = ({ fileList }) => { @@ -2498,17 +2487,17 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> - {/* 使用TwoFactorAuthModal组件进行2FA验证 */} - {/* 使用ChannelKeyDisplay组件显示密钥 */} @@ -2531,10 +2520,10 @@ const EditChannelModal = (props) => { {t('渠道密钥信息')} } - visible={twoFAState.showModal && twoFAState.showKey} - onCancel={resetTwoFAState} + visible={keyDisplayState.showModal} + onCancel={resetKeyDisplayState} footer={ - } @@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => { style={{ maxWidth: '90vw' }} > { @@ -253,6 +255,20 @@ const renderOperations = ( > {t('降级')} + + - - +
+
+ + + + } + style={{ width: '100%' }} + />
- + + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
+ + +
+
)} {hasPasskey && passkeySupported && ( - - - - {t('Passkey')} - - } + tab={t('Passkey')} itemKey='passkey' > - -
-
-
- - - -
- - {t('使用 Passkey 验证')} - - - {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')} - -
-
- - +
+
+
+ + +
+ + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} +
- + +
+ + +
+
)} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index abb55301a..f0c2dbc3a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -1043,7 +1043,7 @@ const SystemSetting = () => { handleCheckboxChange('passkey.enabled', e) } > - {t('允许通过 Passkey 登录 & 注册')} + {t('允许通过 Passkey 登录 & 认证')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 27499f824..54b4525d6 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -206,7 +206,7 @@ const EditChannelModal = (props) => { isModalVisible, verificationMethods, verificationState, - startVerification, + withVerification, executeVerification, cancelVerification, setVerificationCode, @@ -214,12 +214,20 @@ const EditChannelModal = (props) => { } = useSecureVerification({ onSuccess: (result) => { // 验证成功后显示密钥 - if (result.success && result.data?.key) { + console.log('Verification success, result:', result); + if (result && result.success && result.data?.key) { showSuccess(t('密钥获取成功')); setKeyDisplayState({ showModal: true, keyData: result.data.key, }); + } else if (result && result.key) { + // 直接返回了 key(没有包装在 data 中) + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.key, + }); } }, }); @@ -604,19 +612,30 @@ const EditChannelModal = (props) => { } }; - // 显示安全验证模态框并开始验证流程 + // 查看渠道密钥(透明验证) const handleShow2FAModal = async () => { try { - const apiCall = createApiCalls.viewChannelKey(channelId); - - await startVerification(apiCall, { - title: t('查看渠道密钥'), - description: t('为了保护账户安全,请验证您的身份。'), - preferredMethod: 'passkey', // 优先使用 Passkey - }); + // 使用 withVerification 包装,会自动处理需要验证的情况 + const result = await withVerification( + createApiCalls.viewChannelKey(channelId), + { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey + } + ); + + // 如果直接返回了结果(已验证),显示密钥 + if (result && result.success && result.data?.key) { + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } } catch (error) { - console.error('Failed to start verification:', error); - showError(error.message || t('启动验证失败')); + console.error('Failed to view channel key:', error); + showError(error.message || t('获取密钥失败')); } }; diff --git a/web/src/helpers/secureApiCall.js b/web/src/helpers/secureApiCall.js new file mode 100644 index 000000000..b82a6ae92 --- /dev/null +++ b/web/src/helpers/secureApiCall.js @@ -0,0 +1,62 @@ +/* +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 +*/ + +/** + * 安全 API 调用包装器 + * 自动处理需要验证的 403 错误,透明地触发验证流程 + */ + +/** + * 检查错误是否是需要安全验证的错误 + * @param {Error} error - 错误对象 + * @returns {boolean} + */ +export function isVerificationRequiredError(error) { + if (!error.response) return false; + + const { status, data } = error.response; + + // 检查是否是 403 错误且包含验证相关的错误码 + if (status === 403 && data) { + const verificationCodes = [ + 'VERIFICATION_REQUIRED', + 'VERIFICATION_EXPIRED', + 'VERIFICATION_INVALID' + ]; + + return verificationCodes.includes(data.code); + } + + return false; +} + +/** + * 从错误中提取验证需求信息 + * @param {Error} error - 错误对象 + * @returns {Object} 验证需求信息 + */ +export function extractVerificationInfo(error) { + const data = error.response?.data || {}; + + return { + code: data.code, + message: data.message || '需要安全验证', + required: true + }; +} \ No newline at end of file diff --git a/web/src/hooks/common/useSecureVerification.jsx b/web/src/hooks/common/useSecureVerification.jsx index 271345d1c..e60a104db 100644 --- a/web/src/hooks/common/useSecureVerification.jsx +++ b/web/src/hooks/common/useSecureVerification.jsx @@ -21,6 +21,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { SecureVerificationService } from '../../services/secureVerification'; import { showError, showSuccess } from '../../helpers'; +import { isVerificationRequiredError } from '../../helpers/secureApiCall'; /** * 通用安全验证 Hook @@ -82,10 +83,10 @@ export const useSecureVerification = ({ // 开始验证流程 const startVerification = useCallback(async (apiCall, options = {}) => { const { preferredMethod, title, description } = options; - + // 检查验证方式 const methods = await checkVerificationMethods(); - + if (!methods.has2FA && !methods.hasPasskey) { const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作'); showError(errorMessage); @@ -111,7 +112,7 @@ export const useSecureVerification = ({ description })); setIsModalVisible(true); - + return true; }, [checkVerificationMethods, onError, t]); @@ -125,10 +126,11 @@ export const useSecureVerification = ({ setVerificationState(prev => ({ ...prev, loading: true })); try { - const result = await SecureVerificationService.verify(method, { - code, - apiCall: verificationState.apiCall - }); + // 先调用验证 API,成功后后端会设置 session + await SecureVerificationService.verify(method, code); + + // 验证成功,调用业务 API(此时中间件会通过) + const result = await verificationState.apiCall(); // 显示成功消息 if (successMessage) { @@ -191,12 +193,36 @@ export const useSecureVerification = ({ return null; }, [verificationMethods]); + /** + * 包装 API 调用,自动处理验证错误 + * 当 API 返回需要验证的错误时,自动弹出验证模态框 + * @param {Function} apiCall - API 调用函数 + * @param {Object} options - 验证选项(同 startVerification) + * @returns {Promise} + */ + const withVerification = useCallback(async (apiCall, options = {}) => { + try { + // 直接尝试调用 API + return await apiCall(); + } catch (error) { + // 检查是否是需要验证的错误 + if (isVerificationRequiredError(error)) { + // 自动触发验证流程 + await startVerification(apiCall, options); + // 不抛出错误,让验证模态框处理 + return null; + } + // 其他错误继续抛出 + throw error; + } + }, [startVerification]); + return { // 状态 isModalVisible, verificationMethods, verificationState, - + // 方法 startVerification, executeVerification, @@ -205,11 +231,12 @@ export const useSecureVerification = ({ setVerificationCode, switchVerificationMethod, checkVerificationMethods, - + // 辅助方法 canUseMethod, getRecommendedMethod, - + withVerification, // 新增:自动处理验证的包装函数 + // 便捷属性 hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey, isLoading: verificationState.loading, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e221c3b28..5586e0a83 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -333,6 +333,7 @@ "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password", "允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account", "允许通过微信登录 & 注册": "Allow login & registration via WeChat", + "允许通过 Passkey 登录 & 认证": "Allow login & authentication via Passkey", "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way", "启用 Turnstile 用户校验": "Enable Turnstile user verification", "配置 SMTP": "Configure SMTP", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 26c418205..e6dafac18 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -87,5 +87,6 @@ "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。", "目标用户:{{username}}": "目标用户:{{username}}", "Passkey 已重置": "Passkey 已重置", - "二步验证已重置": "二步验证已重置" + "二步验证已重置": "二步验证已重置", + "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证" } diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js index 1af53204b..93cdd0a4d 100644 --- a/web/src/services/secureVerification.js +++ b/web/src/services/secureVerification.js @@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import { API, showError } from '../helpers'; -import { - prepareCredentialRequestOptions, - buildAssertionResult, - isPasskeySupported +import { + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported } from '../helpers/passkey'; /** * 通用安全验证服务 + * 验证状态完全由后端 Session 控制,前端不存储任何状态 */ export class SecureVerificationService { /** @@ -81,36 +82,41 @@ export class SecureVerificationService { /** * 执行2FA验证 * @param {string} code - 验证码 - * @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数 - * @returns {Promise} API响应结果 + * @returns {Promise} */ - static async verify2FA(code, apiCall) { + static async verify2FA(code) { if (!code?.trim()) { throw new Error('请输入验证码或备用码'); } - return await apiCall({ + // 调用通用验证 API,验证成功后后端会设置 session + const verifyResponse = await API.post('/api/verify', { method: '2fa', code: code.trim() }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 } /** * 执行Passkey验证 - * @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数 - * @returns {Promise} API响应结果 + * @returns {Promise} */ - static async verifyPasskey(apiCall) { + static async verifyPasskey() { try { // 开始Passkey验证 const beginResponse = await API.post('/api/user/passkey/verify/begin'); - if (!beginResponse.success) { - throw new Error(beginResponse.message); + if (!beginResponse.data?.success) { + throw new Error(beginResponse.data?.message || '开始验证失败'); } // 准备WebAuthn选项 - const publicKey = prepareCredentialRequestOptions(beginResponse.data); - + const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options); + // 执行WebAuthn验证 const credential = await navigator.credentials.get({ publicKey }); if (!credential) { @@ -119,17 +125,23 @@ export class SecureVerificationService { // 构建验证结果 const assertionResult = buildAssertionResult(credential); - + // 完成验证 const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult); - if (!finishResponse.success) { - throw new Error(finishResponse.message); + if (!finishResponse.data?.success) { + throw new Error(finishResponse.data?.message || '验证失败'); } - // 调用业务API - return await apiCall({ + // 调用通用验证 API 设置 session(Passkey 验证已完成) + const verifyResponse = await API.post('/api/verify', { method: 'passkey' }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 } catch (error) { if (error.name === 'NotAllowedError') { throw new Error('Passkey 验证被取消或超时'); @@ -144,17 +156,15 @@ export class SecureVerificationService { /** * 通用验证方法,根据验证类型执行相应的验证流程 * @param {string} method - 验证方式: '2fa' | 'passkey' - * @param {Object} params - 参数对象 - * @param {string} params.code - 2FA验证码(当method为'2fa'时必需) - * @param {Function} params.apiCall - API调用函数 - * @returns {Promise} API响应结果 + * @param {string} code - 2FA验证码(当method为'2fa'时必需) + * @returns {Promise} */ - static async verify(method, { code, apiCall }) { + static async verify(method, code = '') { switch (method) { case '2fa': - return await this.verify2FA(code, apiCall); + return await this.verify2FA(code); case 'passkey': - return await this.verifyPasskey(apiCall); + return await this.verifyPasskey(); default: throw new Error(`不支持的验证方式: ${method}`); } @@ -169,8 +179,10 @@ export const createApiCalls = { * 创建查看渠道密钥的API调用 * @param {number} channelId - 渠道ID */ - viewChannelKey: (channelId) => async (verificationData) => { - return await API.post(`/api/channel/${channelId}/key`, verificationData); + viewChannelKey: (channelId) => async () => { + // 新系统中,验证已通过中间件处理,直接调用 API 即可 + const response = await API.post(`/api/channel/${channelId}/key`, {}); + return response.data; }, /** @@ -179,20 +191,27 @@ export const createApiCalls = { * @param {string} method - HTTP方法,默认为 'POST' * @param {Object} extraData - 额外的请求数据 */ - custom: (url, method = 'POST', extraData = {}) => async (verificationData) => { - const data = { ...extraData, ...verificationData }; - + custom: (url, method = 'POST', extraData = {}) => async () => { + // 新系统中,验证已通过中间件处理 + const data = extraData; + + let response; switch (method.toUpperCase()) { case 'GET': - return await API.get(url, { params: data }); + response = await API.get(url, { params: data }); + break; case 'POST': - return await API.post(url, data); + response = await API.post(url, data); + break; case 'PUT': - return await API.put(url, data); + response = await API.put(url, data); + break; case 'DELETE': - return await API.delete(url, { data }); + response = await API.delete(url, { data }); + break; default: throw new Error(`不支持的HTTP方法: ${method}`); } + return response.data; } }; \ No newline at end of file From 013a575541b7da22a2a4ada911bbd82fc79da0ef Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 12:26:24 +0800 Subject: [PATCH 25/53] fix: personal setting --- model/passkey.go | 5 +++-- .../personal/cards/AccountManagement.jsx | 21 +++++++++++++++---- web/src/i18n/locales/en.json | 3 +++ web/src/i18n/locales/zh.json | 5 ++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/model/passkey.go b/model/passkey.go index 092639019..3f45e1764 100644 --- a/model/passkey.go +++ b/model/passkey.go @@ -141,9 +141,10 @@ func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { var credential PasskeyCredential if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - common.SysLog(fmt.Sprintf("GetPasskeyByUserID: passkey not found for user %d", userID)) - return nil, ErrFriendlyPasskeyNotFound + // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 + return nil, ErrPasskeyNotFound } + // 只有真正的数据库错误才记录日志 common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) return nil, ErrFriendlyPasskeyNotFound } diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index b5baa55e5..93a2daf89 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -525,10 +525,23 @@ const AccountManagement = ({
- - - + + + + ), + }, + ]; + return ( - (refForm.current = formAPI)} - style={{ marginBottom: 15 }} - > + - { - return verifyJSON(value); - }, - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ - ...inputs, - Chats: value, - }) - } - /> + + + +
+ + {t('编辑模式')}: + + { + const newMode = e.target.value; + setEditMode(newMode); + + // 确保模式切换时数据正确同步 + setTimeout(() => { + if (newMode === 'json' && refForm.current) { + refForm.current.setValues(inputs); + } + }, 100); + }} + > + {t('可视化编辑')} + {t('JSON编辑')} + +
+ + {editMode === 'visual' ? ( +
+ + + } + placeholder={t('搜索聊天应用名称')} + value={searchText} + onChange={(value) => setSearchText(value)} + style={{ width: 250 }} + showClear + /> + + + + t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', { + total, + start: range[0], + end: range[1], + }), + }} + /> + + ) : ( + (refForm.current = formAPI)} + > + { + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ + ...inputs, + Chats: value, + }) + } + /> + + )} - - - + + + + + + +
(modalFormRef.current = api)}> + + + + +
); } From 14283385467676ef10bbec65ec1fe145d0d73ee6 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:40:02 +0800 Subject: [PATCH 37/53] feat: Enhance SettingsChats edit interface --- web/src/pages/Setting/Chat/SettingsChats.jsx | 69 +++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/web/src/pages/Setting/Chat/SettingsChats.jsx b/web/src/pages/Setting/Chat/SettingsChats.jsx index 368a66f5b..01591c782 100644 --- a/web/src/pages/Setting/Chat/SettingsChats.jsx +++ b/web/src/pages/Setting/Chat/SettingsChats.jsx @@ -36,6 +36,7 @@ import { IconEdit, IconDelete, IconSearch, + IconSaveStroked, } from '@douyinfe/semi-icons'; import { compareObjects, @@ -55,7 +56,7 @@ export default function SettingsChats(props) { }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); - const [editMode, setEditMode] = useState('json'); + const [editMode, setEditMode] = useState('visual'); const [chatConfigs, setChatConfigs] = useState([]); const [modalVisible, setModalVisible] = useState(false); const [editingConfig, setEditingConfig] = useState(null); @@ -167,7 +168,9 @@ export default function SettingsChats(props) { } setInputs(currentInputs); setInputsRow(structuredClone(currentInputs)); - refForm.current.setValues(currentInputs); + if (refForm.current) { + refForm.current.setValues(currentInputs); + } // 同步到可视化配置 const configs = jsonToConfigs(currentInputs.Chats || '[]'); @@ -220,6 +223,18 @@ export default function SettingsChats(props) { modalFormRef.current .validate() .then((values) => { + // 检查名称是否重复 + const isDuplicate = chatConfigs.some( + (config) => + config.name === values.name && + (!isEdit || config.id !== editingConfig.id) + ); + + if (isDuplicate) { + showError(t('聊天应用名称已存在,请使用其他名称')); + return; + } + if (isEdit) { const newConfigs = chatConfigs.map((config) => config.id === editingConfig.id @@ -263,6 +278,28 @@ export default function SettingsChats(props) { config.name.toLowerCase().includes(searchText.toLowerCase()), ); + const highlightKeywords = (text) => { + if (!text) return text; + + const parts = text.split(/(\{address\}|\{key\})/g); + return parts.map((part, index) => { + if (part === '{address}') { + return ( + + {part} + + ); + } else if (part === '{key}') { + return ( + + {part} + + ); + } + return part; + }); + }; + const columns = [ { title: t('聊天应用名称'), @@ -275,7 +312,9 @@ export default function SettingsChats(props) { dataIndex: 'url', key: 'url', render: (text) => ( -
{text}
+
+ {highlightKeywords(text)} +
), }, { @@ -351,6 +390,14 @@ export default function SettingsChats(props) { > {t('添加聊天配置')} + } placeholder={t('搜索聊天应用名称')} @@ -410,11 +457,17 @@ export default function SettingsChats(props) { )} - - - + {editMode === 'json' && ( + + + + )} Date: Wed, 1 Oct 2025 19:15:00 +0800 Subject: [PATCH 38/53] feat: add Gotify notification option for quota alerts --- controller/user.go | 51 +++++++- dto/user_settings.go | 4 + service/quota.go | 5 +- service/user_notify.go | 116 +++++++++++++++++- .../components/settings/PersonalSetting.jsx | 15 +++ .../personal/cards/NotificationSettings.jsx | 102 +++++++++++++++ 6 files changed, 287 insertions(+), 6 deletions(-) diff --git a/controller/user.go b/controller/user.go index c03afa322..33d4636b7 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct { WebhookSecret string `json:"webhook_secret,omitempty"` NotificationEmail string `json:"notification_email,omitempty"` BarkUrl string `json:"bark_url,omitempty"` + GotifyUrl string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` + GotifyPriority int `json:"gotify_priority,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` RecordIpLog bool `json:"record_ip_log"` } @@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) { } // 验证预警类型 - if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark { + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的预警类型", @@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是Gotify类型,验证Gotify URL和Token + if req.QuotaWarningType == dto.NotifyTypeGotify { + if req.GotifyUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址不能为空", + }) + return + } + if req.GotifyToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify令牌不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Gotify服务器地址", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址必须以http://或https://开头", + }) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) { settings.BarkUrl = req.BarkUrl } + // 如果是Gotify类型,添加Gotify配置到设置中 + if req.QuotaWarningType == dto.NotifyTypeGotify { + settings.GotifyUrl = req.GotifyUrl + settings.GotifyToken = req.GotifyToken + // Gotify优先级范围0-10,超出范围则使用默认值5 + if req.GotifyPriority < 0 || req.GotifyPriority > 10 { + settings.GotifyPriority = 5 + } else { + settings.GotifyPriority = req.GotifyPriority + } + } + // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { diff --git a/dto/user_settings.go b/dto/user_settings.go index 89dd926ef..16ce7b985 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -7,6 +7,9 @@ type UserSetting struct { WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL + GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址 + GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌 + GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 @@ -16,4 +19,5 @@ var ( NotifyTypeEmail = "email" // Email 邮件 NotifyTypeWebhook = "webhook" // Webhook NotifyTypeBark = "bark" // Bark 推送 + NotifyTypeGotify = "gotify" // Gotify 推送 ) diff --git a/service/quota.go b/service/quota.go index 12017e11e..43c4024ae 100644 --- a/service/quota.go +++ b/service/quota.go @@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon // Bark推送使用简短文本,不支持HTML content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else if notifyType == dto.NotifyTypeGotify { + content = "{{value}},当前剩余额度为 {{value}},请及时充值。" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else { - // 默认内容格式,适用于Email和Webhook + // 默认内容格式,适用于Email和Webhook(支持HTML) content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} } diff --git a/service/user_notify.go b/service/user_notify.go index fba12d9db..0f92e7d75 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -1,6 +1,8 @@ package service import ( + "bytes" + "encoding/json" "fmt" "net/http" "net/url" @@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data switch notifyType { case dto.NotifyTypeEmail: - // check setting email - userEmail = userSetting.NotificationEmail - if userEmail == "" { + // 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱 + emailToUse := userSetting.NotificationEmail + if emailToUse == "" { + emailToUse = userEmail + } + if emailToUse == "" { common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId)) return nil } - return sendEmailNotify(userEmail, data) + return sendEmailNotify(emailToUse, data) case dto.NotifyTypeWebhook: webhookURLStr := userSetting.WebhookUrl if webhookURLStr == "" { @@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data return nil } return sendBarkNotify(barkURL, data) + case dto.NotifyTypeGotify: + gotifyUrl := userSetting.GotifyUrl + gotifyToken := userSetting.GotifyToken + if gotifyUrl == "" || gotifyToken == "" { + common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId)) + return nil + } + return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data) } return nil } @@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { return nil } + +func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 构建完整的 Gotify API URL + // 确保 URL 以 /message 结尾 + finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken) + + // Gotify优先级范围0-10,如果超出范围则使用默认值5 + if priority < 0 || priority > 10 { + priority = 5 + } + + // 构建 JSON payload + type GotifyMessage struct { + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + } + + payload := GotifyMessage{ + Title: data.Title, + Message: content, + Priority: priority, + } + + // 序列化为 JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal gotify payload: %v", err) + } + + var req *http.Request + var resp *http.Response + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "OneAPI-Gotify-Notify/1.0", + }, + Body: payloadBytes, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send gotify request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Gotify URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create gotify request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send gotify request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 01e7023ad..c9934604c 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -81,6 +81,9 @@ const PersonalSetting = () => { webhookSecret: '', notificationEmail: '', barkUrl: '', + gotifyUrl: '', + gotifyToken: '', + gotifyPriority: 5, acceptUnsetModelRatioModel: false, recordIpLog: false, }); @@ -149,6 +152,12 @@ const PersonalSetting = () => { webhookSecret: settings.webhook_secret || '', notificationEmail: settings.notification_email || '', barkUrl: settings.bark_url || '', + gotifyUrl: settings.gotify_url || '', + gotifyToken: settings.gotify_token || '', + gotifyPriority: + settings.gotify_priority !== undefined + ? settings.gotify_priority + : 5, acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, @@ -406,6 +415,12 @@ const PersonalSetting = () => { webhook_secret: notificationSettings.webhookSecret, notification_email: notificationSettings.notificationEmail, bark_url: notificationSettings.barkUrl, + gotify_url: notificationSettings.gotifyUrl, + gotify_token: notificationSettings.gotifyToken, + gotify_priority: (() => { + const parsed = parseInt(notificationSettings.gotifyPriority); + return isNaN(parsed) ? 5 : parsed; + })(), accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, record_ip_log: notificationSettings.recordIpLog, diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index aad612d2c..dc428f145 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -400,6 +400,7 @@ const NotificationSettings = ({ {t('邮件通知')} {t('Webhook通知')} {t('Bark通知')} + {t('Gotify通知')} )} + + {/* Gotify推送设置 */} + {notificationSettings.warningType === 'gotify' && ( + <> + handleFormChange('gotifyUrl', val)} + prefix={} + extraText={t( + '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify服务器地址'), + }, + { + pattern: /^https?:\/\/.+/, + message: t('Gotify服务器地址必须以http://或https://开头'), + }, + ]} + /> + + handleFormChange('gotifyToken', val)} + prefix={} + extraText={t( + '在Gotify服务器创建应用后获得的令牌,用于发送通知', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify应用令牌'), + }, + ]} + /> + + + handleFormChange('gotifyPriority', val) + } + prefix={} + extraText={t('消息优先级,范围0-10,默认为5')} + style={{ width: '100%', maxWidth: '300px' }} + /> + +
+
+ {t('配置说明')} +
+
+
+ 1. {t('在Gotify服务器的应用管理中创建新应用')} +
+
+ 2.{' '} + {t( + '复制应用的令牌(Token)并填写到上方的应用令牌字段', + )} +
+
+ 3. {t('填写Gotify服务器的完整URL地址')} +
+
+ + {t('更多信息请参考')} + {' '} + + Gotify 官方文档 + +
+
+
+ + )} From d6db10b4bc5aeba46e400d0146fb527516d2304f Mon Sep 17 00:00:00 2001 From: RedwindA Date: Wed, 1 Oct 2025 19:36:19 +0800 Subject: [PATCH 39/53] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Bark=20?= =?UTF-8?q?=E5=92=8C=20Gotify=20=E9=80=9A=E7=9F=A5=E7=9A=84=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E5=8C=96=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal/cards/NotificationSettings.jsx | 4 +-- web/src/i18n/locales/en.json | 32 +++++++++++++++++++ web/src/i18n/locales/fr.json | 32 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index dc428f145..0c99e2855 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -590,7 +590,7 @@ const NotificationSettings = ({ rel='noopener noreferrer' className='text-blue-500 hover:text-blue-600 font-medium' > - Bark 官方文档 + Bark {t('官方文档')} @@ -691,7 +691,7 @@ const NotificationSettings = ({ rel='noopener noreferrer' className='text-blue-500 hover:text-blue-600 font-medium' > - Gotify 官方文档 + Gotify {t('官方文档')} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e1064b56..7ba76a0de 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1313,6 +1313,8 @@ "请输入Webhook地址,例如: https://example.com/webhook": "Please enter the Webhook URL, e.g.: https://example.com/webhook", "邮件通知": "Email notification", "Webhook通知": "Webhook notification", + "Bark通知": "Bark notification", + "Gotify通知": "Gotify notification", "接口凭证(可选)": "Interface credentials (optional)", "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "The secret will be added to the request header as a Bearer token to verify the legitimacy of the webhook request", "Authorization: Bearer your-secret-key": "Authorization: Bearer your-secret-key", @@ -1323,6 +1325,36 @@ "通知邮箱": "Notification email", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used", "留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used", + "Bark推送URL": "Bark Push URL", + "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Please enter Bark push URL, e.g.: https://api.day.app/yourkey/{{title}}/{{content}}", + "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Supports HTTP and HTTPS, template variables: {{title}} (notification title), {{content}} (notification content)", + "请输入Bark推送URL": "Please enter Bark push URL", + "Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://", + "模板示例": "Template example", + "更多参数请参考": "For more parameters, please refer to", + "Gotify服务器地址": "Gotify server address", + "请输入Gotify服务器地址,例如: https://gotify.example.com": "Please enter Gotify server address, e.g.: https://gotify.example.com", + "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Supports HTTP and HTTPS, enter the complete URL of the Gotify server", + "请输入Gotify服务器地址": "Please enter Gotify server address", + "Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://", + "Gotify应用令牌": "Gotify application token", + "请输入Gotify应用令牌": "Please enter Gotify application token", + "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Token obtained after creating an application on the Gotify server, used to send notifications", + "消息优先级": "Message priority", + "请选择消息优先级": "Please select message priority", + "0 - 最低": "0 - Lowest", + "2 - 低": "2 - Low", + "5 - 正常(默认)": "5 - Normal (default)", + "8 - 高": "8 - High", + "10 - 最高": "10 - Highest", + "消息优先级,范围0-10,默认为5": "Message priority, range 0-10, default is 5", + "配置说明": "Configuration instructions", + "在Gotify服务器的应用管理中创建新应用": "Create a new application in the Gotify server's application management", + "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copy the application token and fill it in the application token field above", + "填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server", + "更多信息请参考": "For more information, please refer to", + "通知内容": "Notification content", + "官方文档": "Official documentation", "API地址": "Base URL", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in", "渠道额外设置": "Channel extra settings", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 3a216e53b..6dde55977 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1308,6 +1308,8 @@ "请输入Webhook地址,例如: https://example.com/webhook": "Veuillez saisir l'URL du Webhook, par exemple : https://example.com/webhook", "邮件通知": "Notification par e-mail", "Webhook通知": "Notification par Webhook", + "Bark通知": "Notification Bark", + "Gotify通知": "Notification Gotify", "接口凭证(可选)": "Informations d'identification de l'interface (facultatif)", "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "Le secret sera ajouté à l'en-tête de la requête en tant que jeton Bearer pour vérifier la légitimité de la requête webhook", "Authorization: Bearer your-secret-key": "Autorisation : Bearer votre-clé-secrète", @@ -1318,6 +1320,36 @@ "通知邮箱": "E-mail de notification", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Définissez l'adresse e-mail pour recevoir les notifications d'avertissement de quota, si elle n'est pas définie, l'adresse e-mail liée au compte sera utilisée", "留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée", + "Bark推送URL": "URL de notification Bark", + "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Veuillez saisir l'URL de notification Bark, par exemple : https://api.day.app/yourkey/{{title}}/{{content}}", + "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Prend en charge HTTP et HTTPS, variables de modèle : {{title}} (titre de la notification), {{content}} (contenu de la notification)", + "请输入Bark推送URL": "Veuillez saisir l'URL de notification Bark", + "Bark推送URL必须以http://或https://开头": "L'URL de notification Bark doit commencer par http:// ou https://", + "模板示例": "Exemple de modèle", + "更多参数请参考": "Pour plus de paramètres, veuillez vous référer à", + "Gotify服务器地址": "Adresse du serveur Gotify", + "请输入Gotify服务器地址,例如: https://gotify.example.com": "Veuillez saisir l'adresse du serveur Gotify, par exemple : https://gotify.example.com", + "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Prend en charge HTTP et HTTPS, saisissez l'URL complète du serveur Gotify", + "请输入Gotify服务器地址": "Veuillez saisir l'adresse du serveur Gotify", + "Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://", + "Gotify应用令牌": "Jeton d'application Gotify", + "请输入Gotify应用令牌": "Veuillez saisir le jeton d'application Gotify", + "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Jeton obtenu après la création d'une application sur le serveur Gotify, utilisé pour envoyer des notifications", + "消息优先级": "Priorité du message", + "请选择消息优先级": "Veuillez sélectionner la priorité du message", + "0 - 最低": "0 - La plus basse", + "2 - 低": "2 - Basse", + "5 - 正常(默认)": "5 - Normale (par défaut)", + "8 - 高": "8 - Haute", + "10 - 最高": "10 - La plus haute", + "消息优先级,范围0-10,默认为5": "Priorité du message, plage 0-10, par défaut 5", + "配置说明": "Instructions de configuration", + "在Gotify服务器的应用管理中创建新应用": "Créer une nouvelle application dans la gestion des applications du serveur Gotify", + "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copier le jeton de l'application et le remplir dans le champ de jeton d'application ci-dessus", + "填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify", + "更多信息请参考": "Pour plus d'informations, veuillez vous référer à", + "通知内容": "Contenu de la notification", + "官方文档": "Documentation officielle", "API地址": "URL de base", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir", "渠道额外设置": "Paramètres supplémentaires du canal", From 2200bb9166e20bca3168273e04ce039e7210e075 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Wed, 1 Oct 2025 22:19:22 +0800 Subject: [PATCH 40/53] fix(openai): add nil checks for web_search streaming to prevent panic --- relay/channel/openai/relay_responses.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 85938a771..7b148f323 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp if streamResponse.Item != nil { switch streamResponse.Item.Type { case dto.BuildInCallWebSearchCall: - info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++ + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ + } + } } } } From 0e9ad4a15f6cee8b3c0193215db1fa6bb7d5453d Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 00:14:35 +0800 Subject: [PATCH 41/53] fix: missing field & field control --- dto/channel_settings.go | 3 + dto/claude.go | 5 +- dto/openai_request.go | 39 ++++-- relay/claude_handler.go | 6 + relay/common/relay_info.go | 34 +++++ relay/compatible_handler.go | 6 + relay/responses_handler.go | 7 + .../channels/modals/EditChannelModal.jsx | 125 ++++++++++++++++-- web/src/i18n/locales/en.json | 7 + web/src/i18n/locales/fr.json | 7 + 10 files changed, 213 insertions(+), 26 deletions(-) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index d6d6e0848..d57184b38 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -20,6 +20,9 @@ type ChannelOtherSettings struct { AzureResponsesVersion string `json:"azure_responses_version,omitempty"` VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/dto/claude.go b/dto/claude.go index 427742263..dfc5cfd4c 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -195,12 +195,15 @@ type ClaudeRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` TopK int `json:"top_k,omitempty"` - //ClaudeMetadata `json:"metadata,omitempty"` Stream bool `json:"stream,omitempty"` Tools any `json:"tools,omitempty"` ContextManagement json.RawMessage `json:"context_management,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Thinking *Thinking `json:"thinking,omitempty"` + McpServers json.RawMessage `json:"mcp_servers,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` } func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/openai_request.go b/dto/openai_request.go index 191fa638f..dbdfad446 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct { Dimensions int `json:"dimensions,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"` Audio json.RawMessage `json:"audio,omitempty"` + // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户 + // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私 + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Whether or not to store the output of this chat completion request for use in our model distillation or evals products. + // 是否存储此次请求数据供 OpenAI 用于评估和优化产品 + // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用 + Store json.RawMessage `json:"store,omitempty"` + // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + LogitBias json.RawMessage `json:"logit_bias,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` // gemini ExtraBody json.RawMessage `json:"extra_body,omitempty"` //xai @@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct { ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Store json.RawMessage `json:"store,omitempty"` - PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - Text json.RawMessage `json:"text,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map - TopP float64 `json:"top_p,omitempty"` - Truncation string `json:"truncation,omitempty"` - User string `json:"user,omitempty"` - MaxToolCalls uint `json:"max_tool_calls,omitempty"` - Prompt json.RawMessage `json:"prompt,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` } func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 59d12abe4..3a739785f 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for Claude API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4ffaee23..cb66cd806 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -507,3 +507,37 @@ type TaskInfo struct { Url string `json:"url,omitempty"` Progress string `json:"progress,omitempty"` } + +// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段 +// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持) +// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) +// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) +func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + return jsonData, err + } + + // 默认移除 service_tier,除非明确允许(避免额外计费风险) + if !channelOtherSettings.AllowServiceTier { + if _, exists := data["service_tier"]; exists { + delete(data, "service_tier") + } + } + + // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用) + if channelOtherSettings.DisableStore { + if _, exists := data["store"]; exists { + delete(data, "store") + } + } + + // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息) + if !channelOtherSettings.AllowSafetyIdentifier { + if _, exists := data["safety_identifier"]; exists { + delete(data, "safety_identifier") + } + } + + return common.Marshal(data) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 38b820f72..a3ddf6d49 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for OpenAI API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 0c57a303f..6958f96ef 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + + // remove disabled fields for OpenAI Responses API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index f625ab14e..571c136f9 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -169,6 +169,10 @@ const EditChannelModal = (props) => { vertex_key_type: 'json', // 企业账户设置 is_enterprise_account: false, + // 字段透传控制默认值 + allow_service_tier: false, + disable_store: false, // false = 允许透传(默认开启) + allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -453,17 +457,27 @@ const EditChannelModal = (props) => { data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; // 读取企业账户设置 data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; + // 读取字段透传控制设置 + data.allow_service_tier = parsedSettings.allow_service_tier || false; + data.disable_store = parsedSettings.disable_store || false; + data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } } else { // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } if ( @@ -900,21 +914,33 @@ const EditChannelModal = (props) => { }; localInputs.setting = JSON.stringify(channelExtraSettings); - // 处理type === 20的企业账户设置 - if (localInputs.type === 20) { - let settings = {}; - if (localInputs.settings) { - try { - settings = JSON.parse(localInputs.settings); - } catch (error) { - console.error('解析settings失败:', error); - } + // 处理 settings 字段(包括企业账户设置和字段透传控制) + let settings = {}; + if (localInputs.settings) { + try { + settings = JSON.parse(localInputs.settings); + } catch (error) { + console.error('解析settings失败:', error); } - // 设置企业账户标识,无论是true还是false都要传到后端 - settings.openrouter_enterprise = localInputs.is_enterprise_account === true; - localInputs.settings = JSON.stringify(settings); } + // type === 20: 设置企业账户标识,无论是true还是false都要传到后端 + if (localInputs.type === 20) { + settings.openrouter_enterprise = localInputs.is_enterprise_account === true; + } + + // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值) + if (localInputs.type === 1 || localInputs.type === 14) { + settings.allow_service_tier = localInputs.allow_service_tier === true; + // 仅 OpenAI 渠道需要 store 和 safety_identifier + if (localInputs.type === 1) { + settings.disable_store = localInputs.disable_store === true; + settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; + } + } + + localInputs.settings = JSON.stringify(settings); + // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; @@ -925,6 +951,10 @@ const EditChannelModal = (props) => { delete localInputs.is_enterprise_account; // 顶层的 vertex_key_type 不应发送给后端 delete localInputs.vertex_key_type; + // 清理字段透传控制的临时字段 + delete localInputs.allow_service_tier; + delete localInputs.disable_store; + delete localInputs.allow_safety_identifier; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -2384,6 +2414,76 @@ const EditChannelModal = (props) => { '键为原状态码,值为要复写的状态码,仅影响本地判断', )} /> + + {/* 字段透传控制 - OpenAI 渠道 */} + {inputs.type === 1 && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange('allow_service_tier', value) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + + handleChannelOtherSettingsChange('disable_store', value) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + + handleChannelOtherSettingsChange('allow_safety_identifier', value) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + + )} + + {/* 字段透传控制 - Claude 渠道 */} + {(inputs.type === 14) && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange('allow_service_tier', value) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + )} {/* Channel Extra Settings Card */} @@ -2487,6 +2587,7 @@ const EditChannelModal = (props) => { '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', )} /> + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e1064b56..0d940d82b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2191,6 +2191,13 @@ "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com", "保存 Passkey 设置": "Save Passkey Settings", "黑名单": "Blacklist", + "字段透传控制": "Field Pass-through Control", + "允许 service_tier 透传": "Allow service_tier Pass-through", + "禁用 store 透传": "Disable store Pass-through", + "允许 safety_identifier 透传": "Allow safety_identifier Pass-through", + "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges", + "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction", + "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy", "common": { "changeLanguage": "Change Language" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 3a216e53b..f67b88efb 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2135,6 +2135,13 @@ "关闭侧边栏": "Fermer la barre latérale", "定价": "Tarification", "语言": "Langue", + "字段透传控制": "Contrôle du passage des champs", + "允许 service_tier 透传": "Autoriser le passage de service_tier", + "禁用 store 透传": "Désactiver le passage de store", + "允许 safety_identifier 透传": "Autoriser le passage de safety_identifier", + "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires", + "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex", + "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs", "common": { "changeLanguage": "Changer de langue" } From c320410c848cd3f3f1f7666d4d2978d28668f78d Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 2 Oct 2025 01:03:20 +0800 Subject: [PATCH 42/53] feat: add doubao video generate --- constant/channel.go | 3 +- controller/channel-test.go | 6 + relay/channel/task/doubao/adaptor.go | 245 +++++++++++++++++++++++++ relay/channel/task/doubao/constants.go | 9 + relay/relay_adaptor.go | 7 +- web/src/constants/channel.constants.js | 5 + web/src/helpers/render.jsx | 2 + 7 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 relay/channel/task/doubao/adaptor.go create mode 100644 relay/channel/task/doubao/constants.go diff --git a/constant/channel.go b/constant/channel.go index 34fb20f46..7d8893c1d 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -51,9 +51,9 @@ const ( ChannelTypeJimeng = 51 ChannelTypeVidu = 52 ChannelTypeSubmodel = 53 + ChannelTypeDoubaoVideo = 54 ChannelTypeDummy // this one is only for count, do not add any channel after this - ) var ChannelBaseURLs = []string{ @@ -111,4 +111,5 @@ var ChannelBaseURLs = []string{ "https://visual.volcengineapi.com", //51 "https://api.vidu.cn", //52 "https://llm.submodel.ai", //53 + "https://ark.cn-beijing.volces.com", //54 } diff --git a/controller/channel-test.go b/controller/channel-test.go index b3a3be4eb..ff1e8cef4 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) newAPIError: nil, } } + if channel.Type == constant.ChannelTypeDoubaoVideo { + return testResult{ + localErr: errors.New("doubao video channel test is not supported"), + newAPIError: nil, + } + } if channel.Type == constant.ChannelTypeVidu { return testResult{ localErr: errors.New("vidu channel test is not supported"), diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go new file mode 100644 index 000000000..9b40a249a --- /dev/null +++ b/relay/channel/task/doubao/adaptor.go @@ -0,0 +1,245 @@ +package doubao + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type ContentItem struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` // for text type + ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type +} + +type ImageURL struct { + URL string `json:"url"` +} + +type requestPayload struct { + Model string `json:"model"` + Content []ContentItem `json:"content"` +} + +type responsePayload struct { + ID string `json:"id"` // task_id +} + +type responseTask struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Content struct { + VideoURL string `json:"video_url"` + } `json:"content"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio"` + FramesPerSecond int `json:"framespersecond"` + Usage struct { + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +// BuildRequestBody converts request into Doubao specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Doubao response + var dResp responsePayload + if err := json.Unmarshal(responseBody, &dResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if dResp.ID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID}) + return dResp.ID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Model: req.Model, + Content: []ContentItem{}, + } + + // Add text prompt + if req.Prompt != "" { + r.Content = append(r.Content, ContentItem{ + Type: "text", + Text: req.Prompt, + }) + } + + // Add images if present + if req.HasImage() { + for _, imgURL := range req.Images { + r.Content = append(r.Content, ContentItem{ + Type: "image_url", + ImageURL: &ImageURL{ + URL: imgURL, + }, + }) + } + } + + // TODO: Add support for additional parameters from metadata + // such as ratio, duration, seed, etc. + // metadata := req.Metadata + // if metadata != nil { + // // Parse and apply metadata parameters + // } + + return &r, nil +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + // Map Doubao status to internal status + switch resTask.Status { + case "pending", "queued": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "processing": + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "50%" + case "succeeded": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + taskResult.Url = resTask.Content.VideoURL + case "failed": + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + taskResult.Reason = "task failed" + default: + // Unknown status, treat as processing + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "30%" + } + + return &taskResult, nil +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go new file mode 100644 index 000000000..74b416c6d --- /dev/null +++ b/relay/channel/task/doubao/constants.go @@ -0,0 +1,9 @@ +package doubao + +var ModelList = []string{ + "doubao-seedance-1-0-pro-250528", + "doubao-seedance-1-0-lite-t2v", + "doubao-seedance-1-0-lite-i2v", +} + +var ChannelName = "doubao-video" diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 406074c58..c8fd51a11 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -1,6 +1,7 @@ package relay import ( + "github.com/gin-gonic/gin" "one-api/constant" "one-api/relay/channel" "one-api/relay/channel/ali" @@ -24,6 +25,8 @@ import ( "one-api/relay/channel/palm" "one-api/relay/channel/perplexity" "one-api/relay/channel/siliconflow" + "one-api/relay/channel/submodel" + taskdoubao "one-api/relay/channel/task/doubao" taskjimeng "one-api/relay/channel/task/jimeng" "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" @@ -37,8 +40,6 @@ import ( "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" "strconv" - "one-api/relay/channel/submodel" - "github.com/gin-gonic/gin" ) func GetAdaptor(apiType int) channel.Adaptor { @@ -134,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskvertex.TaskAdaptor{} case constant.ChannelTypeVidu: return &taskVidu.TaskAdaptor{} + case constant.ChannelTypeDoubaoVideo: + return &taskdoubao.TaskAdaptor{} } } return nil diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 9ed2e8b5e..3b376ed35 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -164,6 +164,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'SubModel', }, + { + value: 54, + color: 'blue', + label: '豆包视频', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 82d164b38..25afacec0 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -337,6 +337,8 @@ export function getChannelIcon(channelType) { return ; case 51: // 即梦 Jimeng return ; + case 54: // 豆包视频 Doubao Video + return ; case 8: // 自定义渠道 case 22: // 知识库:FastGPT return ; From b244a06ca1d8dd56289ff4556416aa8921ccb185 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 2 Oct 2025 02:46:47 +0800 Subject: [PATCH 43/53] feat: add doubao video use quota by total token --- controller/task_video.go | 84 ++++++++++++++++++++++++++++ relay/channel/task/doubao/adaptor.go | 3 + relay/common/relay_info.go | 14 +++-- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/controller/task_video.go b/controller/task_video.go index 73d5c39b1..8e8a5852d 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -13,6 +13,7 @@ import ( "one-api/relay" "one-api/relay/channel" relaycommon "one-api/relay/common" + "one-api/setting/ratio_setting" "time" ) @@ -120,6 +121,89 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") { task.FailReason = taskResult.Url } + + // 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费 + if taskResult.TotalTokens > 0 { + // 获取模型名称 + var taskData map[string]interface{} + if err := json.Unmarshal(task.Data, &taskData); err == nil { + if modelName, ok := taskData["model"].(string); ok && modelName != "" { + // 获取模型价格和倍率 + modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName) + + // 只有配置了倍率(非固定价格)时才按 token 重新计费 + if hasRatioSetting && modelRatio > 0 { + // 获取用户和组的倍率信息 + user, err := model.GetUserById(task.UserId, false) + if err == nil { + groupRatio := ratio_setting.GetGroupRatio(user.Group) + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group) + + var finalGroupRatio float64 + if hasUserGroupRatio { + finalGroupRatio = userGroupRatio + } else { + finalGroupRatio = groupRatio + } + + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio + actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio) + + // 计算差额 + preConsumedQuota := task.Quota + quotaDelta := actualQuota - preConsumedQuota + + if quotaDelta > 0 { + // 需要补扣费 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(quotaDelta), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil { + logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error())) + } else { + model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta) + model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta) + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录消费日志 + logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d", + modelRatio, finalGroupRatio, taskResult.TotalTokens) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else if quotaDelta < 0 { + // 需要退还多扣的费用 + refundQuota := -quotaDelta + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(refundQuota), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil { + logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error())) + } else { + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录退款日志 + logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,退还 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, logger.LogQuota(refundQuota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else { + // quotaDelta == 0, 预扣费刚好准确 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)", + task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens)) + } + } + } + } + } + } case model.TaskStatusFailure: task.Status = model.TaskStatusFailure task.Progress = "100%" diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 9b40a249a..8cc1fa4f5 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -231,6 +231,9 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e taskResult.Status = model.TaskStatusSuccess taskResult.Progress = "100%" taskResult.Url = resTask.Content.VideoURL + // 解析 usage 信息用于按倍率计费 + taskResult.CompletionTokens = resTask.Usage.CompletionTokens + taskResult.TotalTokens = resTask.Usage.TotalTokens case "failed": taskResult.Status = model.TaskStatusFailure taskResult.Progress = "100%" diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4ffaee23..b2905c57b 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -500,10 +500,12 @@ func (t TaskSubmitReq) HasImage() bool { } type TaskInfo struct { - Code int `json:"code"` - TaskID string `json:"task_id"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Url string `json:"url,omitempty"` - Progress string `json:"progress,omitempty"` + Code int `json:"code"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Url string `json:"url,omitempty"` + Progress string `json:"progress,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 + TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 } From 7ca65a5e8e58bf56c1339510d434f18517435010 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 2 Oct 2025 03:46:00 +0800 Subject: [PATCH 44/53] feat: add doubao video add log detail --- controller/task_video.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/controller/task_video.go b/controller/task_video.go index 8e8a5852d..ded011fe9 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -170,8 +170,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.Quota = actualQuota // 更新任务记录的实际扣费额度 // 记录消费日志 - logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d", - modelRatio, finalGroupRatio, taskResult.TotalTokens) + logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta)) model.RecordLog(task.UserId, model.LogTypeSystem, logContent) } } else if quotaDelta < 0 { @@ -190,8 +191,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.Quota = actualQuota // 更新任务记录的实际扣费额度 // 记录退款日志 - logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,退还 %s", - modelRatio, finalGroupRatio, taskResult.TotalTokens, logger.LogQuota(refundQuota)) + logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota)) model.RecordLog(task.UserId, model.LogTypeSystem, logContent) } } else { From 26a563da54c65f4ade601b567a51da86013e0537 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 13:57:49 +0800 Subject: [PATCH 45/53] fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields --- relay/common/relay_info.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index cb66cd806..35f8ad191 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -515,7 +515,8 @@ type TaskInfo struct { func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { var data map[string]interface{} if err := common.Unmarshal(jsonData, &data); err != nil { - return jsonData, err + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil } // 默认移除 service_tier,除非明确允许(避免额外计费风险) @@ -539,5 +540,10 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther } } - return common.Marshal(data) + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil } From 6a1de0ebdca9ffaa665ffb0346a04b30986a50e5 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 14:28:58 +0800 Subject: [PATCH 46/53] fix: merge conflict --- .../components/table/channels/modals/EditChannelModal.jsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 5cff89616..09cfb0f05 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -2521,8 +2521,6 @@ const EditChannelModal = (props) => { '键为原状态码,值为要复写的状态码,仅影响本地判断', )} /> - - {/* 字段透传控制 - OpenAI 渠道 */} {inputs.type === 1 && ( @@ -2593,7 +2591,8 @@ const EditChannelModal = (props) => { /> )} - + + {/* Channel Extra Settings Card */}
formSectionRefs.current.channelExtraSettings = el}> @@ -2699,8 +2698,6 @@ const EditChannelModal = (props) => { />
- - )} From 01469aa01c56c84e2c705b09621cca6345852f1c Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 15:28:09 +0800 Subject: [PATCH 47/53] refactor(adaptor): extract common header operations into a separate function --- relay/channel/aws/adaptor.go | 7 +------ relay/channel/claude/adaptor.go | 15 ++++++++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index 6202c9fc4..92d60df48 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -7,7 +7,6 @@ import ( "one-api/dto" "one-api/relay/channel/claude" relaycommon "one-api/relay/common" - "one-api/setting/model_setting" "one-api/types" "github.com/gin-gonic/gin" @@ -52,11 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { - anthropicBeta := c.Request.Header.Get("anthropic-beta") - if anthropicBeta != "" { - req.Set("anthropic-beta", anthropicBeta) - } - model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + claude.CommonClaudeHeadersOperation(c, req, info) return nil } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 362f09e77..17e7cbd2b 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -64,6 +64,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return baseURL, nil } +func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) { + // common headers operation + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } + model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) +} + func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) req.Set("x-api-key", info.ApiKey) @@ -72,11 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel anthropicVersion = "2023-06-01" } req.Set("anthropic-version", anthropicVersion) - anthropicBeta := c.Request.Header.Get("anthropic-beta") - if anthropicBeta != "" { - req.Set("anthropic-beta", anthropicBeta) - } - model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + CommonClaudeHeadersOperation(c, req, info) return nil } From 81a66be721b3fd8b9b1c695f7673fe9231469c16 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 16:14:15 +0800 Subject: [PATCH 48/53] chore(docker): switch from MySQL to PostgreSQL in docker-compose configuration --- docker-compose.yml | 63 +++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d98fd706e..b98776d1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,17 @@ +# New-API Docker Compose Configuration +# +# Quick Start: +# 1. docker-compose up -d +# 2. Access at http://localhost:3000 +# +# Using MySQL instead of PostgreSQL: +# 1. Comment out the postgres service and SQL_DSN line 15 +# 2. Uncomment the mysql service and SQL_DSN line 16 +# 3. Uncomment mysql in depends_on (line 28) +# 4. Uncomment mysql_data in volumes section (line 64) +# +# ⚠️ IMPORTANT: Change all default passwords before deploying to production! + version: '3.4' services: @@ -12,21 +26,22 @@ services: - ./data:/data - ./logs:/app/logs environment: - - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service + - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! +# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 - # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 - # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!! - # - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment - # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed - # - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL + - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled +# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions +# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!! +# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed depends_on: - redis - - mysql + - postgres +# - mysql # Uncomment if using MySQL healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"] + test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -36,17 +51,31 @@ services: container_name: redis restart: always - mysql: - image: mysql:8.2 - container_name: mysql + postgres: + image: postgres:15 + container_name: postgres restart: always environment: - MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN - MYSQL_DATABASE: new-api + POSTGRES_USER: root + POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! + POSTGRES_DB: new-api volumes: - - mysql_data:/var/lib/mysql - # ports: - # - "3306:3306" # If you want to access MySQL from outside Docker, uncomment + - pg_data:/var/lib/postgresql/data +# ports: +# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker + +# mysql: +# image: mysql:8.2 +# container_name: mysql +# restart: always +# environment: +# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! +# MYSQL_DATABASE: new-api +# volumes: +# - mysql_data:/var/lib/mysql +# ports: +# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker volumes: - mysql_data: + pg_data: +# mysql_data: From b0b275b2360e1a9f75f50cffb7d7dbe46e0150fd Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 16:20:15 +0800 Subject: [PATCH 49/53] chore(docker): add comment for compatibility with older Docker versions --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b98776d1f..e657390a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ # # ⚠️ IMPORTANT: Change all default passwords before deploying to production! -version: '3.4' +version: '3.4' # For compatibility with older Docker versions services: new-api: From df19a8de5dd3b4659bdcb4844ddce41d59dd96eb Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 27 Sep 2025 18:47:53 +0800 Subject: [PATCH 50/53] =?UTF-8?q?=E2=9C=A8=20feat(layout):=20refine=20foot?= =?UTF-8?q?er=20visibility=20logic=20to=20target=20CardPro=20component=20p?= =?UTF-8?q?ages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace blanket console route footer hiding with specific page targeting - Only hide footer on pages that use CardPro component: * /console/channel (channels management) * /console/log (usage logs) * /console/redemption (redemption codes) * /console/user (user management) * /console/token (token management) * /console/midjourney (midjourney logs) * /console/task (task logs) * /console/models (model management) * /pricing (pricing page) - Footer now displays on other console pages (dashboard, settings, topup, etc.) - Improves UI consistency by showing footer where CardPro's internal pagination isn't used This change ensures footer is only hidden when CardPro component provides its own pagination/footer functionality, while preserving footer visibility on other pages that benefit from the global footer navigation. --- web/jsconfig.json | 2 +- .../common/modals/TwoFactorAuthModal.jsx | 4 +- web/src/components/layout/PageLayout.jsx | 16 +++- web/src/components/settings/SystemSetting.jsx | 74 +++++++++++------ .../channels/modals/EditChannelModal.jsx | 31 ++----- .../table/mj-logs/MjLogsFilters.jsx | 4 +- .../table/task-logs/TaskLogsColumnDefs.jsx | 5 +- .../table/task-logs/TaskLogsFilters.jsx | 4 +- .../table/usage-logs/UsageLogsFilters.jsx | 4 +- web/src/components/topup/RechargeCard.jsx | 82 +++++++++++++------ web/src/components/topup/index.jsx | 31 ++++--- .../topup/modals/PaymentConfirmModal.jsx | 7 +- web/src/constants/console.constants.js | 10 +-- web/src/helpers/api.js | 2 - web/src/helpers/render.jsx | 2 +- web/src/hooks/common/useSidebar.js | 5 +- .../Setting/Operation/SettingsGeneral.jsx | 27 +++--- .../Setting/Operation/SettingsMonitoring.jsx | 3 +- .../Payment/SettingsPaymentGateway.jsx | 42 +++++++--- .../Setting/Ratio/ModelRatioSettings.jsx | 28 ++++--- 20 files changed, 240 insertions(+), 143 deletions(-) diff --git a/web/jsconfig.json b/web/jsconfig.json index ced4d0543..170a7cb4c 100644 --- a/web/jsconfig.json +++ b/web/jsconfig.json @@ -6,4 +6,4 @@ } }, "include": ["src/**/*"] -} \ No newline at end of file +} diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx index 2a9a8b25b..082e63d79 100644 --- a/web/src/components/common/modals/TwoFactorAuthModal.jsx +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({ autoFocus /> - {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')} + {t( + '支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。', + )} diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index f8cdfb0cb..6474501dd 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -48,9 +48,19 @@ const PageLayout = () => { const { i18n } = useTranslation(); const location = useLocation(); - const shouldHideFooter = - location.pathname.startsWith('/console') || - location.pathname === '/pricing'; + const cardProPages = [ + '/console/channel', + '/console/log', + '/console/redemption', + '/console/user', + '/console/token', + '/console/midjourney', + '/console/task', + '/console/models', + '/pricing', + ]; + + const shouldHideFooter = cardProPages.includes(location.pathname); const shouldInnerPadding = location.pathname.includes('/console') && diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 112d104a6..780e89fb1 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -46,7 +46,6 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ - PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -212,7 +211,9 @@ const SystemSetting = () => { setInputs(newInputs); setOriginInputs(newInputs); // 同步模式布尔到本地状态 - if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') { + if ( + typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined' + ) { setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']); } if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') { @@ -749,14 +750,17 @@ const SystemSetting = () => { noLabel extraText={t('SSRF防护开关详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + handleCheckboxChange( + 'fetch_setting.enable_ssrf_protection', + e, + ) } > {t('启用SSRF防护(推荐开启以保护服务器安全)')} - + { noLabel extraText={t('私有IP访问详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.allow_private_ip', e) + handleCheckboxChange( + 'fetch_setting.allow_private_ip', + e, + ) } > - {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')} + {t( + '允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)', + )} - + { noLabel extraText={t('域名IP过滤详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + handleCheckboxChange( + 'fetch_setting.apply_ip_filter_for_domain', + e, + ) } style={{ marginBottom: 8 }} > @@ -794,17 +806,23 @@ const SystemSetting = () => { {t(domainFilterMode ? '域名白名单' : '域名黑名单')} - - {t('支持通配符格式,如:example.com, *.api.example.com')} + + {t( + '支持通配符格式,如:example.com, *.api.example.com', + )} { - const selected = val && val.target ? val.target.value : val; + const selected = + val && val.target ? val.target.value : val; const isWhitelist = selected === 'whitelist'; setDomainFilterMode(isWhitelist); - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, 'fetch_setting.domain_filter_mode': isWhitelist, })); @@ -819,9 +837,9 @@ const SystemSetting = () => { onChange={(value) => { setDomainList(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.domain_list': value + 'fetch_setting.domain_list': value, })); }} placeholder={t('输入域名后回车,如:example.com')} @@ -838,17 +856,21 @@ const SystemSetting = () => { {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} - + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} { - const selected = val && val.target ? val.target.value : val; + const selected = + val && val.target ? val.target.value : val; const isWhitelist = selected === 'whitelist'; setIpFilterMode(isWhitelist); - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, 'fetch_setting.ip_filter_mode': isWhitelist, })); @@ -863,9 +885,9 @@ const SystemSetting = () => { onChange={(value) => { setIpList(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.ip_list': value + 'fetch_setting.ip_list': value, })); }} placeholder={t('输入IP地址后回车,如:8.8.8.8')} @@ -880,7 +902,10 @@ const SystemSetting = () => { >
{t('允许的端口')} - + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} { onChange={(value) => { setAllowedPorts(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.allowed_ports': value + 'fetch_setting.allowed_ports': value, })); }} placeholder={t('输入端口后回车,如:80 或 8000-8999')} style={{ width: '100%' }} /> - + {t('端口配置详细说明')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 09cfb0f05..e9a21c20e 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -91,22 +91,7 @@ const REGION_EXAMPLE = { // 支持并且已适配通过接口获取模型列表的渠道类型 const MODEL_FETCHABLE_TYPES = new Set([ - 1, - 4, - 14, - 34, - 17, - 26, - 24, - 47, - 25, - 20, - 23, - 31, - 35, - 40, - 42, - 48, + 1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43, ]); @@ -279,8 +264,8 @@ const EditChannelModal = (props) => { const scrollToSection = (sectionKey) => { const sectionElement = formSectionRefs.current[sectionKey]; if (sectionElement) { - sectionElement.scrollIntoView({ - behavior: 'smooth', + sectionElement.scrollIntoView({ + behavior: 'smooth', block: 'start', inline: 'nearest' }); @@ -301,7 +286,7 @@ const EditChannelModal = (props) => { } else { newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; } - + setCurrentSectionIndex(newIndex); scrollToSection(availableSections[newIndex]); }; @@ -1340,7 +1325,7 @@ const EditChannelModal = (props) => { type='tertiary' icon={} onClick={() => navigateToSection('up')} - style={{ + style={{ borderRadius: '50%', width: '32px', height: '32px', @@ -1356,7 +1341,7 @@ const EditChannelModal = (props) => { type='tertiary' icon={} onClick={() => navigateToSection('down')} - style={{ + style={{ borderRadius: '50%', width: '32px', height: '32px', @@ -1398,8 +1383,8 @@ const EditChannelModal = (props) => { > {() => ( -
formSectionRefs.current.basicInfo = el}> diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx index 6db96e791..7c61454e0 100644 --- a/web/src/components/table/mj-logs/MjLogsFilters.jsx +++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx @@ -56,10 +56,10 @@ const MjLogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} />
diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index b63c7dd4f..1f097b2b7 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -36,8 +36,9 @@ import { } from 'lucide-react'; import { TASK_ACTION_FIRST_TAIL_GENERATE, - TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE, - TASK_ACTION_TEXT_GENERATE + TASK_ACTION_GENERATE, + TASK_ACTION_REFERENCE_GENERATE, + TASK_ACTION_TEXT_GENERATE, } from '../../../constants/common.constant'; import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx index e27cea867..3bfae77a4 100644 --- a/web/src/components/table/task-logs/TaskLogsFilters.jsx +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -56,10 +56,10 @@ const TaskLogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} />
diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx index 58e5a4692..840c82eea 100644 --- a/web/src/components/table/usage-logs/UsageLogsFilters.jsx +++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx @@ -57,10 +57,10 @@ const LogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} /> diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 03ea2b31e..0a299ffa2 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -30,7 +30,8 @@ import { Space, Row, Col, - Spin, Tooltip + Spin, + Tooltip, } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; @@ -266,7 +267,8 @@ const RechargeCard = ({ {payMethods && payMethods.length > 0 ? ( {payMethods.map((payMethod) => { - const minTopupVal = Number(payMethod.min_topup) || 0; + const minTopupVal = + Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; const disabled = (!enableOnlineTopUp && !isStripe) || @@ -280,7 +282,9 @@ const RechargeCard = ({ type='tertiary' onClick={() => preTopUp(payMethod.type)} disabled={disabled} - loading={paymentLoading && payWay === payMethod.type} + loading={ + paymentLoading && payWay === payMethod.type + } icon={ payMethod.type === 'alipay' ? ( @@ -291,7 +295,10 @@ const RechargeCard = ({ ) : ( ) } @@ -301,12 +308,22 @@ const RechargeCard = ({ ); - return disabled && minTopupVal > Number(topUpCount || 0) ? ( - + return disabled && + minTopupVal > Number(topUpCount || 0) ? ( + {buttonEl} ) : ( - {buttonEl} + + {buttonEl} + ); })} @@ -324,23 +341,27 @@ const RechargeCard = ({
{presetAmounts.map((preset, index) => { - const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + const discount = + preset.discount || + topupInfo?.discount?.[preset.value] || + 1.0; const originalPrice = preset.value * priceRatio; const discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; const actualPay = discountedPrice; const save = originalPrice - discountedPrice; - + return ( { @@ -352,24 +373,35 @@ const RechargeCard = ({ }} >
- + {formatLargeNumber(preset.value)} {hasDiscount && ( - - {t('折').includes('off') ? - ((1 - parseFloat(discount)) * 100).toFixed(1) : - (discount * 10).toFixed(1)}{t('折')} - + + {t('折').includes('off') + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) + : (discount * 10).toFixed(1)} + {t('折')} + )} -
+
{t('实付')} {actualPay.toFixed(2)}, - {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} + {hasDiscount + ? `${t('节省')} ${save.toFixed(2)}` + : `${t('节省')} 0.00`}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 929a47e39..558c67050 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,11 +80,11 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); - + // 充值配置信息 const [topupInfo, setTopupInfo] = useState({ amount_options: [], - discount: {} + discount: {}, }); const topUp = async () => { @@ -262,9 +262,9 @@ const TopUp = () => { if (success) { setTopupInfo({ amount_options: data.amount_options || [], - discount: data.discount || {} + discount: data.discount || {}, }); - + // 处理支付方式 let payMethods = data.pay_methods || []; try { @@ -280,10 +280,15 @@ const TopUp = () => { payMethods = payMethods.map((method) => { // 规范化最小充值数 const normalizedMinTopup = Number(method.min_topup); - method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + method.min_topup = Number.isFinite(normalizedMinTopup) + ? normalizedMinTopup + : 0; // Stripe 的最小充值从后端字段回填 - if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { + if ( + method.type === 'stripe' && + (!method.min_topup || method.min_topup <= 0) + ) { const stripeMin = Number(data.stripe_min_topup); if (Number.isFinite(stripeMin)) { method.min_topup = stripeMin; @@ -313,7 +318,11 @@ const TopUp = () => { setPayMethods(payMethods); const enableStripeTopUp = data.enable_stripe_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; - const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; + const minTopUpValue = enableOnlineTopUp + ? data.min_topup + : enableStripeTopUp + ? data.stripe_min_topup + : 1; setEnableOnlineTopUp(enableOnlineTopUp); setEnableStripeTopUp(enableStripeTopUp); setMinTopUp(minTopUpValue); @@ -330,12 +339,12 @@ const TopUp = () => { console.log('解析支付方式失败:', e); setPayMethods([]); } - + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 if (data.amount_options && data.amount_options.length > 0) { - const customPresets = data.amount_options.map(amount => ({ + const customPresets = data.amount_options.map((amount) => ({ value: amount, - discount: data.discount[amount] || 1.0 + discount: data.discount[amount] || 1.0, })); setPresetAmounts(customPresets); } @@ -483,7 +492,7 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - + // 计算实际支付金额,考虑折扣 const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 1bffbfed1..8bd5455c7 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -40,9 +40,10 @@ const PaymentConfirmModal = ({ amountNumber, discountRate, }) => { - const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; - const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; - const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; + const hasDiscount = + discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? amountNumber / discountRate : 0; + const discountAmount = hasDiscount ? originalAmount - amountNumber : 0; return ( dayjs().startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '近 7 天', start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本周', start: () => dayjs().startOf('week').toDate(), - end: () => dayjs().endOf('week').toDate() + end: () => dayjs().endOf('week').toDate(), }, { text: '近 30 天', start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本月', start: () => dayjs().startOf('month').toDate(), - end: () => dayjs().endOf('month').toDate() + end: () => dayjs().endOf('month').toDate(), }, ]; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index bc389b2e8..1ccfffaf2 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -131,13 +131,11 @@ export const buildApiPayload = ( seed: 'seed', }; - Object.entries(parameterMappings).forEach(([key, param]) => { const enabled = parameterEnabled[key]; const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (enabled && hasValue) { payload[param] = value; } diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 25afacec0..78ff8a44d 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1074,7 +1074,7 @@ export function renderModelPrice( (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageGenerationCallPrice * groupRatio); + imageGenerationCallPrice * groupRatio; return ( <> diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 0ccc58354..76d74ac34 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -183,7 +183,10 @@ export const useSidebar = () => { sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); return () => { - sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + sidebarEventTarget.removeEventListener( + SIDEBAR_REFRESH_EVENT, + handleRefresh, + ); }; }, [adminConfig]); diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index 5af750ec3..b8b925dcf 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -130,19 +130,20 @@ export default function GeneralSettings(props) { showClear /> - {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( -
- setShowQuotaWarning(true)} - /> - - )} + {inputs.QuotaPerUnit !== '500000' && + inputs.QuotaPerUnit !== 500000 && ( + + setShowQuotaWarning(true)} + /> + + )} setInputs({ ...inputs, - 'monitor_setting.auto_test_channel_minutes': parseInt(value), + 'monitor_setting.auto_test_channel_minutes': + parseInt(value), }) } /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index d681b6a27..a4f1029a1 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) { } } - if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { + if ( + originInputs['AmountOptions'] !== inputs.AmountOptions && + inputs.AmountOptions.trim() !== '' + ) { if (!verifyJSON(inputs.AmountOptions)) { showError(t('自定义充值数量选项不是合法的 JSON 数组')); return; } } - if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if ( + originInputs['AmountDiscount'] !== inputs.AmountDiscount && + inputs.AmountDiscount.trim() !== '' + ) { if (!verifyJSON(inputs.AmountDiscount)) { showError(t('充值金额折扣配置不是合法的 JSON 对象')); return; @@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } if (originInputs['AmountOptions'] !== inputs.AmountOptions) { - options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); + options.push({ + key: 'payment_setting.amount_options', + value: inputs.AmountOptions, + }); } if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { - options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); + options.push({ + key: 'payment_setting.amount_discount', + value: inputs.AmountDiscount, + }); } // 发送请求 @@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本')} autosize /> - + - + - + diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index ed982edcf..b298cc787 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, ImageRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, ImageRatio: value })} /> @@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, AudioRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, AudioRatio: value })} /> @@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) { Date: Thu, 2 Oct 2025 19:00:07 +0800 Subject: [PATCH 51/53] refactor(footer): update footer links and localization text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the 'chatnio' link from the footer. - Added new links for 'CoAI' and 'GPT-Load' in the footer. - Updated the localization key for '基于New API的项目' to '友情链接' for better clarity. - Adjusted the design of the footer to improve layout and visibility of the developer credit. --- web/src/components/layout/Footer.jsx | 57 +++++++++++++++++----------- web/src/i18n/locales/en.json | 3 +- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/web/src/components/layout/Footer.jsx b/web/src/components/layout/Footer.jsx index 5c210fca8..c827a581b 100644 --- a/web/src/components/layout/Footer.jsx +++ b/web/src/components/layout/Footer.jsx @@ -142,14 +142,6 @@ const FooterBar = () => { > Midjourney-Proxy - - chatnio - { @@ -200,15 +207,6 @@ const FooterBar = () => { > New API - & - - One API - @@ -223,10 +221,23 @@ const FooterBar = () => { return (
{footer ? ( -
+
+
+
+ {t('设计与开发由')} + + New API + +
+
) : ( customFooter )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6ffff050c..cb213b997 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -759,7 +759,6 @@ "获取当前设置失败": "Failed to get current settings", "设置已更新": "Settings updated", "更新设置失败": "Update settings failed", - "确认解绑": "Confirm unbinding", "您确定要解绑WxPusher吗?": "Are you sure you want to unbind WxPusher?", "解绑失败": "Unbinding failed", "订阅事件": "Subscribe to events", @@ -1478,7 +1477,7 @@ "相关项目": "Related Projects", "基于New API的项目": "Projects Based on New API", "版权所有": "All rights reserved", - "设计与开发由": "Designed & Developed with love by", + "设计与开发由": "Designed & Developed by", "演示站点": "Demo Site", "页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct", "您无权访问此页面,请联系管理员": "You do not have permission to access this page. Please contact the administrator.", From 01bcbf09c6b11ff49bfb01299e5a3736c28d5e17 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 19:29:57 +0800 Subject: [PATCH 52/53] =?UTF-8?q?=E2=9C=A8=20feat(api):=20add=20header=20o?= =?UTF-8?q?verride=20processing=20with=20variable=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/api_request.go | 48 ++++++++++++----- .../channels/modals/EditChannelModal.jsx | 52 ++++++++++++------- web/src/i18n/locales/en.json | 16 +++++- 3 files changed, 81 insertions(+), 35 deletions(-) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 79a0f7060..548e720d9 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -14,6 +14,7 @@ import ( "one-api/service" "one-api/setting/operation_setting" "one-api/types" + "strings" "sync" "time" @@ -36,6 +37,26 @@ func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Hea } } +// processHeaderOverride 处理请求头覆盖,支持变量替换 +// 支持的变量:{api_key} +func processHeaderOverride(info *common.RelayInfo) (map[string]string, error) { + headerOverride := make(map[string]string) + for k, v := range info.HeadersOverride { + str, ok := v.(string) + if !ok { + return nil, types.NewError(nil, types.ErrorCodeChannelHeaderOverrideInvalid) + } + + // 替换支持的变量 + if strings.Contains(str, "{api_key}") { + str = strings.ReplaceAll(str, "{api_key}", info.ApiKey) + } + + headerOverride[k] = str + } + return headerOverride, nil +} + func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.GetRequestURL(info) if err != nil { @@ -49,13 +70,9 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("new request failed: %w", err) } headers := req.Header - headerOverride := make(map[string]string) - for k, v := range info.HeadersOverride { - if str, ok := v.(string); ok { - headerOverride[k] = str - } else { - return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) - } + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err } for key, value := range headerOverride { headers.Set(key, value) @@ -86,13 +103,9 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod // set form data req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) headers := req.Header - headerOverride := make(map[string]string) - for k, v := range info.HeadersOverride { - if str, ok := v.(string); ok { - headerOverride[k] = str - } else { - return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) - } + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err } for key, value := range headerOverride { headers.Set(key, value) @@ -114,6 +127,13 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("get request url failed: %w", err) } targetHeader := http.Header{} + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err + } + for key, value := range headerOverride { + targetHeader.Set(key, value) + } err = a.SetupRequestHeader(c, &targetHeader, info) if err != nil { return nil, fmt.Errorf("setup request header failed: %w", err) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index e9a21c20e..dfbd75a43 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -2452,32 +2452,44 @@ const EditChannelModal = (props) => { t('此项可选,用于覆盖请求头参数') + '\n' + t('格式示例:') + - '\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0"\n}' + '\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}' } autosize onChange={(value) => handleInputChange('header_override', value) } extraText={ -
- - handleInputChange( - 'header_override', - JSON.stringify( - { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', - }, - null, - 2, - ), - ) - } - > - {t('格式模板')} - + +
+
+ + handleInputChange( + 'header_override', + JSON.stringify( + { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', + 'Authorization': 'Bearer{api_key}', + }, + null, + 2, + ), + ) + } + > + {t('填入模板')} + +
+
+ + {t('支持变量:')} + +
+
{t('渠道密钥')}: {'{api_key}'}
+
+
} showClear diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index cb213b997..58b743e13 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2221,7 +2221,6 @@ "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment", "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com", "保存 Passkey 设置": "Save Passkey Settings", - "黑名单": "Blacklist", "字段透传控制": "Field Pass-through Control", "允许 service_tier 透传": "Allow service_tier Pass-through", "禁用 store 透传": "Disable store Pass-through", @@ -2229,6 +2228,21 @@ "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction", "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy", + "支持变量:": "Supported variables:", + "请求头覆盖": "Request header override", + "旧格式模板": "Old format template", + "新格式模板": "New format template", + "系统提示词拼接": "System prompt append", + "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt", + "键为请求中的模型名称,值为要替换的模型名称": "Key is the model name in the request, value is the model name to replace", + "仅影响本地判断,不修改返回到上游的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "Only affects local judgment, does not modify the status code returned to the upstream, for example, rewrite the 400 error of the claude channel to 500 (for retry). Please do not abuse this function, for example:", + "密钥更新模式": "Key update mode", + "请选择密钥更新模式": "Please select key update mode", + "追加到现有密钥": "Append to existing key", + "覆盖现有密钥": "Overwrite existing key", + "追加模式:将新密钥添加到现有密钥列表末尾": "Append mode: add new keys to the end of the existing key list", + "覆盖模式:将完全替换现有的所有密钥": "Overwrite mode: completely replace all existing keys", + "轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented", "common": { "changeLanguage": "Change Language" } From 66d0764fc1e7a188d66b0c489568dc85deb35f7f Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 19:55:37 +0800 Subject: [PATCH 53/53] =?UTF-8?q?=E2=9C=A8=20docs:=20update=20README=20fil?= =?UTF-8?q?es=20to=20include=20Japanese=20language=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 21 +++-- README.fr.md | 21 +++-- README.ja.md | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 20 ++--- 4 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 README.ja.md diff --git a/README.en.md b/README.en.md index 2349104aa..60d4f6a02 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,10 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

+ +> [!NOTE] +> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md). +
![new-api](/web/public/logo.png) @@ -75,7 +79,7 @@ New API offers a wide range of features, please refer to [Features Introduction] 1. 🎨 Brand new UI interface 2. 🌍 Multi-language support -3. 💰 Online recharge functionality (YiPay) +3. 💰 Online recharge functionality, currently supports EPay and Stripe 4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 Compatible with the original One API database 6. 💵 Support for pay-per-use model pricing @@ -96,7 +100,11 @@ New API offers a wide range of features, please refer to [Features Introduction] - Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 Thinking-to-content functionality 17. 🔄 Model rate limiting for users -18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: +18. 🔄 Request format conversion functionality, supporting the following three format conversions: + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: 1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings` 2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit 3. Supported channels: @@ -115,7 +123,9 @@ This version supports multiple models, please refer to [API Documentation-Relay 4. Custom channels, supporting full call address input 5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank) 6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) -7. Dify, currently only supports chatflow +7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify, currently only supports chatflow +9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api) ## Environment Variable Configuration @@ -192,7 +202,8 @@ For detailed API documentation, please refer to [API Documentation](https://docs - [Image API](https://docs.newapi.pro/api/openai-image) - [Rerank API](https://docs.newapi.pro/api/jinaai-rerank) - [Realtime API](https://docs.newapi.pro/api/openai-realtime) -- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat) +- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat) ## Related Projects - [One API](https://github.com/songquanpeng/one-api): Original project diff --git a/README.fr.md b/README.fr.md index de788ede4..d06980053 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,6 +1,10 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

+ +> [!NOTE] +> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md). +
![new-api](/web/public/logo.png) @@ -75,7 +79,7 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à 1. 🎨 Nouvelle interface utilisateur 2. 🌍 Prise en charge multilingue -3. 💰 Fonctionnalité de recharge en ligne (YiPay) +3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe 4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 Compatible avec la base de données originale de One API 6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation @@ -96,7 +100,11 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à - Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 Fonctionnalité de la pensée au contenu 17. 🔄 Limitation du débit du modèle pour les utilisateurs -18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : +18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes : + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : 1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement` 2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint 3. Canaux pris en charge : @@ -115,7 +123,9 @@ Cette version prend en charge plusieurs modèles, veuillez vous référer à [Do 4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel 5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank) 6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) -7. Dify, ne prend actuellement en charge que chatflow +7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify, ne prend actuellement en charge que chatflow +9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api) ## Configuration des variables d'environnement @@ -192,7 +202,8 @@ Pour une documentation détaillée de l'API, veuillez vous référer à [Documen - [API d'image](https://docs.newapi.pro/api/openai-image) - [API de rerank](https://docs.newapi.pro/api/jinaai-rerank) - [API en temps réel](https://docs.newapi.pro/api/openai-realtime) -- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat) +- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat) +- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat) ## Projets connexes - [One API](https://github.com/songquanpeng/one-api) : Projet original diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 000000000..13049e86d --- /dev/null +++ b/README.ja.md @@ -0,0 +1,224 @@ +

+ 中文 | English | Français | 日本語 +

+ +> [!NOTE] +> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。 + +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥次世代大規模モデルゲートウェイとAI資産管理システム + +Calcium-Ion%2Fnew-api | Trendshift + +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+
+ +## 📝 プロジェクト説明 + +> [!NOTE] +> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです + +> [!IMPORTANT] +> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。 +> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。 +> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。 + +

🤝 信頼できるパートナー

+

 

+

順不同

+

+ Cherry Studio + 北京大学 + UCloud 優刻得 + Alibaba Cloud + IO.NET +

+

 

+ +## 📚 ドキュメント + +詳細なドキュメントは公式Wikiをご覧ください:[https://docs.newapi.pro/](https://docs.newapi.pro/) + +AIが生成したDeepWikiにもアクセスできます: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +## ✨ 主な機能 + +New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください: + +1. 🎨 全く新しいUIインターフェース +2. 🌍 多言語サポート +3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート +4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携) +5. 🔄 オリジナルのOne APIデータベースと互換性あり +6. 💵 モデルの従量課金をサポート +7. ⚖️ チャネルの重み付けランダムをサポート +8. 📈 データダッシュボード(コンソール) +9. 🔒 トークングループ化、モデル制限 +10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC) +11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) +12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime) +13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +14. /chat2linkルートを使用してチャット画面に入ることをサポート +15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート: + 1. OpenAI oシリーズモデル + - `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`) + - `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`) + - `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`) + 2. Claude思考モデル + - `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`) +16. 🔄 思考からコンテンツへの機能 +17. 🔄 ユーザーに対するモデルレート制限機能 +18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート: + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます: + 1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定 + 2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金 + 3. サポートされているチャネル: + - [x] OpenAI + - [x] Azure + - [x] DeepSeek + - [x] Claude + +## モデルサポート + +このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください: + +1. サードパーティモデル **gpts**(gpt-4-gizmo-*) +2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) +3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music) +4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート +5. Rerankモデル([Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank) +6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify、現在はchatflowのみをサポート +9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください + +## 環境変数設定 + +詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください: + +- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false` +- `STREAMING_TIMEOUT`:ストリーミング応答のタイムアウト時間、デフォルトは300秒 +- `DIFY_DEBUG`:Difyチャネルがワークフローとノード情報を出力するかどうか、デフォルトは`true` +- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true` +- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true` +- `UPDATE_TASK`:非同期タスク(Midjourney、Suno)を更新するかどうか、デフォルトは`true` +- `GEMINI_VISION_MAX_IMAGE_NUM`:Geminiモデルの最大画像数、デフォルトは`16` +- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20` +- `CRYPTO_SECRET`:暗号化キー、Redisデータベースの内容を暗号化するために使用 +- `AZURE_DEFAULT_API_VERSION`:Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview` +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`分 +- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2` +- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false` + +## デプロイ + +詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください: + +> [!TIP] +> 最新のDockerイメージ:`calciumion/new-api:latest` + +### マルチマシンデプロイの注意事項 +- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります +- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません + +### デプロイ要件 +- ローカルデータベース(デフォルト):SQLite(Dockerデプロイの場合は`/data`ディレクトリをマウントする必要があります) +- リモートデータベース:MySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6 + +### デプロイ方法 + +#### 宝塔パネルのDocker機能を使用してデプロイ +宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。 +[画像付きチュートリアル](./docs/BT.md) + +#### Docker Composeを使用してデプロイ(推奨) +```shell +# プロジェクトをダウンロード +git clone https://github.com/Calcium-Ion/new-api.git +cd new-api +# 必要に応じてdocker-compose.ymlを編集 +# 起動 +docker-compose up -d +``` + +#### Dockerイメージを直接使用 +```shell +# SQLiteを使用 +docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest + +# MySQLを使用 +docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest +``` + +## チャネルリトライとキャッシュ +チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。 + +### キャッシュ設定方法 +1. `REDIS_CONN_STRING`:Redisをキャッシュとして設定 +2. `MEMORY_CACHE_ENABLED`:メモリキャッシュを有効にする(Redisを設定した場合は手動設定不要) + +## APIドキュメント + +詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください: + +- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat) +- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image) +- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat) +- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat) + +## 関連プロジェクト +- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト +- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourneyインターフェースサポート +- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会 + +New APIベースのその他のプロジェクト: +- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能最適化版 + +## ヘルプサポート + +問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください: +- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction) +- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues) +- [よくある質問](https://docs.newapi.pro/support/faq) + +## 🌟 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + diff --git a/README.md b/README.md index 2103fe8fc..af2b64b44 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

@@ -75,7 +75,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 1. 🎨 全新的UI界面 2. 🌍 多语言支持 -3. 💰 支持在线充值功能(易支付) +3. 💰 支持在线充值功能,当前支持易支付和Stripe 4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 兼容原版One API的数据库 6. 💵 支持模型按次数收费 @@ -119,7 +119,9 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 4. 自定义渠道,支持填入完整调用地址 5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank) 6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) -7. Dify,当前仅支持chatflow +7. Google Gemini格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify,当前仅支持chatflow +9. 更多接口请参考[接口文档](https://docs.newapi.pro/api) ## 环境变量配置 @@ -128,16 +130,14 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do - `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false` - `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒 - `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true` -- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true` - `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true` - `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true` - `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true` -- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE` - `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16` - `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20` -- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容 +- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容 - `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview` -- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟 +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟 - `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2` - `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false` @@ -182,7 +182,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ``` ## 渠道重试与缓存 -渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。 +渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。 ### 缓存设置方法 1. `REDIS_CONN_STRING`:设置Redis作为缓存 @@ -196,12 +196,12 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 - [图像接口(Image)](https://docs.newapi.pro/api/openai-image) - [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat) +- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat) ## 相关项目 - [One API](https://github.com/songquanpeng/one-api):原版项目 - [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持 -- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案 - [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度 其他基于New API的项目: