Compare commits

...

51 Commits

Author SHA1 Message Date
CaIon
01469aa01c refactor(adaptor): extract common header operations into a separate function 2025-10-02 15:28:09 +08:00
Calcium-Ion
0769184b9b Merge pull request #1956 from QuantumNous/alpha
alpha -> main
2025-10-02 15:26:59 +08:00
Seefs
4137120d69 Merge pull request #1779 from feitianbubu/pr/fix-video-get-task
fix: get video task err when Content-Type=json
2025-10-02 14:43:18 +08:00
Seefs
3d1433dd70 Merge branch 'alpha' into pr/fix-video-get-task 2025-10-02 14:43:11 +08:00
Seefs
acbfc9d3b3 Merge pull request #1951 from feitianbubu/pr/add-doubao-video
增加豆包视频渠道
2025-10-02 14:41:41 +08:00
Seefs
4cdad47695 Merge pull request #1955 from seefs001/fix/megge-conflict
fix: merge conflict
2025-10-02 14:30:25 +08:00
Seefs
6a1de0ebdc fix: merge conflict 2025-10-02 14:28:58 +08:00
Seefs
92a4e88ceb Merge pull request #1844 from JoeyLearnsToCode/feat-channel-block-edit
feat: Add navigation buttons for channel edit form sections
2025-10-02 14:16:11 +08:00
Seefs
e9043590a9 Merge branch 'alpha' into feat-channel-block-edit 2025-10-02 14:16:01 +08:00
Seefs
9e6828653b Merge pull request #1947 from HynoR/feat/newedit
feat: Add visual editing mode for chat configurations
2025-10-02 14:11:49 +08:00
Seefs
dae661bb53 Merge pull request #1948 from RedwindA/feat/gotify
feat: Add Gotify Notification Channel for Quota Alerts
2025-10-02 14:11:20 +08:00
Seefs
649a5205c9 Merge pull request #1950 from seefs001/fix/missing-field
fix: missing field & field control
2025-10-02 14:10:11 +08:00
Seefs
26a563da54 fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields 2025-10-02 13:57:49 +08:00
Seefs
c1492be131 Merge pull request #1954 from QuantumNous/main
main -> alpha
2025-10-02 13:50:18 +08:00
feitianbubu
7ca65a5e8e feat: add doubao video add log detail 2025-10-02 04:00:50 +08:00
feitianbubu
b244a06ca1 feat: add doubao video use quota by total token 2025-10-02 04:00:46 +08:00
feitianbubu
c320410c84 feat: add doubao video generate 2025-10-02 04:00:43 +08:00
Seefs
2938246f2e Merge pull request #1949 from RedwindA/fix/oai-responses-webSearch-panic
fix(openai): add nil checks for web_search streaming to prevent panic
2025-10-02 00:36:25 +08:00
Seefs
0e9ad4a15f fix: missing field & field control 2025-10-02 00:14:35 +08:00
RedwindA
2200bb9166 fix(openai): add nil checks for web_search streaming to prevent panic 2025-10-01 22:19:22 +08:00
RedwindA
d6db10b4bc feat: 添加 Bark 和 Gotify 通知的国际化支持 2025-10-01 19:36:19 +08:00
RedwindA
85ff8b1422 feat: add Gotify notification option for quota alerts 2025-10-01 19:15:00 +08:00
HynoR
1428338546 feat: Enhance SettingsChats edit interface 2025-10-01 18:40:02 +08:00
HynoR
ec76b0f5e2 feat: add visual editing mode for chat configurations 2025-10-01 18:22:32 +08:00
Seefs
96b172e93b Merge pull request #1945 from QuantumNous/gemini-robotics-er
feat: 支持 gemini-robotics-er-1.5-preview
2025-10-01 17:41:41 +08:00
creamlike1024
70263e96ab feat: 支持 gemini-robotics-er-1.5-preview 2025-10-01 17:33:54 +08:00
Seefs
15db5c0062 Merge pull request #1943 from RedwindA/fix/jina-embedding
fix(jina): remove encoding_format for jina embedding
2025-10-01 16:58:56 +08:00
RedwindA
f5a774f22c fix(jina): remove encoding_format for jina embedding 2025-10-01 02:06:30 +08:00
CaIon
0b91e45197 fix(adaptor): update relay mode handling to support relay format 2025-09-30 16:53:57 +08:00
CaIon
6bc3e62fd5 feat: add endpoint type selection to channel testing functionality 2025-09-30 16:52:14 +08:00
Calcium-Ion
3ba2aaee32 Merge pull request #1936 from seefs001/fix/passkey
fix: passkey 文案
2025-09-30 16:19:01 +08:00
Seefs
0a6f39e60b fix: passkey 文案 2025-09-30 16:15:33 +08:00
Calcium-Ion
1bd791d603 Merge pull request #1934 from seefs001/fix/passkey
fix: passkey rpid detect
2025-09-30 15:54:49 +08:00
Seefs
fcc6172b43 fix: passkey rpid detect 2025-09-30 15:53:19 +08:00
Seefs
7533ffc3ee Merge pull request #1931 from seefs001/feature/passkey
fix: passkey security
2025-09-30 13:28:18 +08:00
Seefs
e71407ee62 fix: passkey security 2025-09-30 13:18:18 +08:00
Seefs
d026edc1b3 Merge pull request #1930 from seefs001/feature/passkey
fix: passkey model type
2025-09-30 12:52:32 +08:00
Seefs
ab166649bc fix: blob type 2025-09-30 12:40:05 +08:00
Seefs
9f20e49100 Merge pull request #1912 from seefs001/feature/passkey
feat: passkey
2025-09-30 12:28:09 +08:00
Seefs
013a575541 fix: personal setting 2025-09-30 12:26:24 +08:00
Seefs
4c13666f26 Merge branch 'main-upstream' into feature/passkey
# Conflicts:
#	web/src/components/settings/PersonalSetting.jsx
#	web/src/i18n/locales/en.json
#	web/src/i18n/locales/zh.json
2025-09-30 12:15:20 +08:00
Seefs
e8425addf0 feat: 通用二步验证 2025-09-30 12:12:50 +08:00
Seefs
aab82f22fa Merge pull request #1817 from wzxjohn/hotfix/relay_vertex_claude
fix(relay): wrong URL for claude model in GCP Vertex AI
2025-09-30 11:27:15 +08:00
CaIon
8e10af82b1 fix(main): conditionally log missing .env file message based on debug mode 2025-09-30 11:22:00 +08:00
Seefs
84cdd24116 feat: passkey wip 2025-09-29 21:54:16 +08:00
Seefs
dcf4336c75 feat: passkey 2025-09-29 17:45:09 +08:00
JoeyLearnsToCode
3ed0ae83f1 Merge remote-tracking branch 'remotes/up-origin/main' into feat-channel-block-edit 2025-09-28 09:56:35 +08:00
JoeyLearnsToCode
ec9903e640 feat: jump between section on channel edit page 2025-09-19 18:11:47 +08:00
wzxjohn
8d92ce38ed fix(relay): wrong key param while enable sse 2025-09-19 11:22:03 +08:00
wzxjohn
f2e9fd7afb fix(relay): wrong URL for claude model in GCP Vertex AI 2025-09-16 17:18:32 +08:00
feitianbubu
465830945b fix: get video task err when Content-Type=json 2025-09-11 12:53:19 +08:00
69 changed files with 5239 additions and 358 deletions

View File

@@ -23,6 +23,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
}
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在

View File

@@ -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
}

View File

