mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-15 14:27:26 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01469aa01c | ||
|
|
0769184b9b | ||
|
|
4137120d69 | ||
|
|
3d1433dd70 | ||
|
|
acbfc9d3b3 | ||
|
|
4cdad47695 | ||
|
|
6a1de0ebdc | ||
|
|
92a4e88ceb | ||
|
|
e9043590a9 | ||
|
|
9e6828653b | ||
|
|
dae661bb53 | ||
|
|
649a5205c9 | ||
|
|
26a563da54 | ||
|
|
c1492be131 | ||
|
|
7ca65a5e8e | ||
|
|
b244a06ca1 | ||
|
|
c320410c84 | ||
|
|
2938246f2e | ||
|
|
0e9ad4a15f | ||
|
|
2200bb9166 | ||
|
|
d6db10b4bc | ||
|
|
85ff8b1422 | ||
|
|
1428338546 | ||
|
|
ec76b0f5e2 | ||
|
|
96b172e93b | ||
|
|
70263e96ab | ||
|
|
15db5c0062 | ||
|
|
f5a774f22c | ||
|
|
0b91e45197 | ||
|
|
6bc3e62fd5 | ||
|
|
3ba2aaee32 | ||
|
|
0a6f39e60b | ||
|
|
1bd791d603 | ||
|
|
fcc6172b43 | ||
|
|
7533ffc3ee | ||
|
|
e71407ee62 | ||
|
|
d026edc1b3 | ||
|
|
ab166649bc | ||
|
|
9f20e49100 | ||
|
|
013a575541 | ||
|
|
4c13666f26 | ||
|
|
e8425addf0 | ||
|
|
aab82f22fa | ||
|
|
8e10af82b1 | ||
|
|
84cdd24116 | ||
|
|
dcf4336c75 | ||
|
|
3ed0ae83f1 | ||
|
|
ec9903e640 | ||
|
|
8d92ce38ed | ||
|
|
f2e9fd7afb | ||
|
|
465830945b |
@@ -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 返回指定端点类型的默认信息以及是否存在
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
497
controller/passkey.go
Normal 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
|
||||
}
|
||||
313
controller/secure_verification.go
Normal file
313
controller/secure_verification.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,预扣费:%s,tokens:%d)",
|
||||
task.TaskID,
|
||||
logger.LogQuota(quotaDelta),
|
||||
logger.LogQuota(actualQuota),
|
||||
logger.LogQuota(preConsumedQuota),
|
||||
taskResult.TotalTokens,
|
||||
))
|
||||
if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
|
||||
} else {
|
||||
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
|
||||
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
|
||||
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||
|
||||
// 记录消费日志
|
||||
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %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,预扣费:%s,tokens:%d)",
|
||||
task.TaskID,
|
||||
logger.LogQuota(refundQuota),
|
||||
logger.LogQuota(actualQuota),
|
||||
logger.LogQuota(preConsumedQuota),
|
||||
taskResult.TotalTokens,
|
||||
))
|
||||
if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
|
||||
} else {
|
||||
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||
|
||||
// 记录退款日志
|
||||
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %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 预扣费准确(%s,tokens:%d)",
|
||||
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case model.TaskStatusFailure:
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = "100%"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
20
go.mod
@@ -1,7 +1,9 @@
|
||||
module one-api
|
||||
|
||||
// +heroku goVersion go1.18
|
||||
go 1.23.4
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.6
|
||||
|
||||
require (
|
||||
github.com/Calcium-Ion/go-epay v0.0.4
|
||||
@@ -20,6 +22,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.9.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
@@ -35,10 +38,10 @@ require (
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/tiktoken-go/tokenizer v0.6.2
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.11.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.17.0
|
||||
gorm.io/driver/mysql v1.4.3
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
gorm.io/gorm v1.25.2
|
||||
@@ -58,6 +61,7 @@ require (
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
@@ -65,8 +69,11 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.25 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
@@ -91,11 +98,12 @@ require (
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
|
||||
37
go.sum
37
go.sum
@@ -47,6 +47,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
@@ -89,16 +91,24 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
@@ -200,8 +210,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
|
||||
@@ -229,27 +240,31 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -261,14 +276,14 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
|
||||
5
main.go
5
main.go
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载环境变量
|
||||
|
||||
@@ -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
|
||||
|
||||
131
middleware/secure_verification.go
Normal file
131
middleware/secure_verification.go
Normal 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()
|
||||
}
|
||||
@@ -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
209
model/passkey.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
relay/channel/task/doubao/adaptor.go
Normal file
248
relay/channel/task/doubao/adaptor.go
Normal 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
|
||||
}
|
||||
9
relay/channel/task/doubao/constants.go
Normal file
9
relay/channel/task/doubao/constants.go
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
177
service/passkey/service.go
Normal 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"
|
||||
}
|
||||
50
service/passkey/session.go
Normal file
50
service/passkey/session.go
Normal 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
71
service/passkey/user.go
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
49
setting/system_setting/passkey.go
Normal file
49
setting/system_setting/passkey.go
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
117
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
117
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal 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;
|
||||
285
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
285
web/src/components/common/modals/SecureVerificationModal.jsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
{/* 右侧:其他设置 */}
|
||||
|
||||
@@ -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('允许不安全的 Origin(HTTP)')}
|
||||
</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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
39
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
39
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal 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;
|
||||
|
||||
39
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
39
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal 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;
|
||||
|
||||
@@ -164,6 +164,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'blue',
|
||||
label: 'SubModel',
|
||||
},
|
||||
{
|
||||
value: 54,
|
||||
color: 'blue',
|
||||
label: '豆包视频',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -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
137
web/src/helpers/passkey.js
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
62
web/src/helpers/secureApiCall.js
Normal file
62
web/src/helpers/secureApiCall.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
246
web/src/hooks/common/useSecureVerification.jsx
Normal file
246
web/src/hooks/common/useSecureVerification.jsx
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
"允许不安全的 Origin(HTTP)": "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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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安全密钥",
|
||||
"允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
|
||||
"仅用于开发环境,生产环境应使用 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 登录,确定要继续吗?",
|
||||
"确认解绑": "确认解绑"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
217
web/src/services/secureVerification.js
Normal file
217
web/src/services/secureVerification.js
Normal 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 设置 session(Passkey 验证已完成)
|
||||
const verifyResponse = await API.post('/api/verify', {
|
||||
method: 'passkey'
|
||||
});
|
||||
|
||||
if (!verifyResponse.data?.success) {
|
||||
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 验证成功,session 已在后端设置
|
||||
} catch (error) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
throw new Error('Passkey 验证被取消或超时');
|
||||
} 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user