@@ -9,6 +9,7 @@ const (
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"

View File

@@ -38,7 +38,7 @@ type testResult struct {
newAPIError *types.NewAPIError
}
func testChannel(channel *model.Channel, testModel string) testResult {
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
tik := time.Now()
if channel.Type == constant.ChannelTypeMidjourney {
return testResult{
@@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string) testResult {
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"),
@@ -81,18 +87,26 @@ func testChannel(channel *model.Channel, testModel string) testResult {
requestPath := "/v1/chat/completions"
// 先判断是否为 Embedding 模
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
strings.Contains(testModel, "bge-") || // bge 系列模型
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
requestPath = "/v1/embeddings" // 修改请求路径
}
// 如果指定了端点类型,使用指定的端点类
if endpointType != "" {
if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok {
requestPath = endpointInfo.Path
}
} else {
// 如果没有指定端点类型,使用原有的自动检测逻辑
// 先判断是否为 Embedding 模型
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
strings.Contains(testModel, "bge-") || // bge 系列模型
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
requestPath = "/v1/embeddings" // 修改请求路径
}
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
}
c.Request = &http.Request{
@@ -114,21 +128,6 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
// 重新检查模型类型并更新请求路径
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") ||
strings.Contains(testModel, "bge-") ||
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI {
requestPath = "/v1/embeddings"
c.Request.URL.Path = requestPath
}
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
c.Request.URL.Path = requestPath
}
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -153,17 +152,54 @@ func testChannel(channel *model.Channel, testModel string) testResult {
newAPIError: newAPIError,
}
}
request := buildTestRequest(testModel)
// Determine relay format based on request path
relayFormat := types.RelayFormatOpenAI
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
// Determine relay format based on endpoint type or request path
var relayFormat types.RelayFormat
if endpointType != "" {
// 根据指定的端点类型设置 relayFormat
switch constant.EndpointType(endpointType) {
case constant.EndpointTypeOpenAI:
relayFormat = types.RelayFormatOpenAI
case constant.EndpointTypeOpenAIResponse:
relayFormat = types.RelayFormatOpenAIResponses
case constant.EndpointTypeAnthropic:
relayFormat = types.RelayFormatClaude
case constant.EndpointTypeGemini:
relayFormat = types.RelayFormatGemini
case constant.EndpointTypeJinaRerank:
relayFormat = types.RelayFormatRerank
case constant.EndpointTypeImageGeneration:
relayFormat = types.RelayFormatOpenAIImage
case constant.EndpointTypeEmbeddings:
relayFormat = types.RelayFormatEmbedding
default:
relayFormat = types.RelayFormatOpenAI
}
} else {
// 根据请求路径自动检测
relayFormat = types.RelayFormatOpenAI
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
}
if c.Request.URL.Path == "/v1/messages" {
relayFormat = types.RelayFormatClaude
}
if strings.Contains(c.Request.URL.Path, "/v1beta/models") {
relayFormat = types.RelayFormatGemini
}
if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" {
relayFormat = types.RelayFormatRerank
}
if c.Request.URL.Path == "/v1/responses" {
relayFormat = types.RelayFormatOpenAIResponses
}
}
request := buildTestRequest(testModel, endpointType)
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
if err != nil {
@@ -186,7 +222,8 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
testModel = info.UpstreamModelName
request.Model = testModel
// 更新请求中的模型名称
request.SetModelName(testModel)
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
@@ -216,33 +253,62 @@ func testChannel(channel *model.Channel, testModel string) testResult {
var convertedRequest any
// 根据 RelayMode 选择正确的转换函数
if info.RelayMode == relayconstant.RelayModeEmbeddings {
// 创建一个 EmbeddingRequest
embeddingRequest := dto.EmbeddingRequest{
Input: request.Input,
Model: request.Model,
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
// 创建一个 ImageRequest
prompt := "cat"
if request.Prompt != nil {
if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
prompt = promptStr
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
// Embedding 请求 - request 已经是正确的类型
if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok {
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid embedding request type"),
newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed),
}
}
imageRequest := dto.ImageRequest{
Prompt: prompt,
Model: request.Model,
N: uint(request.N),
Size: request.Size,
case relayconstant.RelayModeImagesGenerations:
// 图像生成请求 - request 已经是正确的类型
if imageReq, ok := request.(*dto.ImageRequest); ok {
convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid image request type"),
newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeRerank:
// Rerank 请求 - request 已经是正确的类型
if rerankReq, ok := request.(*dto.RerankRequest); ok {
convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid rerank request type"),
newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeResponses:
// Response 请求 - request 已经是正确的类型
if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok {
convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid response request type"),
newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed),
}
}
default:
// Chat/Completion 等其他请求类型
if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok {
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid general request type"),
newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed),
}
}
// 调用专门用于图像生成的转换函数
convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
@@ -345,22 +411,82 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest := &dto.GeneralOpenAIRequest{
Model: "", // this will be set later
Stream: false,
func buildTestRequest(model string, endpointType string) dto.Request {
// 根据端点类型构建不同的测试请求
if endpointType != "" {
switch constant.EndpointType(endpointType) {
case constant.EndpointTypeEmbeddings:
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
case constant.EndpointTypeImageGeneration:
// 返回 ImageRequest
return &dto.ImageRequest{
Model: model,
Prompt: "a cute cat",
N: 1,
Size: "1024x1024",
}
case constant.EndpointTypeJinaRerank:
// 返回 RerankRequest
return &dto.RerankRequest{
Model: model,
Query: "What is Deep Learning?",
Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."},
TopN: 2,
}
case constant.EndpointTypeOpenAIResponse:
// 返回 OpenAIResponsesRequest
return &dto.OpenAIResponsesRequest{
Model: model,
Input: json.RawMessage("\"hi\""),
}
case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
// 返回 GeneralOpenAIRequest
maxTokens := uint(10)
if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
maxTokens = 3000
}
return &dto.GeneralOpenAIRequest{
Model: model,
Stream: false,
Messages: []dto.Message{
{
Role: "user",
Content: "hi",
},
},
MaxTokens: maxTokens,
}
}
}
// 自动检测逻辑(保持原有行为)
// 先判断是否为 Embedding 模型
if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型
strings.HasPrefix(model, "m3e") || // m3e 系列模型
if strings.Contains(strings.ToLower(model), "embedding") ||
strings.HasPrefix(model, "m3e") ||
strings.Contains(model, "bge-") {
testRequest.Model = model
// Embedding 请求
testRequest.Input = []any{"hello world"} // 修改为any因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
return testRequest
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
}
// 并非Embedding 模型
// Chat/Completion 请求 - 返回 GeneralOpenAIRequest
testRequest := &dto.GeneralOpenAIRequest{
Model: model,
Stream: false,
Messages: []dto.Message{
{
Role: "user",
Content: "hi",
},
},
}
if strings.HasPrefix(model, "o") {
testRequest.MaxCompletionTokens = 10
} else if strings.Contains(model, "thinking") {
@@ -373,12 +499,6 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest.MaxTokens = 10
}
testMessage := dto.Message{
Role: "user",
Content: "hi",
}
testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
@@ -402,8 +522,9 @@ func TestChannel(c *gin.Context) {
// }
//}()
testModel := c.Query("model")
endpointType := c.Query("endpoint_type")
tik := time.Now()
result := testChannel(channel, testModel)
result := testChannel(channel, testModel, endpointType)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -429,7 +550,6 @@ func TestChannel(c *gin.Context) {
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
@@ -463,7 +583,7 @@ func testAllChannels(notify bool) error {
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(channel, "")
result := testChannel(channel, "", "")
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
@@ -477,7 +597,7 @@ func testAllChannels(notify bool) error {
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
@@ -514,7 +634,6 @@ func TestAllChannels(c *gin.Context) {
"success": true,
"message": "",
})
return
}
var autoTestChannelsOnce sync.Once

View File

@@ -384,18 +384,9 @@ func GetChannel(c *gin.Context) {
return
}
// GetChannelKey 验证2FA后获取渠道密钥
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code" binding:"required"`
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -403,24 +394,6 @@ 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无法查看密钥"))
return
}
// 统一的2FA验证逻辑
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
@@ -436,10 +409,10 @@ func GetChannelKey(c *gin.Context) {
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 统一的成功响应格式
// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"message": "获取成功",
"data": map[string]interface{}{
"key": channel.Key,
},

View File

@@ -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,
}

497
controller/passkey.go Normal file
View File

@@ -0,0 +1,497 @@
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,
}
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 {
userID, parseErr := strconv.Atoi(string(userHandle))
if parseErr != nil {
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
} else if userID != user.Id {
return nil, errors.New("用户句柄与凭证不匹配")
}
}
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
}

View File

@@ -0,0 +1,313 @@
package controller
import (
"fmt"
"net/http"
"one-api/common"
"one-api/model"
passkeysvc "one-api/service/passkey"
"one-api/setting/system_setting"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
type UniversalVerifyRequest struct {
Method string `json:"method"` // "2fa" 或 "passkey"
Code string `json:"code,omitempty"`
}
type VerificationStatusResponse struct {
Verified bool `json:"verified"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// UniversalVerify 通用验证接口
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
func UniversalVerify(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
var req UniversalVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
// 获取用户信息
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
// 检查用户的验证方式
twoFA, _ := model.GetTwoFAByUserId(userId)
has2FA := twoFA != nil && twoFA.IsEnabled
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
return
}
// 根据验证方式进行验证
var verified bool
var verifyMethod string
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("验证码不能为空"))
return
}
verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA"
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
// 这里只是验证 Passkey 验证流程是否已经完成
// 实际上,前端应该先调用这两个接口,然后再调用本接口
verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
verifyMethod = "Passkey"
default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
return
}
// 验证成功,在 session 中记录时间戳
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
}
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": gin.H{
"verified": true,
"expires_at": now + SecureVerificationTimeout,
},
})
}
// GetVerificationStatus 获取验证状态
func GetVerificationStatus(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: true,
ExpiresAt: verifiedAt + SecureVerificationTimeout,
},
})
}
// CheckSecureVerification 检查是否已通过安全验证
// 返回 true 表示验证有效false 表示需要重新验证
func CheckSecureVerification(c *gin.Context) bool {
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
return false
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
return false
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
return false
}
return true
}
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
func PasskeyVerifyAndSetSession(c *gin.Context) {
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
_ = session.Save()
}
// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
// 整合了 begin 和 finish 流程
func PasskeyVerifyForSecure(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
credential, err := model.GetPasskeyByUserID(userId)
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)
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
_, 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
}
// 验证成功,设置 session
PasskeyVerifyAndSetSession(c)
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"verified": true,
"expires_at": time.Now().Unix() + SecureVerificationTimeout,
},
})
}

View File

@@ -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,91 @@ 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预扣费%stokens%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,分组倍率 %.2ftokens %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 {
// 需要退还多扣的费用
refundQuota := -quotaDelta
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s实际消耗%s预扣费%stokens%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,分组倍率 %.2ftokens %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 {
// quotaDelta == 0, 预扣费刚好准确
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%stokens%d",
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
}
}
}
}
}
}
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 推送
)

20
go.mod
View File

@@ -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

37
go.sum
View File

@@ -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=

View File

@@ -185,8 +185,9 @@ func InitResources() error {
// This is a placeholder function for future resource initialization
err := godotenv.Load(".env")
if err != nil {
common.SysLog("未找到 .env 文件,使用默认环境变量,如果需要,请创建 .env 文件并设置相关变量")
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
if common.DebugEnabled {
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
}
}
// 加载环境变量

View File

@@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
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

View File

@@ -0,0 +1,131 @@
package middleware
import (
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
// SecureVerificationRequired 安全验证中间件
// 检查用户是否在有效时间内通过了安全验证
// 如果未验证或验证已过期,返回 401 错误
func SecureVerificationRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查用户是否已登录
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查 session 中的验证时间戳
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要安全验证",
"code": "VERIFICATION_REQUIRED",
})
c.Abort()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
// session 数据格式错误
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证状态异常,请重新验证",
"code": "VERIFICATION_INVALID",
})
c.Abort()
return
}
// 检查验证是否过期
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证已过期,请重新验证",
"code": "VERIFICATION_EXPIRED",
})
c.Abort()
return
}
// 验证有效,继续处理请求
c.Next()
}
}
// OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景
func OptionalSecureVerification() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.Set("secure_verified", false)
c.Next()
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.Set("secure_verified", false)
c.Next()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.Set("secure_verified", false)
c.Next()
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.Set("secure_verified", false)
c.Next()
return
}
c.Set("secure_verified", true)
c.Set("secure_verified_at", verifiedAt)
c.Next()
}
}
// ClearSecureVerification 清除安全验证状态
// 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c)
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
}

View File

@@ -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"},

209
model/passkey.go Normal file
View File

@@ -0,0 +1,209 @@
package model
import (
"encoding/base64"
"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 string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
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,
}
credID, _ := base64.StdEncoding.DecodeString(p.CredentialID)
pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey)
aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID)
return webauthn.Credential{
ID: credID,
PublicKey: pubKey,
AttestationType: p.AttestationType,
Transport: p.TransportList(),
Flags: flags,
Authenticator: webauthn.Authenticator{
AAGUID: 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: base64.StdEncoding.EncodeToString(credential.ID),
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
AttestationType: credential.AttestationType,
AAGUID: base64.StdEncoding.EncodeToString(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 = base64.StdEncoding.EncodeToString(credential.ID)
p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey)
p.AttestationType = credential.AttestationType
p.AAGUID = base64.StdEncoding.EncodeToString(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) {
// 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志
return nil, ErrPasskeyNotFound
}
// 只有真正的数据库错误才记录日志
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
}
credIDStr := base64.StdEncoding.EncodeToString(credentialID)
var credential PasskeyCredential
if err := DB.Where("credential_id = ?", credIDStr).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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
request.EncodingFormat = ""
return request, nil
}

View File

@@ -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++
}
}
}
}
}

View File

@@ -0,0 +1,248 @@
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
// 解析 usage 信息用于按倍率计费
taskResult.CompletionTokens = resTask.Usage.CompletionTokens
taskResult.TotalTokens = resTask.Usage.TotalTokens
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
}

View File

@@ -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"

View File

@@ -91,7 +91,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,
@@ -99,42 +135,33 @@ 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 {
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
}
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -188,7 +215,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

View File

@@ -195,21 +195,29 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
}
switch info.RelayMode {
case constant.RelayModeChatCompletions:
switch info.RelayFormat {
case types.RelayFormatClaude:
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
default:
switch info.RelayMode {
case constant.RelayModeChatCompletions:
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
default:
}
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
}

View File

@@ -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)

View File

@@ -500,10 +500,52 @@ 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"` // 用于按倍率计费
}
// 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 {
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
return jsonData, nil
}
// 默认移除 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")
}
}
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
return jsonData, nil
}
return jsonDataAfter, nil
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -40,11 +40,17 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus)
userRoute := apiRouter.Group("/user")
{
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 +65,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 +99,7 @@ func SetApiRouter(router *gin.Engine) {
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey)
// Admin 2FA routes
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
@@ -115,7 +128,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels)
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", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

177
service/passkey/service.go Normal file
View File

@@ -0,0 +1,177 @@
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) {
originsStr := strings.TrimSpace(settings.Origins)
if originsStr != "" {
originList := strings.Split(originsStr, ",")
origins := make([]string, 0, len(originList))
for _, origin := range originList {
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"
}

View File

@@ -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
}

71
service/passkey/user.go Normal file
View File

@@ -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
}

View File

@@ -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}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}

View File

@@ -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
}

View File

@@ -29,6 +29,7 @@ const (
Gemini25FlashLitePreviewInputAudioPrice = 0.50
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
GeminiRoboticsER15InputAudioPrice = 1.00
)
const (
@@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
return Gemini25FlashProductionInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
return GeminiRoboticsER15InputAudioPrice
}
return 0
}

View File

@@ -179,6 +179,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"gemini-robotics-er-1.5-preview": 0.15,
"gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
@@ -252,17 +253,17 @@ 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,
// 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{
@@ -587,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 4, false
}
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
return 2.5 / 0.3, false
}
return 4, false
}

View File

@@ -0,0 +1,49 @@
package system_setting
import (
"net/url"
"one-api/common"
"one-api/setting/config"
"strings"
)
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: "",
AllowInsecureOrigin: false,
UserVerification: "preferred",
AttachmentPreference: "",
}
func init() {
config.GlobalConfig.Register("passkey", &defaultPasskeySettings)
}
func GetPasskeySettings() *PasskeySettings {
if defaultPasskeySettings.RPID == "" && ServerAddress != "" {
// 从ServerAddress提取域名作为RPID
// ServerAddress可能是 "https://newapi.pro" 这种格式
serverAddr := strings.TrimSpace(ServerAddress)
if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" {
defaultPasskeySettings.RPID = parsed.Host
} else {
defaultPasskeySettings.RPID = serverAddr
}
}
if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" {
defaultPasskeySettings.Origins = ServerAddress
}
return &defaultPasskeySettings
}

View File

@@ -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 = () => {
</div>
)}
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
@@ -437,6 +510,18 @@ const LoginForm = () => {
</Title>
</div>
<div className='px-2 py-8'>
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
type='tertiary'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Form className='space-y-3'>
<Form.Input
field='username'

View File

@@ -0,0 +1,117 @@
/*
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 <https://www.gnu.org/licenses/>.
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 (
<>
{/* 查看密钥按钮 */}
<Button
type='primary'
theme='outline'
onClick={handleViewKey}
>
{t('查看密钥')}
</Button>
{/* 安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 密钥显示模态框 */}
<Modal
title={t('渠道密钥信息')}
visible={showKeyModal}
onCancel={() => setShowKeyModal(false)}
footer={
<Button type='primary' onClick={() => setShowKeyModal(false)}>
{t('完成')}
</Button>
}
width={700}
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}
/>
</Modal>
</>
);
};
export default ChannelKeyViewExample;

View File

@@ -0,0 +1,285 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } 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 [isAnimating, setIsAnimating] = useState(false);
const [verifySuccess, setVerifySuccess] = useState(false);
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
const { method, loading, code } = verificationState;
useEffect(() => {
if (visible) {
setIsAnimating(true);
setVerifySuccess(false);
} else {
setIsAnimating(false);
}
}, [visible]);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
onVerify(method, code);
}
if (e.key === 'Escape' && !loading) {
onCancel();
}
};
// 如果用户没有启用任何验证方式
if (visible && !has2FA && !hasPasskey) {
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={onCancel}
footer={
<Button onClick={onCancel}>{t('确定')}</Button>
}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className='text-center py-6'>
<div className='mb-4'>
<svg
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
</div>
<Typography.Title heading={4} className='mb-2'>
{t('需要安全验证')}
</Typography.Title>
<Typography.Text type='tertiary'>
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
</Typography.Text>
<br />
<Typography.Text type='tertiary'>
{t('请前往个人设置 → 安全设置进行配置。')}
</Typography.Text>
</div>
</Modal>
);
}
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={loading ? undefined : onCancel}
closeOnEsc={!loading}
footer={null}
width={460}
centered
style={{
maxWidth: 'calc(100vw - 32px)'
}}
bodyStyle={{
padding: '20px 24px'
}}
>
<div style={{ width: '100%' }}>
{/* 描述信息 */}
{description && (
<Typography.Paragraph
type="tertiary"
style={{
margin: '0 0 20px 0',
fontSize: '14px',
lineHeight: '1.6'
}}
>
{description}
</Typography.Paragraph>
)}
{/* 验证方式选择 */}
<Tabs
activeKey={method}
onChange={onMethodSwitch}
type='line'
size='default'
style={{ margin: 0 }}
>
{has2FA && (
<TabPane
tab={t('两步验证')}
itemKey='2fa'
>
<div style={{ paddingTop: '20px' }}>
<div style={{ marginBottom: '12px' }}>
<Input
placeholder={t('请输入6位验证码或8位备用码')}
value={code}
onChange={onCodeChange}
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus={method === '2fa'}
disabled={loading}
prefix={
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
</svg>
}
style={{ width: '100%' }}
/>
</div>
<Typography.Text
type="tertiary"
size="small"
style={{
display: 'block',
marginBottom: '20px',
fontSize: '13px',
lineHeight: '1.5'
}}
>
{t('从认证器应用中获取验证码,或使用备用码')}
</Typography.Text>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={!code.trim() || loading}
onClick={() => onVerify(method, code)}
>
{t('验证')}
</Button>
</div>
</div>
</TabPane>
)}
{hasPasskey && passkeySupported && (
<TabPane
tab={t('Passkey')}
itemKey='passkey'
>
<div style={{ paddingTop: '20px' }}>
<div style={{
textAlign: 'center',
padding: '24px 16px',
marginBottom: '20px'
}}>
<div style={{
width: 56,
height: 56,
margin: '0 auto 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
background: 'var(--semi-color-primary-light-default)',
}}>
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
</svg>
</div>
<Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
{t('使用 Passkey 验证')}
</Typography.Title>
<Typography.Text
type='tertiary'
style={{
display: 'block',
margin: 0,
fontSize: '13px',
lineHeight: '1.5'
}}
>
{t('点击验证按钮,使用您的生物特征或安全密钥')}
</Typography.Text>
</div>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={loading}
onClick={() => onVerify(method)}
>
{t('验证 Passkey')}
</Button>
</div>
</div>
</TabPane>
)}
</Tabs>
</div>
</Modal>
);
};
export default SecureVerificationModal;

View File

@@ -26,6 +26,9 @@ import {
showInfo,
showSuccess,
setStatusData,
prepareCredentialCreationOptions,
buildRegistrationResult,
isPasskeySupported,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User';
@@ -67,6 +70,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,
@@ -74,6 +81,9 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
gotifyUrl: '',
gotifyToken: '',
gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
@@ -113,6 +123,10 @@ const PersonalSetting = () => {
})();
getUserData();
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
useEffect(() => {
@@ -138,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,
@@ -161,12 +181,90 @@ 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 });
setUserData(data);
await loadPasskeyStatus();
} else {
showError(message);
}
@@ -317,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,
@@ -354,6 +458,12 @@ const PersonalSetting = () => {
handleSystemTokenClick={handleSystemTokenClick}
setShowChangePasswordModal={setShowChangePasswordModal}
setShowAccountDeleteModal={setShowAccountDeleteModal}
passkeyStatus={passkeyStatus}
passkeySupported={passkeySupported}
passkeyRegisterLoading={passkeyRegisterLoading}
passkeyDeleteLoading={passkeyDeleteLoading}
onPasskeyRegister={handleRegisterPasskey}
onPasskeyDelete={handleRemovePasskey}
/>
{/* 右侧:其他设置 */}

View File

@@ -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: '',
@@ -173,9 +181,25 @@ 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':
// origins是逗号分隔的字符串直接使用
item.value = item.value || '';
break;
case 'passkey.rp_display_name':
case 'passkey.rp_id':
case 'passkey.attachment_preference':
// 确保字符串字段不为null/undefined
item.value = item.value || '';
break;
case 'passkey.user_verification':
// 确保有默认值
item.value = item.value || 'preferred';
break;
case 'Price':
case 'MinTopUp':
item.value = parseFloat(item.value);
@@ -582,6 +606,36 @@ const SystemSetting = () => {
}
};
const submitPasskeySettings = async () => {
// 使用formApi直接获取当前表单值
const formValues = formApiRef.current?.getValues() || {};
const options = [];
options.push({
key: 'passkey.rp_display_name',
value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '',
});
options.push({
key: 'passkey.rp_id',
value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '',
});
options.push({
key: 'passkey.user_verification',
value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred',
});
options.push({
key: 'passkey.attachment_preference',
value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '',
});
options.push({
key: 'passkey.origins',
value: formValues['passkey.origins'] || inputs['passkey.origins'] || '',
});
await updateOptions(options);
};
const handleCheckboxChange = async (optionKey, event) => {
const value = event.target.checked;
@@ -957,6 +1011,116 @@ const SystemSetting = () => {
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置 Passkey')}>
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
<Banner
type='info'
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field="['passkey.enabled']"
noLabel
onChange={(e) =>
handleCheckboxChange('passkey.enabled', e)
}
>
{t('允许通过 Passkey 登录 & 认证')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['passkey.rp_display_name']"
label={t('服务显示名称')}
placeholder={t('默认使用系统名称')}
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['passkey.rp_id']"
label={t('网站域名标识')}
placeholder={t('例如example.com')}
extraText={t('留空则默认使用服务器地址注意不能携带http://或者https://')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field="['passkey.user_verification']"
label={t('安全验证级别')}
placeholder={t('是否要求指纹/面容等生物识别')}
optionList={[
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
{ label: t('强制要求'), value: 'required' },
{ label: t('不建议使用'), value: 'discouraged' },
]}
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field="['passkey.attachment_preference']"
label={t('设备类型偏好')}
placeholder={t('选择支持的认证设备类型')}
optionList={[
{ label: t('不限制'), value: '' },
{ label: t('本设备内置'), value: 'platform' },
{ label: t('外接设备'), value: 'cross-platform' },
]}
extraText={t('本设备:手机指纹/面容外接USB安全密钥')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field="['passkey.allow_insecure_origin']"
noLabel
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
onChange={(e) =>
handleCheckboxChange('passkey.allow_insecure_origin', e)
}
>
{t('允许不安全的 OriginHTTP')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Input
field="['passkey.origins']"
label={t('允许的 Origins')}
placeholder={t('填写带https的域名逗号分隔')}
extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[]需使用https')}
/>
</Col>
</Row>
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
{t('保存 Passkey 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置邮箱域名白名单')}>
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

View File

@@ -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 (
<Card className='!rounded-2xl'>
@@ -476,6 +486,71 @@ const AccountManagement = ({
</div>
</Card>
{/* Passkey 设置 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconKey size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className='mb-1'>
{t('Passkey 登录')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{passkeyEnabled
? t('已启用 Passkey无需密码即可登录')
: t('使用 Passkey 实现免密且更安全的登录体验')}
</Typography.Text>
<div className='mt-2 text-xs text-gray-500 space-y-1'>
<div>
{t('最后使用时间')}{lastUsedLabel}
</div>
{/*{passkeyEnabled && (*/}
{/* <div>*/}
{/* {t('备份支持')}*/}
{/* {passkeyStatus?.backup_eligible*/}
{/* ? t('支持备份')*/}
{/* : t('不支持')}*/}
{/* {t('备份状态')}*/}
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
{/* </div>*/}
{/*)}*/}
{!passkeySupported && (
<div className='text-amber-600'>
{t('当前设备不支持 Passkey')}
</div>
)}
</div>
</div>
</div>
<Button
type={passkeyEnabled ? 'danger' : 'primary'}
theme={passkeyEnabled ? 'solid' : 'solid'}
onClick={
passkeyEnabled
? () => {
Modal.confirm({
title: t('确认解绑 Passkey'),
content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
okText: t('确认解绑'),
cancelText: t('取消'),
okType: 'danger',
onOk: onPasskeyDelete,
});
}
: onPasskeyRegister
}
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
icon={<IconKey />}
disabled={!passkeySupported && !passkeyEnabled}
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
>
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
</Button>
</div>
</Card>
{/* 两步验证设置 */}
<TwoFASetting t={t} />

View File

@@ -400,6 +400,7 @@ const NotificationSettings = ({
<Radio value='email'>{t('邮件通知')}</Radio>
<Radio value='webhook'>{t('Webhook通知')}</Radio>
<Radio value='bark'>{t('Bark通知')}</Radio>
<Radio value='gotify'>{t('Gotify通知')}</Radio>
</Form.RadioGroup>
<Form.AutoComplete
@@ -589,7 +590,108 @@ const NotificationSettings = ({
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Bark 官方文档
Bark {t('官方文档')}
</a>
</div>
</div>
</div>
</>
)}
{/* Gotify推送设置 */}
{notificationSettings.warningType === 'gotify' && (
<>
<Form.Input
field='gotifyUrl'
label={t('Gotify服务器地址')}
placeholder={t(
'请输入Gotify服务器地址例如: https://gotify.example.com',
)}
onChange={(val) => handleFormChange('gotifyUrl', val)}
prefix={<IconLink />}
extraText={t(
'支持HTTP和HTTPS填写Gotify服务器的完整URL地址',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify服务器地址'),
},
{
pattern: /^https?:\/\/.+/,
message: t('Gotify服务器地址必须以http://或https://开头'),
},
]}
/>
<Form.Input
field='gotifyToken'
label={t('Gotify应用令牌')}
placeholder={t('请输入Gotify应用令牌')}
onChange={(val) => handleFormChange('gotifyToken', val)}
prefix={<IconKey />}
extraText={t(
'在Gotify服务器创建应用后获得的令牌用于发送通知',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify应用令牌'),
},
]}
/>
<Form.AutoComplete
field='gotifyPriority'
label={t('消息优先级')}
placeholder={t('请选择消息优先级')}
data={[
{ value: 0, label: t('0 - 最低') },
{ value: 2, label: t('2 - 低') },
{ value: 5, label: t('5 - 正常(默认)') },
{ value: 8, label: t('8 - 高') },
{ value: 10, label: t('10 - 最高') },
]}
onChange={(val) =>
handleFormChange('gotifyPriority', val)
}
prefix={<IconBell />}
extraText={t('消息优先级范围0-10默认为5')}
style={{ width: '100%', maxWidth: '300px' }}
/>
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
<div className='text-sm text-gray-700 mb-3'>
<strong>{t('配置说明')}</strong>
</div>
<div className='text-xs text-gray-500 space-y-2'>
<div>
1. {t('在Gotify服务器的应用管理中创建新应用')}
</div>
<div>
2.{' '}
{t(
'复制应用的令牌Token并填写到上方的应用令牌字段',
)}
</div>
<div>
3. {t('填写Gotify服务器的完整URL地址')}
</div>
<div className='mt-3 pt-3 border-t border-gray-200'>
<span className='text-gray-400'>
{t('更多信息请参考')}
</span>{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Gotify {t('官方文档')}
</a>
</div>
</div>

View File

@@ -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,
@@ -66,6 +68,8 @@ import {
IconCode,
IconGlobe,
IconBolt,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
@@ -167,6 +171,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);
@@ -194,12 +202,9 @@ 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: '',
});
@@ -208,18 +213,57 @@ 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 }));
};
// 使用通用安全验证 Hook
const {
isModalVisible,
verificationMethods,
verificationState,
withVerification,
executeVerification,
cancelVerification,
setVerificationCode,
switchVerificationMethod,
} = useSecureVerification({
onSuccess: (result) => {
// 验证成功后显示密钥
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,
});
}
},
});
// 重置2FA状态
const resetTwoFAState = () => {
setTwoFAState({
// 重置密钥显示状态
const resetKeyDisplayState = () => {
setKeyDisplayState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: '',
});
};
@@ -231,6 +275,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,
@@ -443,17 +518,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 (
@@ -603,42 +688,33 @@ 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,
});
if (res.data.success) {
// 验证成功,显示密钥
updateTwoFAState({
// 使用 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,
showKey: true,
keyData: res.data.data.key,
keyData: result.data.key,
});
reset2FAVerifyState();
showSuccess(t('验证成功'));
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('获取密钥失败'));
} finally {
setVerifyLoading(false);
console.error('Failed to view channel key:', error);
showError(error.message || t('获取密钥失败'));
}
};
// 显示2FA验证模态框 - 使用TwoFactorAuthModal
const handleShow2FAModal = () => {
setShow2FAVerifyModal(true);
};
useEffect(() => {
const modelMap = new Map();
@@ -714,6 +790,8 @@ const EditChannelModal = (props) => {
fetchModelGroups();
// 重置手动输入模式状态
setUseManualInput(false);
// 重置导航状态
setCurrentSectionIndex(0);
} else {
// 统一的模态框关闭重置逻辑
resetModalState();
@@ -742,10 +820,8 @@ const EditChannelModal = (props) => {
}
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
setInputs(getInitValues());
// 重置2FA状态
resetTwoFAState();
// 重置2FA验证状态
reset2FAVerifyState();
// 重置密钥显示状态
resetKeyDisplayState();
};
const handleVertexUploadChange = ({ fileList }) => {
@@ -901,21 +977,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;
@@ -926,6 +1014,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;
@@ -1241,7 +1333,41 @@ const EditChannelModal = (props) => {
visible={props.visible}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<div className='flex justify-between items-center bg-white'>
<div className='flex gap-2'>
<Button
size='small'
type='tertiary'
icon={<IconChevronUp />}
onClick={() => navigateToSection('up')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('上一个表单块')}
/>
<Button
size='small'
type='tertiary'
icon={<IconChevronDown />}
onClick={() => navigateToSection('down')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('下一个表单块')}
/>
</div>
<Space>
<Button
theme='solid'
@@ -1272,10 +1398,14 @@ const EditChannelModal = (props) => {
>
{() => (
<Spin spinning={loading}>
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<div
className='p-2'
ref={formContainerRef}
>
<div ref={el => formSectionRefs.current.basicInfo = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
@@ -1749,13 +1879,15 @@ const EditChannelModal = (props) => {
}
/>
)}
</Card>
</Card>
</div>
{/* API Configuration Card */}
{showApiConfigCard && (
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.apiConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
@@ -1966,13 +2098,15 @@ const EditChannelModal = (props) => {
/>
</div>
)}
</Card>
</Card>
</div>
)}
{/* Model Configuration Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.modelConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
@@ -2167,12 +2301,14 @@ const EditChannelModal = (props) => {
formApi={formApiRef.current}
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
/>
</Card>
</Card>
</div>
{/* Advanced Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.advancedSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
@@ -2385,12 +2521,84 @@ const EditChannelModal = (props) => {
'键为原状态码,值为要复写的状态码,仅影响本地判断',
)}
/>
</Card>
{/* 字段透传控制 - OpenAI 渠道 */}
{inputs.type === 1 && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
<Form.Switch
field='disable_store'
label={t('禁用 store 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('disable_store', value)
}
extraText={t(
'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
)}
/>
<Form.Switch
field='allow_safety_identifier'
label={t('允许 safety_identifier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_safety_identifier', value)
}
extraText={t(
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
)}
/>
</>
)}
{/* 字段透传控制 - Claude 渠道 */}
{(inputs.type === 14) && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
</>
)}
</Card>
</div>
{/* Channel Extra Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.channelExtraSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='violet'
@@ -2488,7 +2696,8 @@ const EditChannelModal = (props) => {
'如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面',
)}
/>
</Card>
</Card>
</div>
</div>
</Spin>
)}
@@ -2499,17 +2708,17 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</SideSheet>
{/* 使用TwoFactorAuthModal组件进行2FA验证 */}
<TwoFactorAuthModal
visible={show2FAVerifyModal}
code={verifyCode}
loading={verifyLoading}
onCodeChange={setVerifyCode}
onVerify={handleVerify2FA}
onCancel={reset2FAVerifyState}
title={t('查看渠道密钥')}
description={t('为了保护账户安全,请验证您的两步验证码。')}
placeholder={t('请输入验证码或备用码')}
{/* 使用通用安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 使用ChannelKeyDisplay组件显示密钥 */}
@@ -2532,10 +2741,10 @@ const EditChannelModal = (props) => {
{t('渠道密钥信息')}
</div>
}
visible={twoFAState.showModal && twoFAState.showKey}
onCancel={resetTwoFAState}
visible={keyDisplayState.showModal}
onCancel={resetKeyDisplayState}
footer={
<Button type='primary' onClick={resetTwoFAState}>
<Button type='primary' onClick={resetKeyDisplayState}>
{t('完成')}
</Button>
}
@@ -2543,7 +2752,7 @@ const EditChannelModal = (props) => {
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={twoFAState.keyData}
keyData={keyDisplayState.keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}

View File

@@ -25,6 +25,7 @@ import {
Table,
Tag,
Typography,
Select,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
@@ -45,6 +46,8 @@ const ModelTestModal = ({
testChannel,
modelTablePage,
setModelTablePage,
selectedEndpointType,
setSelectedEndpointType,
allSelectingRef,
isMobile,
t,
@@ -59,6 +62,17 @@ const ModelTestModal = ({
)
: [];
const endpointTypeOptions = [
{ value: '', label: t('自动检测') },
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
{ value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
{ value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
{ value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
];
const handleCopySelected = () => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
@@ -152,7 +166,7 @@ const ModelTestModal = ({
return (
<Button
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
loading={isTesting}
size='small'
>
@@ -228,6 +242,18 @@ const ModelTestModal = ({
>
{hasChannel && (
<div className='model-test-scroll'>
{/* 端点类型选择器 */}
<div className='flex items-center gap-2 w-full mb-2'>
<Typography.Text strong>{t('端点类型')}:</Typography.Text>
<Select
value={selectedEndpointType}
onChange={setSelectedEndpointType}
optionList={endpointTypeOptions}
className='!w-full'
placeholder={t('选择端点类型')}
/>
</div>
{/* 搜索与操作按钮 */}
<div className='flex items-center justify-end gap-2 w-full mb-2'>
<Input

View File

@@ -26,7 +26,9 @@ import {
Progress,
Popover,
Typography,
Dropdown,
} from '@douyinfe/semi-ui';
import { IconMore } from '@douyinfe/semi-icons';
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
/**
@@ -204,6 +206,8 @@ const renderOperations = (
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
t,
},
) => {
@@ -211,6 +215,28 @@ const renderOperations = (
return <></>;
}
const moreMenu = [
{
node: 'item',
name: t('重置 Passkey'),
onClick: () => showResetPasskeyModal(record),
},
{
node: 'item',
name: t('重置 2FA'),
onClick: () => showResetTwoFAModal(record),
},
{
node: 'divider',
},
{
node: 'item',
name: t('注销'),
type: 'danger',
onClick: () => showDeleteModal(record),
},
];
return (
<Space>
{record.status === 1 ? (
@@ -253,13 +279,17 @@ const renderOperations = (
>
{t('降级')}
</Button>
<Button
type='danger'
size='small'
onClick={() => showDeleteModal(record)}
<Dropdown
menu={moreMenu}
trigger='click'
position='bottomRight'
>
{t('注销')}
</Button>
<Button
type='tertiary'
size='small'
icon={<IconMore />}
/>
</Dropdown>
</Space>
);
};
@@ -275,6 +305,8 @@ export const getUsersColumns = ({
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
}) => {
return [
{
@@ -329,6 +361,8 @@ export const getUsersColumns = ({
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
t,
}),
},

View File

@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
import DemoteUserModal from './modals/DemoteUserModal';
import EnableDisableUserModal from './modals/EnableDisableUserModal';
import DeleteUserModal from './modals/DeleteUserModal';
import ResetPasskeyModal from './modals/ResetPasskeyModal';
import ResetTwoFAModal from './modals/ResetTwoFAModal';
const UsersTable = (usersData) => {
const {
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
setShowEditUser,
manageUser,
refresh,
resetUserPasskey,
resetUserTwoFA,
t,
} = usersData;
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [modalUser, setModalUser] = useState(null);
const [enableDisableAction, setEnableDisableAction] = useState('');
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
// Modal handlers
const showPromoteUserModal = (user) => {
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
setShowDeleteModal(true);
};
const showResetPasskeyUserModal = (user) => {
setModalUser(user);
setShowResetPasskeyModal(true);
};
const showResetTwoFAUserModal = (user) => {
setModalUser(user);
setShowResetTwoFAModal(true);
};
// Modal confirm handlers
const handlePromoteConfirm = () => {
manageUser(modalUser.id, 'promote', modalUser);
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
setShowEnableDisableModal(false);
};
const handleResetPasskeyConfirm = async () => {
await resetUserPasskey(modalUser);
setShowResetPasskeyModal(false);
};
const handleResetTwoFAConfirm = async () => {
await resetUserTwoFA(modalUser);
setShowResetTwoFAModal(false);
};
// Get all columns
const columns = useMemo(() => {
return getUsersColumns({
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
showDemoteModal: showDemoteUserModal,
showEnableDisableModal: showEnableDisableUserModal,
showDeleteModal: showDeleteUserModal,
showResetPasskeyModal: showResetPasskeyUserModal,
showResetTwoFAModal: showResetTwoFAUserModal,
});
}, [t, setEditingUser, setShowEditUser]);
}, [
t,
setEditingUser,
setShowEditUser,
showPromoteUserModal,
showDemoteUserModal,
showEnableDisableUserModal,
showDeleteUserModal,
showResetPasskeyUserModal,
showResetTwoFAUserModal,
]);
// Handle compact mode by removing fixed positioning
const tableColumns = useMemo(() => {
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
manageUser={manageUser}
t={t}
/>
<ResetPasskeyModal
visible={showResetPasskeyModal}
onCancel={() => setShowResetPasskeyModal(false)}
onConfirm={handleResetPasskeyConfirm}
user={modalUser}
t={t}
/>
<ResetTwoFAModal
visible={showResetTwoFAModal}
onCancel={() => setShowResetTwoFAModal(false)}
onConfirm={handleResetTwoFAConfirm}
user={modalUser}
t={t}
/>
</>
);
};

View File

@@ -0,0 +1,39 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
return (
<Modal
title={t('确认重置 Passkey')}
visible={visible}
onCancel={onCancel}
onOk={onConfirm}
type='warning'
>
{t('此操作将解绑用户当前的 Passkey下次登录需要重新注册。')}{' '}
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
</Modal>
);
};
export default ResetPasskeyModal;

View File

@@ -0,0 +1,39 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
return (
<Modal
title={t('确认重置两步验证')}
visible={visible}
onCancel={onCancel}
onOk={onConfirm}
type='warning'
>
{t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
</Modal>
);
};
export default ResetTwoFAModal;

View File

@@ -164,6 +164,11 @@ export const CHANNEL_OPTIONS = [
color: 'blue',
label: 'SubModel',
},
{
value: 54,
color: 'blue',
label: '豆包视频',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;

View File

@@ -27,3 +27,4 @@ export * from './data';
export * from './token';
export * from './boolean';
export * from './dashboard';
export * from './passkey';

137
web/src/helpers/passkey.js Normal file
View File

@@ -0,0 +1,137 @@
export function base64UrlToBuffer(base64url) {
if (!base64url) return new ArrayBuffer(0);
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const buffer = new ArrayBuffer(rawData.length);
const uintArray = new Uint8Array(buffer);
for (let i = 0; i < rawData.length; i += 1) {
uintArray[i] = rawData.charCodeAt(i);
}
return buffer;
}
export function bufferToBase64Url(buffer) {
if (!buffer) return '';
const uintArray = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < uintArray.byteLength; i += 1) {
binary += String.fromCharCode(uintArray[i]);
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function prepareCredentialCreationOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
}
const publicKey = {
...options,
challenge: base64UrlToBuffer(options.challenge),
user: {
...options.user,
id: base64UrlToBuffer(options.user?.id),
},
};
if (Array.isArray(options.excludeCredentials)) {
publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
}));
}
if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
delete publicKey.attestationFormats;
}
return publicKey;
}
export function prepareCredentialRequestOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
}
const publicKey = {
...options,
challenge: base64UrlToBuffer(options.challenge),
};
if (Array.isArray(options.allowCredentials)) {
publicKey.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
}));
}
return publicKey;
}
export function buildRegistrationResult(credential) {
if (!credential) return null;
const { response } = credential;
const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
response: {
attestationObject: bufferToBase64Url(response.attestationObject),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
transports,
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
};
}
export function buildAssertionResult(assertion) {
if (!assertion) return null;
const { response } = assertion;
return {
id: assertion.id,
rawId: bufferToBase64Url(assertion.rawId),
type: assertion.type,
authenticatorAttachment: assertion.authenticatorAttachment,
response: {
authenticatorData: bufferToBase64Url(response.authenticatorData),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
},
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
};
}
export async function isPasskeySupported() {
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
return false;
}
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
try {
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
if (available) return true;
} catch (error) {
// ignore
}
}
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (error) {
return false;
}
}
return true;
}

View File

@@ -337,6 +337,8 @@ export function getChannelIcon(channelType) {
return <Kling.Color size={iconSize} />;
case 51: // 即梦 Jimeng
return <Jimeng.Color size={iconSize} />;
case 54: // 豆包视频 Doubao Video
return <Doubao.Color size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库FastGPT
return <FastGPT.Color size={iconSize} />;

View File

@@ -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 <https://www.gnu.org/licenses/>.
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
};
}

View File

@@ -80,6 +80,7 @@ export const useChannelsData = () => {
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
const [selectedEndpointType, setSelectedEndpointType] = useState('');
// 使用 ref 来避免闭包问题,类似旧版实现
const shouldStopBatchTestingRef = useRef(false);
@@ -691,7 +692,7 @@ export const useChannelsData = () => {
};
// Test channel - 单个模型测试,参考旧版实现
const testChannel = async (record, model) => {
const testChannel = async (record, model, endpointType = '') => {
const testKey = `${record.id}-${model}`;
// 检查是否应该停止批量测试
@@ -703,7 +704,11 @@ export const useChannelsData = () => {
setTestingModels(prev => new Set([...prev, model]));
try {
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
let url = `/api/channel/test/${record.id}?model=${model}`;
if (endpointType) {
url += `&endpoint_type=${endpointType}`;
}
const res = await API.get(url);
// 检查是否在请求期间被停止
if (shouldStopBatchTestingRef.current && isBatchTesting) {
@@ -820,7 +825,7 @@ export const useChannelsData = () => {
.replace('${total}', models.length)
);
const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
const batchPromises = batch.map(model => testChannel(currentTestChannel, model, selectedEndpointType));
const batchResults = await Promise.allSettled(batchPromises);
results.push(...batchResults);
@@ -902,6 +907,7 @@ export const useChannelsData = () => {
setTestingModels(new Set());
setSelectedModelKeys([]);
setModelTablePage(1);
setSelectedEndpointType('');
// 可选择性保留测试结果,这里不清空以便用户查看
};
@@ -989,6 +995,8 @@ export const useChannelsData = () => {
isBatchTesting,
modelTablePage,
setModelTablePage,
selectedEndpointType,
setSelectedEndpointType,
allSelectingRef,
// Multi-key management states

View File

@@ -0,0 +1,246 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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
* @param {Object} options - 配置选项
* @param {Function} options.onSuccess - 验证成功回调
* @param {Function} options.onError - 验证失败回调
* @param {string} options.successMessage - 成功提示消息
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
*/
export const useSecureVerification = ({
onSuccess,
onError,
successMessage,
autoReset = true
} = {}) => {
const { t } = useTranslation();
// 验证方式可用性状态
const [verificationMethods, setVerificationMethods] = useState({
has2FA: false,
hasPasskey: false,
passkeySupported: false
});
// 模态框状态
const [isModalVisible, setIsModalVisible] = useState(false);
// 当前验证状态
const [verificationState, setVerificationState] = useState({
method: null, // '2fa' | 'passkey'
loading: false,
code: '',
apiCall: null
});
// 检查可用的验证方式
const checkVerificationMethods = useCallback(async () => {
const methods = await SecureVerificationService.checkAvailableVerificationMethods();
setVerificationMethods(methods);
return methods;
}, []);
// 初始化时检查验证方式
useEffect(() => {
checkVerificationMethods();
}, [checkVerificationMethods]);
// 重置状态
const resetState = useCallback(() => {
setVerificationState({
method: null,
loading: false,
code: '',
apiCall: null
});
setIsModalVisible(false);
}, []);
// 开始验证流程
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);
onError?.(new Error(errorMessage));
return false;
}
// 设置默认验证方式
let defaultMethod = preferredMethod;
if (!defaultMethod) {
if (methods.hasPasskey && methods.passkeySupported) {
defaultMethod = 'passkey';
} else if (methods.has2FA) {
defaultMethod = '2fa';
}
}
setVerificationState(prev => ({
...prev,
method: defaultMethod,
apiCall,
title,
description
}));
setIsModalVisible(true);
return true;
}, [checkVerificationMethods, onError, t]);
// 执行验证
const executeVerification = useCallback(async (method, code = '') => {
if (!verificationState.apiCall) {
showError(t('验证配置错误'));
return;
}
setVerificationState(prev => ({ ...prev, loading: true }));
try {
// 先调用验证 API成功后后端会设置 session
await SecureVerificationService.verify(method, code);
// 验证成功,调用业务 API此时中间件会通过
const result = await verificationState.apiCall();
// 显示成功消息
if (successMessage) {
showSuccess(successMessage);
}
// 调用成功回调
onSuccess?.(result, method);
// 自动重置状态
if (autoReset) {
resetState();
}
return result;
} catch (error) {
showError(error.message || t('验证失败,请重试'));
onError?.(error);
throw error;
} finally {
setVerificationState(prev => ({ ...prev, loading: false }));
}
}, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
// 设置验证码
const setVerificationCode = useCallback((code) => {
setVerificationState(prev => ({ ...prev, code }));
}, []);
// 切换验证方式
const switchVerificationMethod = useCallback((method) => {
setVerificationState(prev => ({ ...prev, method, code: '' }));
}, []);
// 取消验证
const cancelVerification = useCallback(() => {
resetState();
}, [resetState]);
// 检查是否可以使用某种验证方式
const canUseMethod = useCallback((method) => {
switch (method) {
case '2fa':
return verificationMethods.has2FA;
case 'passkey':
return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
default:
return false;
}
}, [verificationMethods]);
// 获取推荐的验证方式
const getRecommendedMethod = useCallback(() => {
if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
return 'passkey';
}
if (verificationMethods.has2FA) {
return '2fa';
}
return null;
}, [verificationMethods]);
/**
* 包装 API 调用,自动处理验证错误
* 当 API 返回需要验证的错误时,自动弹出验证模态框
* @param {Function} apiCall - API 调用函数
* @param {Object} options - 验证选项(同 startVerification
* @returns {Promise<any>}
*/
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,
cancelVerification,
resetState,
setVerificationCode,
switchVerificationMethod,
checkVerificationMethods,
// 辅助方法
canUseMethod,
getRecommendedMethod,
withVerification, // 新增:自动处理验证的包装函数
// 便捷属性
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
isLoading: verificationState.loading,
currentMethod: verificationState.method,
code: verificationState.code
};
};

View File

@@ -86,7 +86,7 @@ export const useUsersData = () => {
};
// Search users with keyword and group
const searchUsers = async (
const searchUsers = async (
startIdx,
pageSize,
searchKeyword = null,
@@ -154,6 +154,40 @@ export const useUsersData = () => {
setLoading(false);
};
const resetUserPasskey = async (user) => {
if (!user) {
return;
}
try {
const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
const { success, message } = res.data;
if (success) {
showSuccess(t('Passkey 已重置'));
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
}
};
const resetUserTwoFA = async (user) => {
if (!user) {
return;
}
try {
const res = await API.delete(`/api/user/${user.id}/2fa`);
const { success, message } = res.data;
if (success) {
showSuccess(t('二步验证已重置'));
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
}
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
@@ -271,6 +305,8 @@ export const useUsersData = () => {
loadUsers,
searchUsers,
manageUser,
resetUserPasskey,
resetUserTwoFA,
handlePageChange,
handlePageSizeChange,
handleRow,

View File

@@ -6,6 +6,7 @@
"登 录": "Log In",
"注 册": "Sign Up",
"使用 邮箱或用户名 登录": "Sign in with Email or Username",
"使用 Passkey 认证": "Authenticate with Passkey",
"使用 GitHub 继续": "Continue with GitHub",
"使用 OIDC 继续": "Continue with OIDC",
"使用 微信 继续": "Continue with WeChat",
@@ -332,6 +333,10 @@
"通过密码注册时需要进行邮箱验证": "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",
"确认解绑 Passkey": "Confirm Unbind Passkey",
"解绑后将无法使用 Passkey 登录,确定要继续吗?": "After unbinding, you will not be able to login with Passkey. Are you sure you want to continue?",
"确认解绑": "Confirm Unbind",
"允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "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",
@@ -1308,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",
@@ -1318,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",
@@ -2132,6 +2169,67 @@
"域名黑名单": "Domain Blacklist",
"白名单": "Whitelist",
"黑名单": "Blacklist",
"Passkey 登录": "Passkey Sign-in",
"已启用 Passkey无需密码即可登录": "Passkey enabled. Passwordless login available.",
"使用 Passkey 实现免密且更安全的登录体验": "Use Passkey for a passwordless and more secure login experience.",
"最后使用时间": "Last used time",
"备份支持": "Backup support",
"支持备份": "Supported",
"不支持": "Not supported",
"备份状态": "Backup state",
"已备份": "Backed up",
"未备份": "Not backed up",
"当前设备不支持 Passkey": "Passkey is not supported on this device",
"注册 Passkey": "Register Passkey",
"解绑 Passkey": "Remove Passkey",
"Passkey 注册成功": "Passkey registration successful",
"Passkey 注册失败,请重试": "Passkey registration failed. Please try again.",
"已取消 Passkey 注册": "Passkey registration cancelled",
"Passkey 已解绑": "Passkey removed",
"操作失败,请重试": "Operation failed, please retry",
"重置 Passkey": "Reset Passkey",
"重置 2FA": "Reset 2FA",
"确认重置 Passkey": "Confirm Passkey Reset",
"确认重置两步验证": "Confirm Two-Factor Reset",
"此操作将解绑用户当前的 Passkey下次登录需要重新注册。": "This will detach the user's current Passkey. They will need to register again on next login.",
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "This will disable the user's current two-factor setup. No verification code will be required until they enable it again.",
"目标用户:{{username}}": "Target user: {{username}}",
"Passkey 已重置": "Passkey has been reset",
"二步验证已重置": "Two-factor authentication has been reset",
"配置 Passkey": "Configure Passkey",
"用以支持基于 WebAuthn 的无密码登录注册": "Support WebAuthn-based passwordless login and registration",
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey is a passwordless authentication method based on WebAuthn standard, supporting fingerprint, face recognition, hardware keys and other authentication methods",
"服务显示名称": "Service Display Name",
"默认使用系统名称": "Default uses system name",
"用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
"网站域名标识": "Website Domain ID",
"例如example.com": "e.g.: example.com",
"留空自动使用当前域名": "Leave blank to auto-use current domain",
"安全验证级别": "Security Verification Level",
"是否要求指纹/面容等生物识别": "Whether to require fingerprint/face recognition",
"preferred": "preferred",
"required": "required",
"discouraged": "discouraged",
"推荐:用户可以选择是否使用指纹等验证": "Recommended: Users can choose whether to use fingerprint verification",
"设备类型偏好": "Device Type Preference",
"选择支持的认证设备类型": "Choose supported authentication device types",
"platform": "platform",
"cross-platform": "cross-platform",
"本设备:手机指纹/面容外接USB安全密钥": "Built-in: phone fingerprint/face, External: USB security key",
"允许不安全的 OriginHTTP": "Allow insecure Origin (HTTP)",
"仅用于开发环境,生产环境应使用 HTTPS": "For development only, use HTTPS in production",
"允许的 Origins": "Allowed Origins",
"留空将自动使用服务器地址,多个 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",
"允许 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"
}

View File

@@ -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",
@@ -2135,6 +2167,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"
}

View File

@@ -5,6 +5,7 @@
"关于": "关于",
"登录": "登录",
"注册": "注册",
"使用 Passkey 认证": "使用 Passkey 认证",
"退出": "退出",
"语言": "语言",
"展开侧边栏": "展开侧边栏",
@@ -36,5 +37,62 @@
"common": {
"changeLanguage": "切换语言"
},
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
"Passkey 认证": "Passkey 认证",
"已启用 Passkey可进行无密码认证": "已启用 Passkey可进行无密码认证",
"使用 Passkey 实现免密且更安全的认证体验": "使用 Passkey 实现免密且更安全的认证体验",
"最后使用时间": "最后使用时间",
"备份支持": "备份支持",
"支持备份": "支持备份",
"不支持": "不支持",
"备份状态": "备份状态",
"已备份": "已备份",
"未备份": "未备份",
"当前设备不支持 Passkey": "当前设备不支持 Passkey",
"注册 Passkey": "注册 Passkey",
"解绑 Passkey": "解绑 Passkey",
"配置 Passkey": "配置 Passkey",
"用以支持基于 WebAuthn 的无密码登录注册": "用以支持基于 WebAuthn 的无密码登录注册",
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式",
"服务显示名称": "服务显示名称",
"默认使用系统名称": "默认使用系统名称",
"用户注册时看到的网站名称,比如'我的网站'": "用户注册时看到的网站名称,比如'我的网站'",
"网站域名标识": "网站域名标识",
"例如example.com": "例如example.com",
"留空自动使用当前域名": "留空自动使用当前域名",
"安全验证级别": "安全验证级别",
"是否要求指纹/面容等生物识别": "是否要求指纹/面容等生物识别",
"preferred": "preferred",
"required": "required",
"discouraged": "discouraged",
"推荐:用户可以选择是否使用指纹等验证": "推荐:用户可以选择是否使用指纹等验证",
"设备类型偏好": "设备类型偏好",
"选择支持的认证设备类型": "选择支持的认证设备类型",
"platform": "platform",
"cross-platform": "cross-platform",
"本设备:手机指纹/面容外接USB安全密钥": "本设备:手机指纹/面容外接USB安全密钥",
"允许不安全的 OriginHTTP": "允许不安全的 OriginHTTP",
"仅用于开发环境,生产环境应使用 HTTPS": "仅用于开发环境,生产环境应使用 HTTPS",
"允许的 Origins": "允许的 Origins",
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署",
"输入 Origin 后回车https://example.com": "输入 Origin 后回车https://example.com",
"保存 Passkey 设置": "保存 Passkey 设置",
"Passkey 注册成功": "Passkey 注册成功",
"Passkey 注册失败,请重试": "Passkey 注册失败,请重试",
"已取消 Passkey 注册": "已取消 Passkey 注册",
"Passkey 已解绑": "Passkey 已解绑",
"操作失败,请重试": "操作失败,请重试",
"重置 Passkey": "重置 Passkey",
"重置 2FA": "重置 2FA",
"确认重置 Passkey": "确认重置 Passkey",
"确认重置两步验证": "确认重置两步验证",
"此操作将解绑用户当前的 Passkey下次登录需要重新注册。": "此操作将解绑用户当前的 Passkey下次登录需要重新注册。",
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
"目标用户:{{username}}": "目标用户:{{username}}",
"Passkey 已重置": "Passkey 已重置",
"二步验证已重置": "二步验证已重置",
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
"确认解绑 Passkey": "确认解绑 Passkey",
"解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
"确认解绑": "确认解绑"
}

View File

@@ -18,7 +18,26 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Banner, Button, Form, Space, Spin } from '@douyinfe/semi-ui';
import {
Banner,
Button,
Form,
Space,
Spin,
RadioGroup,
Radio,
Table,
Modal,
Input,
Divider,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconEdit,
IconDelete,
IconSearch,
IconSaveStroked,
} from '@douyinfe/semi-icons';
import {
compareObjects,
API,
@@ -37,6 +56,52 @@ export default function SettingsChats(props) {
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [editMode, setEditMode] = useState('visual');
const [chatConfigs, setChatConfigs] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingConfig, setEditingConfig] = useState(null);
const [isEdit, setIsEdit] = useState(false);
const [searchText, setSearchText] = useState('');
const modalFormRef = useRef();
const jsonToConfigs = (jsonString) => {
try {
const configs = JSON.parse(jsonString);
return Array.isArray(configs)
? configs.map((config, index) => ({
id: index,
name: Object.keys(config)[0] || '',
url: Object.values(config)[0] || '',
}))
: [];
} catch (error) {
console.error('JSON parse error:', error);
return [];
}
};
const configsToJson = (configs) => {
const jsonArray = configs.map((config) => ({
[config.name]: config.url,
}));
return JSON.stringify(jsonArray, null, 2);
};
const syncJsonToConfigs = () => {
const configs = jsonToConfigs(inputs.Chats);
setChatConfigs(configs);
};
const syncConfigsToJson = (configs) => {
const jsonString = configsToJson(configs);
setInputs((prev) => ({
...prev,
Chats: jsonString,
}));
if (refForm.current && editMode === 'json') {
refForm.current.setValues({ Chats: jsonString });
}
};
async function onSubmit() {
try {
@@ -103,16 +168,184 @@ 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 || '[]');
setChatConfigs(configs);
}, [props.options]);
useEffect(() => {
if (editMode === 'visual') {
syncJsonToConfigs();
}
}, [inputs.Chats, editMode]);
useEffect(() => {
if (refForm.current && editMode === 'json') {
refForm.current.setValues(inputs);
}
}, [editMode, inputs]);
const handleAddConfig = () => {
setEditingConfig({ name: '', url: '' });
setIsEdit(false);
setModalVisible(true);
setTimeout(() => {
if (modalFormRef.current) {
modalFormRef.current.setValues({ name: '', url: '' });
}
}, 100);
};
const handleEditConfig = (config) => {
setEditingConfig({ ...config });
setIsEdit(true);
setModalVisible(true);
setTimeout(() => {
if (modalFormRef.current) {
modalFormRef.current.setValues(config);
}
}, 100);
};
const handleDeleteConfig = (id) => {
const newConfigs = chatConfigs.filter((config) => config.id !== id);
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
showSuccess(t('删除成功'));
};
const handleModalOk = () => {
if (modalFormRef.current) {
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
? { ...editingConfig, name: values.name, url: values.url }
: config,
);
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
} else {
const maxId =
chatConfigs.length > 0
? Math.max(...chatConfigs.map((c) => c.id))
: -1;
const newConfig = {
id: maxId + 1,
name: values.name,
url: values.url,
};
const newConfigs = [...chatConfigs, newConfig];
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
}
setModalVisible(false);
setEditingConfig(null);
showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
})
.catch((error) => {
console.error('Modal form validation error:', error);
});
}
};
const handleModalCancel = () => {
setModalVisible(false);
setEditingConfig(null);
};
const filteredConfigs = chatConfigs.filter(
(config) =>
!searchText ||
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 (
<span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
{part}
</span>
);
} else if (part === '{key}') {
return (
<span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
{part}
</span>
);
}
return part;
});
};
const columns = [
{
title: t('聊天应用名称'),
dataIndex: 'name',
key: 'name',
render: (text) => text || t('未命名'),
},
{
title: t('URL链接'),
dataIndex: 'url',
key: 'url',
render: (text) => (
<div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
{highlightKeywords(text)}
</div>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
<Button
type='primary'
icon={<IconEdit />}
size='small'
onClick={() => handleEditConfig(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
icon={<IconDelete />}
size='small'
onClick={() => handleDeleteConfig(record.id)}
>
{t('删除')}
</Button>
</Space>
),
},
];
return (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Space vertical style={{ width: '100%' }}>
<Form.Section text={t('聊天设置')}>
<Banner
type='info'
@@ -120,34 +353,155 @@ export default function SettingsChats(props) {
'链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
)}
/>
<Form.TextArea
label={t('聊天配置')}
extraText={''}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({
...inputs,
Chats: value,
})
}
/>
<Divider />
<div style={{ marginBottom: 16 }}>
<span style={{ marginRight: 16, fontWeight: 600 }}>
{t('编辑模式')}:
</span>
<RadioGroup
type='button'
value={editMode}
onChange={(e) => {
const newMode = e.target.value;
setEditMode(newMode);
// 确保模式切换时数据正确同步
setTimeout(() => {
if (newMode === 'json' && refForm.current) {
refForm.current.setValues(inputs);
}
}, 100);
}}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='json'>{t('JSON编辑')}</Radio>
</RadioGroup>
</div>
{editMode === 'visual' ? (
<div>
<Space style={{ marginBottom: 16 }}>
<Button
type='primary'
icon={<IconPlus />}
onClick={handleAddConfig}
>
{t('添加聊天配置')}
</Button>
<Button
type='primary'
theme='solid'
icon={<IconSaveStroked />}
onClick={onSubmit}
>
{t('保存聊天设置')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索聊天应用名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: 250 }}
showClear
/>
</Space>
<Table
columns={columns}
dataSource={filteredConfigs}
rowKey='id'
pagination={{
pageSize: 10,
showSizeChanger: false,
showQuickJumper: true,
showTotal: (total, range) =>
t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
total,
start: range[0],
end: range[1],
}),
}}
/>
</div>
) : (
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.TextArea
label={t('聊天配置')}
extraText={''}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({
...inputs,
Chats: value,
})
}
/>
</Form>
)}
</Form.Section>
</Form>
<Space>
<Button onClick={onSubmit}>{t('保存聊天设置')}</Button>
{editMode === 'json' && (
<Space>
<Button
type='primary'
icon={<IconSaveStroked />}
onClick={onSubmit}
>
{t('保存聊天设置')}
</Button>
</Space>
)}
</Space>
<Modal
title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
visible={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form getFormApi={(api) => (modalFormRef.current = api)}>
<Form.Input
field='name'
label={t('聊天应用名称')}
placeholder={t('请输入聊天应用名称')}
rules={[
{ required: true, message: t('请输入聊天应用名称') },
{ min: 1, message: t('名称不能为空') },
]}
/>
<Form.Input
field='url'
label={t('URL链接')}
placeholder={t('请输入完整的URL链接')}
rules={[{ required: true, message: t('请输入URL链接') }]}
/>
<Banner
type='info'
description={t(
'提示:链接中的{key}将被替换为API密钥{address}将被替换为服务器地址',
)}
style={{ marginTop: 16 }}
/>
</Form>
</Modal>
</Spin>
);
}

View File

@@ -0,0 +1,217 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { API, showError } from '../helpers';
import {
prepareCredentialRequestOptions,
buildAssertionResult,
isPasskeySupported
} from '../helpers/passkey';
/**
* 通用安全验证服务
* 验证状态完全由后端 Session 控制,前端不存储任何状态
*/
export class SecureVerificationService {
/**
* 检查用户可用的验证方式
* @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>}
*/
static async checkAvailableVerificationMethods() {
try {
const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
API.get('/api/user/2fa/status'),
API.get('/api/user/passkey'),
isPasskeySupported()
]);
console.log('=== DEBUGGING VERIFICATION METHODS ===');
console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2));
console.log('Passkey Response:', JSON.stringify(passkeyResponse, null, 2));
const has2FA = twoFAResponse.data?.success && twoFAResponse.data?.data?.enabled === true;
const hasPasskey = passkeyResponse.data?.success && passkeyResponse.data?.data?.enabled === true;
console.log('has2FA calculation:', {
success: twoFAResponse.data?.success,
dataExists: !!twoFAResponse.data?.data,
enabled: twoFAResponse.data?.data?.enabled,
result: has2FA
});
console.log('hasPasskey calculation:', {
success: passkeyResponse.data?.success,
dataExists: !!passkeyResponse.data?.data,
enabled: passkeyResponse.data?.data?.enabled,
result: hasPasskey
});
const result = {
has2FA,
hasPasskey,
passkeySupported
};
return result;
} catch (error) {
console.error('Failed to check verification methods:', error);
return {
has2FA: false,
hasPasskey: false,
passkeySupported: false
};
}
}
/**
* 执行2FA验证
* @param {string} code - 验证码
* @returns {Promise<void>}
*/
static async verify2FA(code) {
if (!code?.trim()) {
throw new Error('请输入验证码或备用码');
}
// 调用通用验证 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验证
* @returns {Promise<void>}
*/
static async verifyPasskey() {
try {
// 开始Passkey验证
const beginResponse = await API.post('/api/user/passkey/verify/begin');
if (!beginResponse.data?.success) {
throw new Error(beginResponse.data?.message || '开始验证失败');
}
// 准备WebAuthn选项
const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options);
// 执行WebAuthn验证
const credential = await navigator.credentials.get({ publicKey });
if (!credential) {
throw new Error('Passkey 验证被取消');
}
// 构建验证结果
const assertionResult = buildAssertionResult(credential);
// 完成验证
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
if (!finishResponse.data?.success) {
throw new Error(finishResponse.data?.message || '验证失败');
}
// 调用通用验证 API 设置 sessionPasskey 验证已完成)
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 验证被取消或超时');
} else if (error.name === 'InvalidStateError') {
throw new Error('Passkey 验证状态无效');
} else {
throw error;
}
}
}
/**
* 通用验证方法,根据验证类型执行相应的验证流程
* @param {string} method - 验证方式: '2fa' | 'passkey'
* @param {string} code - 2FA验证码当method为'2fa'时必需)
* @returns {Promise<void>}
*/
static async verify(method, code = '') {
switch (method) {
case '2fa':
return await this.verify2FA(code);
case 'passkey':
return await this.verifyPasskey();
default:
throw new Error(`不支持的验证方式: ${method}`);
}
}
}
/**
* 预设的API调用函数工厂
*/
export const createApiCalls = {
/**
* 创建查看渠道密钥的API调用
* @param {number} channelId - 渠道ID
*/
viewChannelKey: (channelId) => async () => {
// 新系统中,验证已通过中间件处理,直接调用 API 即可
const response = await API.post(`/api/channel/${channelId}/key`, {});
return response.data;
},
/**
* 创建自定义API调用
* @param {string} url - API URL
* @param {string} method - HTTP方法默认为 'POST'
* @param {Object} extraData - 额外的请求数据
*/
custom: (url, method = 'POST', extraData = {}) => async () => {
// 新系统中,验证已通过中间件处理
const data = extraData;
let response;
switch (method.toUpperCase()) {
case 'GET':
response = await API.get(url, { params: data });
break;
case 'POST':
response = await API.post(url, data);
break;
case 'PUT':
response = await API.put(url, data);
break;
case 'DELETE':
response = await API.delete(url, { data });
break;
default:
throw new Error(`不支持的HTTP方法: ${method}`);
}
return response.data;
}
};