From 0936e2504655a5cbf7bc3c388f6d3e2bb24916d3 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Tue, 19 May 2026 12:11:24 +0800 Subject: [PATCH 001/400] perf: avoid eager formatting in debug log calls (#4929) --- controller/custom_oauth.go | 4 +-- controller/passkey.go | 5 +++ controller/task_video.go | 6 ++-- controller/twofa.go | 2 +- controller/user.go | 14 +++++--- logger/logger.go | 13 ++++--- model/channel.go | 4 +-- model/channel_cache.go | 12 ++++--- model/user.go | 4 +-- relay/channel/ali/image.go | 7 ++-- relay/channel/api_request.go | 40 ++++++--------------- relay/channel/claude/relay-claude.go | 4 +-- relay/channel/gemini/relay-gemini-native.go | 8 ++--- relay/channel/gemini/relay-gemini.go | 6 ++-- relay/channel/openai/adaptor.go | 10 +++--- relay/channel/openai/relay-openai.go | 8 ++--- relay/claude_handler.go | 6 ++-- relay/common_handler/rerank.go | 5 ++- relay/compatible_handler.go | 4 +-- relay/embedding_handler.go | 2 +- relay/gemini_handler.go | 4 +-- relay/helper/price.go | 8 ++--- relay/helper/stream_scanner.go | 33 ++++++----------- relay/image_handler.go | 4 +-- relay/mjproxy_handler.go | 3 +- relay/rerank_handler.go | 5 ++- relay/responses_handler.go | 5 ++- service/file_decoder.go | 2 +- service/file_service.go | 4 +-- service/midjourney.go | 8 ++--- service/quota.go | 3 +- service/task_polling.go | 8 ++--- service/token_counter.go | 8 ++--- 33 files changed, 110 insertions(+), 149 deletions(-) diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index c21ec7910bce..8172e29718f3 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -501,7 +501,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= targetUser.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, targetUser.Role) { common.ApiErrorMsg(c, "no permission") return } @@ -560,7 +560,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= targetUser.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, targetUser.Role) { common.ApiErrorMsg(c, "no permission") return } diff --git a/controller/passkey.go b/controller/passkey.go index 79930fdff513..ac79e6547d57 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -350,6 +350,11 @@ func AdminResetPasskey(c *gin.Context) { common.ApiError(c, err) return } + myRole := c.GetInt("role") + if !canManageTargetRole(myRole, user.Role) { + common.ApiErrorMsg(c, "no permission") + return + } if _, err := model.GetPasskeyByUserID(user.Id); err != nil { if errors.Is(err, model.ErrPasskeyNotFound) { diff --git a/controller/task_video.go b/controller/task_video.go index ce808df6fc32..8be2e1769ba4 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -96,13 +96,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha return fmt.Errorf("readAll failed for task %s: %w", taskId, err) } - logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody))) + logger.LogDebug(ctx, "UpdateVideoSingleTask response: %s", responseBody) taskResult := &relaycommon.TaskInfo{} // try parse as New API response format var responseItems dto.TaskResponse[model.Task] if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { - logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems)) + logger.LogDebug(ctx, "UpdateVideoSingleTask parsed as new api response format: %+v", responseItems) t := responseItems.Data taskResult.TaskID = t.TaskID taskResult.Status = string(t.Status) @@ -116,7 +116,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.Data = redactVideoResponseBody(responseBody) } - logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult)) + logger.LogDebug(ctx, "UpdateVideoSingleTask taskResult: %+v", taskResult) now := time.Now().Unix() if taskResult.Status == "" { diff --git a/controller/twofa.go b/controller/twofa.go index 123c74e2cf44..ef86fd762ddc 100644 --- a/controller/twofa.go +++ b/controller/twofa.go @@ -520,7 +520,7 @@ func AdminDisable2FA(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= targetUser.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, targetUser.Role) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无权操作同级或更高级用户的2FA设置", diff --git a/controller/user.go b/controller/user.go index 555ef9b31238..dafb66094f5f 100644 --- a/controller/user.go +++ b/controller/user.go @@ -264,6 +264,10 @@ func SearchUsers(c *gin.Context) { return } +func canManageTargetRole(myRole int, targetRole int) bool { + return myRole == common.RoleRootUser || myRole > targetRole +} + func GetUser(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -276,7 +280,7 @@ func GetUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, user.Role) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel) return } @@ -567,11 +571,11 @@ func UpdateUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= originUser.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, originUser.Role) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } - if myRole <= updatedUser.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, updatedUser.Role) { common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) return } @@ -610,7 +614,7 @@ func AdminClearUserBinding(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, user.Role) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel) return } @@ -872,7 +876,7 @@ func ManageUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { + if !canManageTargetRole(myRole, user.Role) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } diff --git a/logger/logger.go b/logger/logger.go index 7b0c82de50d6..867d88322430 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -95,9 +95,11 @@ func LogDebug(ctx context.Context, msg string, args ...any) { } func logHelper(ctx context.Context, level string, msg string) { - id := ctx.Value(common.RequestIdKey) - if id == nil { - id = "SYSTEM" + var id any = "SYSTEM" + if ctx != nil { + if requestID := ctx.Value(common.RequestIdKey); requestID != nil { + id = requestID + } } now := time.Now() common.LogWriterMu.RLock() @@ -172,10 +174,13 @@ func FormatQuota(quota int) string { // LogJson 仅供测试使用 only for test func LogJson(ctx context.Context, msg string, obj any) { + if !common.DebugEnabled { + return + } jsonStr, err := common.Marshal(obj) if err != nil { LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error())) return } - LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr))) + LogDebug(ctx, "%s | %s", msg, jsonStr) } diff --git a/model/channel.go b/model/channel.go index 370a99bffa7c..3e6d1866a096 100644 --- a/model/channel.go +++ b/model/channel.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/types" "github.com/samber/lo" @@ -250,10 +251,9 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { if err != nil { return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } - //println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex) defer func() { if common.DebugEnabled { - println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)) + logger.LogDebug(nil, "channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex) } if !common.MemoryCacheEnabled { _ = channel.SaveChannelInfo() diff --git a/model/channel_cache.go b/model/channel_cache.go index c9c503576038..03740d2cd3ab 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/setting/ratio_setting" ) @@ -257,9 +258,12 @@ func CacheUpdateChannel(channel *Channel) { return } - println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex) - - println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex) + if channelsIDM == nil { + channelsIDM = make(map[int]*Channel) + } + if oldChannel, ok := channelsIDM[channel.Id]; ok { + logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex) + } channelsIDM[channel.Id] = channel - println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex) + logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex) } diff --git a/model/user.go b/model/user.go index 5079acaf2e22..8a65031538b2 100644 --- a/model/user.go +++ b/model/user.go @@ -35,8 +35,8 @@ type User struct { OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` - VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! - AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management Quota int `json:"quota" gorm:"type:int;default:0"` UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 79bc22029f0a..a391e40ff4f1 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -229,7 +229,7 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) ( time.Sleep(time.Duration(5) * time.Second) for { - logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)) + logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds) step++ rsp, err, body := updateTask(info, taskID) responseBody = body @@ -320,11 +320,10 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela } } - //logger.LogDebug(c, "ali_async_task_result: "+string(originRespBody)) if a.IsSyncImageModel { - logger.LogDebug(c, "ali_sync_image_result: "+string(originRespBody)) + logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody) } else { - logger.LogDebug(c, "ali_async_image_result: "+string(originRespBody)) + logger.LogDebug(c, "ali_async_image_result: %s", originRespBody) } imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index ac7e2156063e..d5d953b32ef5 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -292,9 +292,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody if err != nil { return nil, fmt.Errorf("get request url failed: %w", err) } - if common2.DebugEnabled { - println("fullRequestURL:", fullRequestURL) - } + logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL) req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) @@ -323,9 +321,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod if err != nil { return nil, fmt.Errorf("get request url failed: %w", err) } - if common2.DebugEnabled { - println("fullRequestURL:", fullRequestURL) - } + logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL) req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) @@ -388,13 +384,9 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc defer func() { // 增加panic恢复处理 if r := recover(); r != nil { - if common2.DebugEnabled { - println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r)) - } - } - if common2.DebugEnabled { - println("SSE ping goroutine stopped.") + logger.LogDebug(c, "SSE ping goroutine panic recovered: %v", r) } + logger.LogDebug(c, "SSE ping goroutine stopped") }() if pingInterval <= 0 { @@ -405,15 +397,11 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc // 确保在任何情况下都清理ticker defer func() { ticker.Stop() - if common2.DebugEnabled { - println("SSE ping ticker stopped") - } + logger.LogDebug(c, "SSE ping ticker stopped") }() var pingMutex sync.Mutex - if common2.DebugEnabled { - println("SSE ping goroutine started") - } + logger.LogDebug(c, "SSE ping goroutine started") // 增加超时控制,防止goroutine长时间运行 maxPingDuration := 120 * time.Minute // 最大ping持续时间 @@ -425,9 +413,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc // 发送 ping 数据 case <-ticker.C: if err := sendPingData(c, &pingMutex); err != nil { - if common2.DebugEnabled { - println("SSE ping error, stopping goroutine:", err.Error()) - } + logger.LogDebug(c, "SSE ping error, stopping goroutine: %s", err.Error()) return } // 收到退出信号 @@ -438,9 +424,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc return // 超时保护,防止goroutine无限运行 case <-pingTimeout.C: - if common2.DebugEnabled { - println("SSE ping goroutine timeout, stopping") - } + logger.LogDebug(c, "SSE ping goroutine timeout, stopping") return } } @@ -463,9 +447,7 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error { return } - if common2.DebugEnabled { - println("SSE ping data sent.") - } + logger.LogDebug(c, "SSE ping data sent") done <- nil }() @@ -507,9 +489,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http defer func() { if stopPinger != nil { stopPinger() - if common2.DebugEnabled { - println("SSE ping goroutine stopped by defer") - } + logger.LogDebug(c, "SSE ping goroutine stopped by defer") } }() } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index e177e56dab14..046ccfe681a0 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -949,9 +949,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI if err != nil { return nil, types.NewError(err, types.ErrorCodeBadResponseBody) } - if common.DebugEnabled { - println("responseBody: ", string(responseBody)) - } + logger.LogDebug(c, "responseBody: %s", responseBody) handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody) if handleErr != nil { return nil, handleErr diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 1a434a43276d..5d91121cb880 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -26,9 +26,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if common.DebugEnabled { - println(string(responseBody)) - } + logger.LogDebug(c, "Gemini native response body: %s", responseBody) // 解析为 Gemini 原生响应格式 var geminiResponse dto.GeminiChatResponse @@ -57,9 +55,7 @@ func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *rel return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if common.DebugEnabled { - println(string(responseBody)) - } + logger.LogDebug(c, "Gemini native embedding response body: %s", responseBody) usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens()) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 355c75d71b7c..0824a0e13629 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1362,7 +1362,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * } } - logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount)) + logger.LogDebug(c, "info.SendResponseCount = %d", info.SendResponseCount) if info.SendResponseCount == 0 { // send first response emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil) @@ -1422,9 +1422,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } service.CloseResponseBodyGracefully(resp) - if common.DebugEnabled { - println(string(responseBody)) - } + logger.LogDebug(c, "Gemini response body: %s", responseBody) var geminiResponse dto.GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 6941ca54a732..581ae1966032 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -377,7 +377,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf } // 打印类似 curl 命令格式的信息 - logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'model=\"%s\"'", request.Model)) + logger.LogDebug(c.Request.Context(), "--form 'model=\"%s\"'", request.Model) // 遍历表单字段并打印输出 for key, values := range formData.Value { @@ -386,7 +386,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf } for _, value := range values { writer.WriteField(key, value) - logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form '%s=\"%s\"'", key, value)) + logger.LogDebug(c.Request.Context(), "--form '%s=\"%s\"'", key, value) } } @@ -398,8 +398,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf // 使用 formData 中的第一个文件 fileHeader := fileHeaders[0] - logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)", - fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type"))) + logger.LogDebug(c.Request.Context(), "--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)", + fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type")) file, err := fileHeader.Open() if err != nil { @@ -418,7 +418,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf // 关闭 multipart 编写器以设置分界线 writer.Close() c.Request.Header.Set("Content-Type", writer.FormDataContentType()) - logger.LogDebug(c.Request.Context(), fmt.Sprintf("--header 'Content-Type: %s'", writer.FormDataContentType())) + logger.LogDebug(c.Request.Context(), "--header 'Content-Type: %s'", writer.FormDataContentType()) return &requestBody, nil } } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index a85751844c0b..c21d4399304d 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -155,9 +155,9 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re containStreamUsage = true if common.DebugEnabled { - logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d", + logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d", usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, - usage.InputTokens, usage.OutputTokens)) + usage.InputTokens, usage.OutputTokens) } } } @@ -200,9 +200,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } - if common.DebugEnabled { - println("upstream response body:", string(responseBody)) - } + logger.LogDebug(c, "upstream response body: %s", responseBody) // Unmarshal to simpleResponse if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() { // 尝试解析为 openrouter enterprise diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 54f8ced2adf4..ec028c71f418 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" @@ -177,9 +178,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } } - if common.DebugEnabled { - println("requestBody: ", string(jsonData)) - } + logger.LogDebug(c, "requestBody: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) } @@ -202,7 +201,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } usage, newAPIError := adaptor.DoResponse(c, httpResp, info) - //log.Printf("usage: %v", usage) if newAPIError != nil { // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) diff --git a/relay/common_handler/rerank.go b/relay/common_handler/rerank.go index f52a91b03bdc..a3f30ae9f75d 100644 --- a/relay/common_handler/rerank.go +++ b/relay/common_handler/rerank.go @@ -7,6 +7,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/relay/channel/xinference" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/service" @@ -21,9 +22,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } service.CloseResponseBodyGracefully(resp) - if common.DebugEnabled { - println("reranker response body: ", string(responseBody)) - } + logger.LogDebug(c, "reranker response body: %s", responseBody) var jinaResp dto.RerankResponse if info.ChannelType == constant.ChannelTypeXinference { var xinRerankResponse xinference.XinRerankResponse diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 7a5624eb34d6..fdd54f39d194 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -102,7 +102,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } if common.DebugEnabled { if debugBytes, bErr := storage.Bytes(); bErr == nil { - println("requestBody: ", string(debugBytes)) + logger.LogDebug(c, "requestBody: %s", debugBytes) } } requestBody = common.ReaderOnly(storage) @@ -174,7 +174,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } } - logger.LogDebug(c, fmt.Sprintf("text request body: %s", string(jsonData))) + logger.LogDebug(c, "text request body: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index b8e7fc9dcfd7..e2fda93e7e5c 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } } - logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData))) + logger.LogDebug(c, "converted embedding request body: %s", jsonData) var requestBody io.Reader = bytes.NewBuffer(jsonData) statusCodeMappingStr := c.GetString("status_code_mapping") resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 3b4bafe2a673..df3bf47c2ad2 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -163,7 +163,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } } - logger.LogDebug(c, "Gemini request body: "+string(jsonData)) + logger.LogDebug(c, "Gemini request body: %s", jsonData) requestBody = bytes.NewReader(jsonData) } @@ -262,7 +262,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI return newAPIErrorFromParamOverride(err) } } - logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData)) + logger.LogDebug(c, "Gemini embedding request body: %s", jsonData) requestBody = bytes.NewReader(jsonData) resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/helper/price.go b/relay/helper/price.go index 0e68edba206b..d1e16bca6085 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -45,7 +45,7 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types. // check auto group autoGroup, exists := ctx.Get("auto_group") if exists { - logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup)) + logger.LogDebug(ctx, "final group: %s", autoGroup) relayInfo.UsingGroup = autoGroup.(string) } @@ -157,7 +157,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } if common.DebugEnabled { - println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting())) + logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting()) } info.PriceData = priceData return priceData, nil @@ -299,9 +299,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT QuotaToPreConsume: preConsumedQuota, } - if common.DebugEnabled { - println(fmt.Sprintf("model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)) - } + logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier) info.PriceData = priceData return priceData, nil diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index a9bc5e16a720..1d44b80443cd 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -72,14 +72,11 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon pingTicker = time.NewTicker(pingInterval) } - if common.DebugEnabled { - // print timeout and ping interval for debugging - println("relay timeout seconds:", common.RelayTimeout) - println("relay max idle conns:", common.RelayMaxIdleConns) - println("relay max idle conns per host:", common.RelayMaxIdleConnsPerHost) - println("streaming timeout seconds:", int64(streamingTimeout.Seconds())) - println("ping interval seconds:", int64(pingInterval.Seconds())) - } + logger.LogDebug(c, "relay timeout seconds: %d", common.RelayTimeout) + logger.LogDebug(c, "relay max idle conns: %d", common.RelayMaxIdleConns) + logger.LogDebug(c, "relay max idle conns per host: %d", common.RelayMaxIdleConnsPerHost) + logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds())) + logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds())) // 改进资源清理,确保所有 goroutine 正确退出 defer func() { @@ -127,9 +124,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r)) common.SafeSendBool(stopChan, true) } - if common.DebugEnabled { - println("ping goroutine exited") - } + logger.LogDebug(c, "ping goroutine exited") }() // 添加超时保护,防止 goroutine 无限运行 @@ -155,9 +150,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err) return } - if common.DebugEnabled { - println("ping data sent") - } + logger.LogDebug(c, "ping data sent") case <-time.After(10 * time.Second): logger.LogError(c, "ping data send timeout") info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout")) @@ -217,9 +210,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r)) } common.SafeSendBool(stopChan, true) - if common.DebugEnabled { - println("scanner goroutine exited") - } + logger.LogDebug(c, "scanner goroutine exited") }() for scanner.Scan() { @@ -237,9 +228,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ticker.Reset(streamingTimeout) data := scanner.Text() - if common.DebugEnabled { - println(data) - } + logger.LogDebug(c, "stream scanner data: %s", data) if len(data) < 6 { continue @@ -265,9 +254,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } } else { info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil) - if common.DebugEnabled { - println("received [DONE], stopping scanner") - } + logger.LogDebug(c, "received [DONE], stopping scanner") return } } diff --git a/relay/image_handler.go b/relay/image_handler.go index e986dd897e65..7b3d961bc835 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -76,9 +76,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type } } - if common.DebugEnabled { - logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData))) - } + logger.LogDebug(c, "image request body: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) } } diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index ee48ca64b10b..5b0750fec435 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" @@ -473,7 +474,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt c.Set("base_url", channel.GetBaseURL()) c.Set("channel_id", originTask.ChannelId) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) - log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) + logger.LogDebug(c, "Midjourney action uses origin channel: id=%s, base_url=%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) } midjRequest.Prompt = originTask.Prompt diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 53cd6e47ad3b..edc69f686fe5 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" @@ -67,9 +68,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } } - if common.DebugEnabled { - println(fmt.Sprintf("Rerank request body: %s", string(jsonData))) - } + logger.LogDebug(c, "Rerank request body: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 58324aa7cec9..54ca3cbc5010 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -10,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/common" appconstant "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" @@ -102,9 +103,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } } - if common.DebugEnabled { - println("requestBody: ", string(jsonData)) - } + logger.LogDebug(c, "requestBody: %s", jsonData) requestBody = bytes.NewBuffer(jsonData) } diff --git a/service/file_decoder.go b/service/file_decoder.go index 57898af7d736..27605a0f8322 100644 --- a/service/file_decoder.go +++ b/service/file_decoder.go @@ -85,7 +85,7 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e var readData []byte limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024} for _, limit := range limits { - logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit)) + logger.LogDebug(c, "Trying to read %d bytes to determine file type", limit) if len(readData) < limit { need := limit - len(readData) tmp := make([]byte, need) diff --git a/service/file_service.go b/service/file_service.go index bcf4744224fd..03baf2deab84 100644 --- a/service/file_service.go +++ b/service/file_service.go @@ -50,7 +50,7 @@ func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) ( } if common.DebugEnabled { - logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier())) + logger.LogDebug(c, "LoadFileSource starting for: %s", source.GetIdentifier()) } // 1. 快速检查内部缓存 @@ -208,7 +208,7 @@ func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFil } common.IncrementDiskFiles(base64Size) if common.DebugEnabled { - logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size)) + logger.LogDebug(c, "File cached to disk: %s, size: %d bytes", diskPath, base64Size) } } } else { diff --git a/service/midjourney.go b/service/midjourney.go index bdb0fe50a94b..c6397b7f0815 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "io" - "log" "net/http" "strconv" "strings" @@ -13,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/setting" @@ -235,9 +235,8 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err } CloseResponseBodyGracefully(resp) - respStr := string(responseBody) - log.Printf("respStr: %s", respStr) - if respStr == "" { + logger.LogDebug(c, "midjourney response body: %s", responseBody) + if len(responseBody) == 0 { return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil } else { err = json.Unmarshal(responseBody, &midjResponse) @@ -248,7 +247,6 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU } } } - //log.Printf("midjResponse: %v", midjResponse) //for k, v := range resp.Header { // c.Writer.Header().Set(k, v[0]) //} diff --git a/service/quota.go b/service/quota.go index e2ab25cf09ba..862805d7cdb0 100644 --- a/service/quota.go +++ b/service/quota.go @@ -3,7 +3,6 @@ package service import ( "errors" "fmt" - "log" "math" "strings" "time" @@ -112,7 +111,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup) if exists { groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string)) - log.Printf("final group ratio: %f", groupRatio) + logger.LogDebug(ctx, "final group ratio: %f", groupRatio) relayInfo.UsingGroup = autoGroup.(string) } diff --git a/service/task_polling.go b/service/task_polling.go index dc85e579e8cc..c5ec3ea33ead 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -372,7 +372,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return fmt.Errorf("readAll failed for task %s: %w", taskId, err) } - logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask response: %s", string(responseBody))) + logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody) snap := task.Snapshot() @@ -380,7 +380,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * // try parse as New API response format var responseItems dto.TaskResponse[model.Task] if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { - logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask parsed as new api response format: %+v", responseItems)) + logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems) t := responseItems.Data taskResult.TaskID = t.TaskID taskResult.Status = string(t.Status) @@ -394,7 +394,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task.Data = redactVideoResponseBody(responseBody) - logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult)) + logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) now := time.Now().Unix() if taskResult.Status == "" { @@ -488,7 +488,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } } else { // No changes, skip update - logger.LogDebug(ctx, fmt.Sprintf("No update needed for task %s", task.TaskID)) + logger.LogDebug(ctx, "No update needed for task %s", task.TaskID) } if shouldSettle { diff --git a/service/token_counter.go b/service/token_counter.go index 63b76d978ad8..933d9fd1ee9b 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -3,7 +3,6 @@ package service import ( "errors" "fmt" - "log" "math" "path/filepath" "strings" @@ -12,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" constant2 "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/types" @@ -111,7 +111,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea width := config.Width height := config.Height - log.Printf("format: %s, width: %d, height: %d", format, width, height) + logger.LogDebug(c, "image token input: format=%s, width=%d, height=%d", format, width, height) if isPatchBased { // 32x32 patch-based calculation with 1536 cap and model multiplier @@ -171,9 +171,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea tilesH := (finalH + 512 - 1) / 512 tiles := tilesW * tilesH - if common.DebugEnabled { - log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles) - } + logger.LogDebug(c, "image token scaled size: width=%d, height=%d, tiles=%d", finalW, finalH, tiles) return tiles*tileTokens + baseTokens, nil } From ee9736bbc82c6f36402f1df0310f4773f5f95de0 Mon Sep 17 00:00:00 2001 From: Li Duoyang Date: Tue, 19 May 2026 01:14:03 -0700 Subject: [PATCH 002/400] fix: add type="submit" to forgot password form button (#4910) The "Send reset email" button was missing type="submit", preventing form submission when clicked. All other auth forms (sign-in, sign-up, OTP) already have this attribute set correctly. Closes #4793 --- .../auth/forgot-password/components/forgot-password-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx b/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx index 24dbb4e3b164..9df5f481147f 100644 --- a/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx +++ b/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx @@ -107,7 +107,7 @@ export function ForgotPasswordForm({ )} /> - From 04b4483d7d8b388675b5445c3250d5dae2cd3af7 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Tue, 19 May 2026 16:14:08 +0800 Subject: [PATCH 003/400] fix(web): normalize model detail tabs layout (#4938) --- web/default/src/features/dashboard/index.tsx | 2 +- web/default/src/features/models/index.tsx | 2 +- .../src/features/pricing/components/model-details.tsx | 6 +++--- .../features/profile/components/profile-settings-card.tsx | 2 +- web/default/src/features/usage-logs/index.tsx | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 399221b02395..4e300be274ad 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -236,7 +236,7 @@ export function Dashboard() {
{showSectionTabs ? ( - + {visibleSections.map((section) => ( {t(SECTION_META[section].titleKey)} diff --git a/web/default/src/features/models/index.tsx b/web/default/src/features/models/index.tsx index e0f179a2c64a..202005bbcfa6 100644 --- a/web/default/src/features/models/index.tsx +++ b/web/default/src/features/models/index.tsx @@ -142,7 +142,7 @@ function ModelsContent() {
- + {MODELS_SECTION_IDS.map((section) => ( {t(SECTION_META[section].titleKey)} diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 2746b221e0f2..123ddbeb5231 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -920,17 +920,17 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { - + {TAB_VALUES.map((value) => { const Icon = TAB_META[value].icon return ( - {t(TAB_META[value].labelKey)} + {t(TAB_META[value].labelKey)} ) })} diff --git a/web/default/src/features/profile/components/profile-settings-card.tsx b/web/default/src/features/profile/components/profile-settings-card.tsx index 1d5eae4cc437..160386b16eab 100644 --- a/web/default/src/features/profile/components/profile-settings-card.tsx +++ b/web/default/src/features/profile/components/profile-settings-card.tsx @@ -69,7 +69,7 @@ export function ProfileSettingsCard({ icon={} > - + {showTaskSwitcher && ( - + {visibleSections.map((section) => ( {t(SECTION_META[section].titleKey)} From 8ae095c3b84c28f17935343dadd69d2446fa81f7 Mon Sep 17 00:00:00 2001 From: Baiyuan Chiu Date: Tue, 19 May 2026 16:14:11 +0800 Subject: [PATCH 004/400] fix user create and delete handling (#4818) --- controller/user.go | 10 ++++++---- .../features/users/components/users-mutate-drawer.tsx | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/controller/user.go b/controller/user.go index dafb66094f5f..c174c7986d9c 100644 --- a/controller/user.go +++ b/controller/user.go @@ -782,12 +782,14 @@ func DeleteUser(c *gin.Context) { } err = model.HardDeleteUserById(id) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - }) + common.ApiError(c, err) return } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return } func DeleteSelf(c *gin.Context) { diff --git a/web/default/src/features/users/components/users-mutate-drawer.tsx b/web/default/src/features/users/components/users-mutate-drawer.tsx index 427da8b60ea3..3d3c2a4b585a 100644 --- a/web/default/src/features/users/components/users-mutate-drawer.tsx +++ b/web/default/src/features/users/components/users-mutate-drawer.tsx @@ -121,6 +121,17 @@ export function UsersMutateDrawer({ const currentQuotaRaw = form.watch('quota_dollars') || 0 const onSubmit = async (data: UserFormValues) => { + if (!isUpdate) { + const passwordLength = data.password?.length || 0 + if (passwordLength < 8 || passwordLength > 20) { + form.setError('password', { + type: 'manual', + message: t('Password must be between 8 and 20 characters'), + }) + return + } + } + setIsSubmitting(true) try { const payload = transformFormDataToPayload(data, currentRow?.id) From b397c58bab61016ed83f8d75e9732c19b76d8015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuhan=20Guo=E4=B8=A8Eohan?= Date: Tue, 19 May 2026 16:14:34 +0800 Subject: [PATCH 005/400] fix(auth): expose register_enabled in /api/status and gate sign-up link (#4871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/status never returned `register_enabled` or `password_register_enabled`, so the sign-in page had no way to react when an admin disabled registration. The "Sign up" link was only gated on `self_use_mode_enabled`, which is a separate and unrelated concept (single-user vs. multi-user deployment). Result: toggling "Registration Enabled" in admin settings had no visible effect on the login page — users could still see the sign-up link even when registration was disabled, and could not see it even when it was enabled (if the system happened to be in self-use mode from initial setup). Fix: - Add `register_enabled` and `password_register_enabled` to GetStatus() - Gate the "Sign up" link on `register_enabled !== false` in addition to the existing `!self_use_mode_enabled` check Co-authored-by: Claude Sonnet 4.6 --- controller/misc.go | 2 ++ web/default/src/features/auth/sign-in/index.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/controller/misc.go b/controller/misc.go index 29b3a5c5e180..344cda7715d6 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -87,6 +87,8 @@ func GetStatus(c *gin.Context) { "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "register_enabled": common.RegisterEnabled, + "password_register_enabled": common.PasswordRegisterEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, "usd_exchange_rate": operation_setting.USDExchangeRate, diff --git a/web/default/src/features/auth/sign-in/index.tsx b/web/default/src/features/auth/sign-in/index.tsx index f0fe0f0e862d..d9675c238ee9 100644 --- a/web/default/src/features/auth/sign-in/index.tsx +++ b/web/default/src/features/auth/sign-in/index.tsx @@ -35,7 +35,7 @@ export function SignIn() {

{t('Sign in')}

- {!status?.self_use_mode_enabled && ( + {!status?.self_use_mode_enabled && status?.register_enabled !== false && (

{t("Don't have an account?")}{' '} Date: Tue, 19 May 2026 16:14:37 +0800 Subject: [PATCH 006/400] fix(web/default): update pagination button labels in ModelCardGrid (#4675) Change 'Previous' to 'Previous page' and 'Next' to 'Next page' for improved clarity in the ModelCardGrid component. --- .../src/features/pricing/components/model-card-grid.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/pricing/components/model-card-grid.tsx b/web/default/src/features/pricing/components/model-card-grid.tsx index 2c8932dc74ce..173d1aba81cd 100644 --- a/web/default/src/features/pricing/components/model-card-grid.tsx +++ b/web/default/src/features/pricing/components/model-card-grid.tsx @@ -103,7 +103,7 @@ export function ModelCardGrid(props: ModelCardGridProps) { className='gap-1.5' > - {t('Previous')} + {t('Previous page')}

From cb9270ed238c20112fc7efa87ac1c570ae01b1f0 Mon Sep 17 00:00:00 2001 From: Neo <65333514+neow021@users.noreply.github.com> Date: Tue, 19 May 2026 01:14:49 -0700 Subject: [PATCH 007/400] fix(auth): localize reset password confirmation (#4769) * fix(auth): localize reset password confirmation Wrap reset confirmation page copy in frontend i18n calls and add matching locale entries so the page no longer mixes translated labels with hardcoded English copy. * fix(auth): use semantic reset i18n keys --- .../features/auth/reset-password-confirm/index.tsx | 12 +++++++----- web/default/src/i18n/locales/en.json | 5 +++++ web/default/src/i18n/locales/fr.json | 5 +++++ web/default/src/i18n/locales/ja.json | 5 +++++ web/default/src/i18n/locales/ru.json | 5 +++++ web/default/src/i18n/locales/vi.json | 5 +++++ web/default/src/i18n/locales/zh.json | 5 +++++ 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/web/default/src/features/auth/reset-password-confirm/index.tsx b/web/default/src/features/auth/reset-password-confirm/index.tsx index c953284d59fb..634153642504 100644 --- a/web/default/src/features/auth/reset-password-confirm/index.tsx +++ b/web/default/src/features/auth/reset-password-confirm/index.tsx @@ -112,8 +112,8 @@ export function ResetPasswordConfirm({

{newPassword - ? 'Your password has been reset successfully' - : 'Confirm the reset request to generate a new password.'} + ? t('auth.resetPasswordConfirm.success') + : t('auth.resetPasswordConfirm.description')}

@@ -178,10 +178,12 @@ export function ResetPasswordConfirm({ } > {newPassword - ? 'Return to login' + ? t('auth.resetPasswordConfirm.backToLogin') : isActive - ? `Retry (${secondsLeft}s)` - : 'Confirm reset password'} + ? t('auth.resetPasswordConfirm.retry', { + seconds: secondsLeft, + }) + : t('auth.resetPasswordConfirm.confirm')} {!newPassword && ( diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 4f8ae557694c..1560b01992de 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -886,6 +886,8 @@ "Confirm New Password": "Confirm New Password", "Confirm password": "Confirm password", "Confirm Payment": "Confirm Payment", + "auth.resetPasswordConfirm.confirm": "Confirm reset password", + "auth.resetPasswordConfirm.description": "Confirm the reset request to generate a new password.", "Confirm Selection": "Confirm Selection", "Confirm settings and finish setup": "Confirm settings and finish setup", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -3349,6 +3351,7 @@ "Retain last N files": "Retain last N files", "Retention days": "Retention days", "Retry": "Retry", + "auth.resetPasswordConfirm.retry": "Retry ({{seconds}}s)", "Retry Chain": "Retry Chain", "Retry Suggestion": "Retry Suggestion", "Retry Times": "Retry Times", @@ -3358,6 +3361,7 @@ "Return Error": "Return Error", "Return per-token log probabilities": "Return per-token log probabilities", "Return to dashboard": "Return to dashboard", + "auth.resetPasswordConfirm.backToLogin": "Return to login", "Return vector embeddings for inputs": "Return vector embeddings for inputs", "Reveal API key": "Reveal API key", "Reveal key": "Reveal key", @@ -4466,6 +4470,7 @@ "Your GitHub OAuth Client ID": "Your GitHub OAuth Client ID", "Your GitHub OAuth Client Secret": "Your GitHub OAuth Client Secret", "Your new backup codes are ready": "Your new backup codes are ready", + "auth.resetPasswordConfirm.success": "Your password has been reset successfully", "Your Referral Link": "Your Referral Link", "Your setup guide is collapsed so usage stays in focus.": "Your setup guide is collapsed so usage stays in focus.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Your system access token for API authentication. Keep it secure and don't share it with others.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 850f83621e43..8b555700a150 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -886,6 +886,8 @@ "Confirm New Password": "Confirmer le nouveau mot de passe", "Confirm password": "Confirmer le mot de passe", "Confirm Payment": "Confirmer le paiement", + "auth.resetPasswordConfirm.confirm": "Confirmer la réinitialisation du mot de passe", + "auth.resetPasswordConfirm.description": "Confirmez la demande de réinitialisation pour générer un nouveau mot de passe.", "Confirm Selection": "Confirmer la sélection", "Confirm settings and finish setup": "Confirmez les paramètres et terminez la configuration", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -3349,6 +3351,7 @@ "Retain last N files": "Conserver les N derniers fichiers", "Retention days": "Jours de rétention", "Retry": "Réessayer", + "auth.resetPasswordConfirm.retry": "Réessayer ({{seconds}}s)", "Retry Chain": "Chaîne de tentatives", "Retry Suggestion": "Suggestion de relance", "Retry Times": "Nombre de tentatives", @@ -3358,6 +3361,7 @@ "Return Error": "Retourner l'erreur", "Return per-token log probabilities": "Retourner les log-probabilités par jeton", "Return to dashboard": "Retour au tableau de bord", + "auth.resetPasswordConfirm.backToLogin": "Retour à la connexion", "Return vector embeddings for inputs": "Renvoyer des embeddings vectoriels pour les entrées", "Reveal API key": "Afficher la clé API", "Reveal key": "Révéler la clé", @@ -4466,6 +4470,7 @@ "Your GitHub OAuth Client ID": "Votre ID Client OAuth GitHub", "Your GitHub OAuth Client Secret": "Votre Secret Client OAuth GitHub", "Your new backup codes are ready": "Vos nouveaux codes de secours sont prêts", + "auth.resetPasswordConfirm.success": "Votre mot de passe a été réinitialisé avec succès", "Your Referral Link": "Votre lien de parrainage", "Your setup guide is collapsed so usage stays in focus.": "Le guide de configuration est réduit afin de garder l'utilisation au premier plan.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Votre jeton d'accès système pour l'authentification API. Gardez-le en sécurité et ne le partagez pas avec d'autres.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index cb5743b0fab2..778867e9428e 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -886,6 +886,8 @@ "Confirm New Password": "新しいパスワードの確認", "Confirm password": "パスワードの確認", "Confirm Payment": "支払いの確認", + "auth.resetPasswordConfirm.confirm": "パスワードリセットを確認", + "auth.resetPasswordConfirm.description": "新しいパスワードを生成するには、リセット要求を確認してください。", "Confirm Selection": "選択の確認", "Confirm settings and finish setup": "設定を確認してセットアップを完了", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -3349,6 +3351,7 @@ "Retain last N files": "最新N個のファイルを保持", "Retention days": "保持日数", "Retry": "再試行", + "auth.resetPasswordConfirm.retry": "再試行 ({{seconds}}秒)", "Retry Chain": "リトライチェーン", "Retry Suggestion": "リトライ提案", "Retry Times": "再試行回数", @@ -3358,6 +3361,7 @@ "Return Error": "エラーを返す", "Return per-token log probabilities": "トークンごとの対数確率を返します", "Return to dashboard": "ダッシュボードに戻る", + "auth.resetPasswordConfirm.backToLogin": "ログインに戻る", "Return vector embeddings for inputs": "入力に対してベクトル埋め込みを返却", "Reveal API key": "APIキーを表示", "Reveal key": "キーを表示", @@ -4466,6 +4470,7 @@ "Your GitHub OAuth Client ID": "あなたのGitHub OAuthクライアントID", "Your GitHub OAuth Client Secret": "あなたのGitHub OAuthクライアントシークレット", "Your new backup codes are ready": "新しいバックアップコードの準備ができました", + "auth.resetPasswordConfirm.success": "パスワードが正常にリセットされました", "Your Referral Link": "あなたの紹介リンク", "Your setup guide is collapsed so usage stays in focus.": "利用状況に集中できるよう、セットアップガイドを折りたたみました。", "Your system access token for API authentication. Keep it secure and don't share it with others.": "API認証用のシステムアクセストークンです。安全に保管し、他者と共有しないでください。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 91b6d6da780c..88ae65b07189 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -886,6 +886,8 @@ "Confirm New Password": "Подтвердить новый пароль", "Confirm password": "Подтвердить пароль", "Confirm Payment": "Подтвердить оплату", + "auth.resetPasswordConfirm.confirm": "Подтвердить сброс пароля", + "auth.resetPasswordConfirm.description": "Подтвердите запрос на сброс, чтобы создать новый пароль.", "Confirm Selection": "Подтвердить выбор", "Confirm settings and finish setup": "Подтвердите настройки и завершите установку", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -3349,6 +3351,7 @@ "Retain last N files": "Хранить последние N файлов", "Retention days": "Дней хранения", "Retry": "Повторить попытку", + "auth.resetPasswordConfirm.retry": "Повторить ({{seconds}}с)", "Retry Chain": "Цепочка повторов", "Retry Suggestion": "Рекомендация по повтору", "Retry Times": "Количество повторных попыток", @@ -3358,6 +3361,7 @@ "Return Error": "Вернуть ошибку", "Return per-token log probabilities": "Возвращать логарифмические вероятности по токенам", "Return to dashboard": "Вернуться на панель управления", + "auth.resetPasswordConfirm.backToLogin": "Вернуться ко входу", "Return vector embeddings for inputs": "Возвращать векторные эмбеддинги для входных данных", "Reveal API key": "Показать API ключ", "Reveal key": "Показать ключ", @@ -4466,6 +4470,7 @@ "Your GitHub OAuth Client ID": "Ваш ID клиента GitHub OAuth", "Your GitHub OAuth Client Secret": "Ваш секрет клиента GitHub OAuth", "Your new backup codes are ready": "Ваши новые резервные коды готовы", + "auth.resetPasswordConfirm.success": "Ваш пароль успешно сброшен", "Your Referral Link": "Ваша реферальная ссылка", "Your setup guide is collapsed so usage stays in focus.": "Руководство свернуто, чтобы основные показатели оставались в фокусе.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Ваш системный токен доступа для аутентификации API. Храните его в безопасности и не делитесь им с другими.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index ed2764d292c6..5e2e32001b2f 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -886,6 +886,8 @@ "Confirm New Password": "Xác nhận mật khẩu mới", "Confirm password": "Xác nhận mật khẩu", "Confirm Payment": "Xác nhận Thanh toán", + "auth.resetPasswordConfirm.confirm": "Xác nhận đặt lại mật khẩu", + "auth.resetPasswordConfirm.description": "Xác nhận yêu cầu đặt lại để tạo mật khẩu mới.", "Confirm Selection": "Xác nhận lựa chọn", "Confirm settings and finish setup": "Xác nhận cài đặt và hoàn tất thiết lập", "confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment", @@ -3349,6 +3351,7 @@ "Retain last N files": "Giữ lại N tệp gần nhất", "Retention days": "Số ngày lưu giữ", "Retry": "Thử lại", + "auth.resetPasswordConfirm.retry": "Thử lại ({{seconds}} giây)", "Retry Chain": "Chuỗi thử lại", "Retry Suggestion": "Gợi ý thử lại", "Retry Times": "Số lần thử lại", @@ -3358,6 +3361,7 @@ "Return Error": "Trả về lỗi", "Return per-token log probabilities": "Trả về log probabilities cho từng token", "Return to dashboard": "Quay lại bảng điều khiển", + "auth.resetPasswordConfirm.backToLogin": "Quay lại đăng nhập", "Return vector embeddings for inputs": "Trả về vector embedding cho đầu vào", "Reveal API key": "Hiển thị khóa API", "Reveal key": "Display key", @@ -4466,6 +4470,7 @@ "Your GitHub OAuth Client ID": "Client ID OAuth GitHub của bạn", "Your GitHub OAuth Client Secret": "Bí mật ứng dụng OAuth của GitHub của bạn", "Your new backup codes are ready": "Mã dự phòng mới của bạn đã sẵn sàng", + "auth.resetPasswordConfirm.success": "Mật khẩu của bạn đã được đặt lại thành công", "Your Referral Link": "Liên kết giới thiệu của bạn", "Your setup guide is collapsed so usage stays in focus.": "Hướng dẫn thiết lập đã thu gọn để giữ phần sử dụng ở vị trí nổi bật.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Mã truy cập hệ thống của bạn để xác thực API. Hãy giữ nó an toàn và đừng chia sẻ nó với người khác.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index fbfd6d733c83..6da35355a926 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -886,6 +886,8 @@ "Confirm New Password": "确认新密码", "Confirm password": "确认密码", "Confirm Payment": "确认付款", + "auth.resetPasswordConfirm.confirm": "确认重置密码", + "auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。", "Confirm Selection": "确认选择", "Confirm settings and finish setup": "确认设置并完成安装", "confirm that I bear legal responsibility arising from deployment": "并确认自行承担部署", @@ -3349,6 +3351,7 @@ "Retain last N files": "保留最近 N 个文件", "Retention days": "保留天数", "Retry": "重试", + "auth.resetPasswordConfirm.retry": "重试 ({{seconds}}s)", "Retry Chain": "重试链路", "Retry Suggestion": "重试建议", "Retry Times": "重试次数", @@ -3358,6 +3361,7 @@ "Return Error": "返回错误", "Return per-token log probabilities": "返回每个 token 的对数概率", "Return to dashboard": "返回仪表盘", + "auth.resetPasswordConfirm.backToLogin": "返回登录", "Return vector embeddings for inputs": "为输入返回向量嵌入", "Reveal API key": "显示 API 密钥", "Reveal key": "显示密钥", @@ -4466,6 +4470,7 @@ "Your GitHub OAuth Client ID": "您的 GitHub OAuth 客户端 ID", "Your GitHub OAuth Client Secret": "您的 GitHub OAuth 客户端密钥", "Your new backup codes are ready": "您的新备份代码已准备就绪", + "auth.resetPasswordConfirm.success": "您的密码已成功重置", "Your Referral Link": "您的推荐链接", "Your setup guide is collapsed so usage stays in focus.": "设置引导已收起,让用量信息保持在焦点位置。", "Your system access token for API authentication. Keep it secure and don't share it with others.": "您的系统访问令牌,用于 API 认证。请妥善保管,不要与他人分享。", From 8db32213e71481ec679a81930cccbe1ac973f305 Mon Sep 17 00:00:00 2001 From: panxinyu Date: Tue, 19 May 2026 16:14:56 +0800 Subject: [PATCH 008/400] fix(web/default/wallet): make recharge preset selection visible in dark mode (#4897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selected preset buttons looked identical to unselected in dark mode: the override classes `border-foreground bg-foreground/5` carry no `dark:` variant, while the Button `outline` variant base contains `dark:border-input dark:bg-input/30`. tailwind-merge keeps both (different variants → no conflict), and in dark mode CSS specificity makes `.dark .border-input` win over `.border-foreground`, so the override is silently overridden and the bright-border/tinted-bg selection state never applies. Add explicit `dark:border-foreground dark:bg-foreground/10` to the override so tailwind-merge resolves the dark-variant conflict in favor of the override and the selected state is clearly distinguishable on both light and dark backgrounds. Co-authored-by: xinnyu --- .../src/features/wallet/components/recharge-form-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index aa76428f1e3d..ad147dd4b895 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -240,7 +240,7 @@ export function RechargeFormCard({ className={cn( 'hover:border-foreground flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4', selectedPreset === preset.value - ? 'border-foreground bg-foreground/5' + ? 'border-foreground bg-foreground/5 dark:border-foreground dark:bg-foreground/10' : 'border-muted' )} onClick={() => onSelectPreset(preset)} From c78573ce03561ef9fabfa63728bd8805af2e534f Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Tue, 19 May 2026 16:15:02 +0800 Subject: [PATCH 009/400] fix(web/default): api-info color dot shows wrong color due to semantic token mismatch (#4824) * fix: unify color system for api-info, add slate to SemanticColor Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> * fix: use direct Tailwind color classes in colorToBgClass for accurate color display Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --------- Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --- .../content/api-info-section.tsx | 39 +++++++++---------- web/default/src/lib/colors.ts | 35 +++++++++-------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/web/default/src/features/system-settings/content/api-info-section.tsx b/web/default/src/features/system-settings/content/api-info-section.tsx index e05beca71622..a5e3b71206d9 100644 --- a/web/default/src/features/system-settings/content/api-info-section.tsx +++ b/web/default/src/features/system-settings/content/api-info-section.tsx @@ -23,6 +23,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Plus, Edit, Trash2, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { getBgColorClass } from '@/lib/colors' import { AlertDialog, AlertDialogAction, @@ -98,20 +99,20 @@ const createApiInfoSchema = (t: (key: string) => string) => type ApiInfoFormValues = z.infer> const colorOptions = [ - { value: 'blue', label: 'Blue', bgClass: 'bg-blue-500' }, - { value: 'green', label: 'Green', bgClass: 'bg-green-500' }, - { value: 'cyan', label: 'Cyan', bgClass: 'bg-cyan-500' }, - { value: 'purple', label: 'Purple', bgClass: 'bg-purple-500' }, - { value: 'pink', label: 'Pink', bgClass: 'bg-pink-500' }, - { value: 'red', label: 'Red', bgClass: 'bg-red-500' }, - { value: 'orange', label: 'Orange', bgClass: 'bg-orange-500' }, - { value: 'amber', label: 'Amber', bgClass: 'bg-amber-500' }, - { value: 'yellow', label: 'Yellow', bgClass: 'bg-yellow-500' }, - { value: 'lime', label: 'Lime', bgClass: 'bg-lime-500' }, - { value: 'teal', label: 'Teal', bgClass: 'bg-teal-500' }, - { value: 'indigo', label: 'Indigo', bgClass: 'bg-indigo-500' }, - { value: 'violet', label: 'Violet', bgClass: 'bg-violet-500' }, - { value: 'slate', label: 'Slate', bgClass: 'bg-slate-500' }, + { value: 'blue', label: 'Blue' }, + { value: 'green', label: 'Green' }, + { value: 'cyan', label: 'Cyan' }, + { value: 'purple', label: 'Purple' }, + { value: 'pink', label: 'Pink' }, + { value: 'red', label: 'Red' }, + { value: 'orange', label: 'Orange' }, + { value: 'amber', label: 'Amber' }, + { value: 'yellow', label: 'Yellow' }, + { value: 'lime', label: 'Lime' }, + { value: 'teal', label: 'Teal' }, + { value: 'indigo', label: 'Indigo' }, + { value: 'violet', label: 'Violet' }, + { value: 'slate', label: 'Slate' }, ] export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { @@ -270,11 +271,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { ) } - const getColorClass = (color: string) => { - return ( - colorOptions.find((opt) => opt.value === color)?.bgClass || 'bg-blue-500' - ) - } + const getColorClass = (color: string) => getBgColorClass(color) return (
{option.label}
@@ -509,7 +506,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
{option.label}
diff --git a/web/default/src/lib/colors.ts b/web/default/src/lib/colors.ts index b439f6af32e5..81c32317bd53 100644 --- a/web/default/src/lib/colors.ts +++ b/web/default/src/lib/colors.ts @@ -33,24 +33,26 @@ export type SemanticColor = | 'indigo' | 'violet' | 'grey' + | 'slate' export const colorToBgClass: Record = { - blue: 'bg-chart-1', - green: 'bg-success', - cyan: 'bg-chart-2', - purple: 'bg-chart-4', - pink: 'bg-chart-5', - red: 'bg-destructive', - orange: 'bg-warning', - amber: 'bg-warning', - yellow: 'bg-warning', - lime: 'bg-chart-3', - 'light-green': 'bg-success', - teal: 'bg-chart-2', - 'light-blue': 'bg-info', - indigo: 'bg-chart-1', - violet: 'bg-chart-4', - grey: 'bg-neutral', + blue: 'bg-blue-500', + green: 'bg-green-500', + cyan: 'bg-cyan-500', + purple: 'bg-purple-500', + pink: 'bg-pink-500', + red: 'bg-red-500', + orange: 'bg-orange-500', + amber: 'bg-amber-500', + yellow: 'bg-yellow-500', + lime: 'bg-lime-500', + 'light-green': 'bg-green-400', + teal: 'bg-teal-500', + 'light-blue': 'bg-sky-400', + indigo: 'bg-indigo-500', + violet: 'bg-violet-500', + grey: 'bg-gray-400', + slate: 'bg-slate-500', } export const avatarColorMap: Record = { @@ -70,6 +72,7 @@ export const avatarColorMap: Record = { indigo: 'bg-chart-1/10 text-chart-1', violet: 'bg-chart-4/10 text-chart-4', grey: 'bg-muted text-muted-foreground', + slate: 'bg-muted text-muted-foreground', } export function getAvatarColorClass(name: string): string { From 032993ed4935a357cad4bc0bd21b1fcc3473e24f Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Tue, 19 May 2026 16:15:13 +0800 Subject: [PATCH 010/400] fix: check save result in handleSaveAll and add slate to validColors (#4823) Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> --- setting/console_setting/validation.go | 2 +- .../features/system-settings/content/api-info-section.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index 529457761bb0..d6e4342c3d8f 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -17,7 +17,7 @@ var ( "blue": true, "green": true, "cyan": true, "purple": true, "pink": true, "red": true, "orange": true, "amber": true, "yellow": true, "lime": true, "light-green": true, "teal": true, "light-blue": true, "indigo": true, - "violet": true, "grey": true, + "violet": true, "grey": true, "slate": true, } slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ) diff --git a/web/default/src/features/system-settings/content/api-info-section.tsx b/web/default/src/features/system-settings/content/api-info-section.tsx index a5e3b71206d9..eb552584f2b9 100644 --- a/web/default/src/features/system-settings/content/api-info-section.tsx +++ b/web/default/src/features/system-settings/content/api-info-section.tsx @@ -250,12 +250,13 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { const handleSaveAll = async () => { try { - await updateOption.mutateAsync({ + const result = await updateOption.mutateAsync({ key: 'console_setting.api_info', value: JSON.stringify(apiInfoList), }) - setHasChanges(false) - toast.success(t('API info saved successfully')) + if (result.success) { + setHasChanges(false) + } } catch { toast.error(t('Failed to save API info')) } From 0cd9a3a0681889d9ab6a7d919e47cddb8f2174e3 Mon Sep 17 00:00:00 2001 From: Calcium-Ion Date: Tue, 19 May 2026 16:39:42 +0800 Subject: [PATCH 011/400] fix(auth): use aff_code field name in registration payload (#4945) (#4965) The new UI's sign-up form sent the invite code under key `aff`, but the backend `Register` controller binds it to `User.AffCode` whose JSON tag is `aff_code` (see model/user.go). Result: every invited sign-up landed with `inviter_id = 0`, breaking the affiliate flow. Rename only the request payload field so it matches the backend contract. URL query parameter (`/sign-up?aff=...`), localStorage key and OAuth state continue to use `aff` and are unchanged. Closes #4945 --- .../src/features/auth/sign-up/components/sign-up-form.tsx | 2 +- web/default/src/features/auth/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index b3a5813d7f48..b5ebd0b8bc40 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -155,7 +155,7 @@ export function SignUpForm({ password: data.password, email: data.email || undefined, verification_code: verificationCode || undefined, - aff: getAffiliateCode(), + aff_code: getAffiliateCode(), turnstile: turnstileToken, }) diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index b429e20c250b..60aedd97b067 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -37,7 +37,7 @@ export interface RegisterPayload { password: string email?: string verification_code?: string - aff?: string + aff_code?: string turnstile?: string } From 5e88f97ac1ed87961089cc4aa71ecebb3938f3a2 Mon Sep 17 00:00:00 2001 From: Calcium-Ion Date: Tue, 19 May 2026 16:39:57 +0800 Subject: [PATCH 012/400] fix(data-table): make faceted filter popover width adaptive (#4905) (#4966) The faceted filter popover used a fixed width of 200px, which clipped long option labels (e.g. user-defined channel group names) and forced the truncated text to be unreadable without leaving a way to see the full value. - Switch PopoverContent from `w-[200px]` to `min-w-[200px] max-w-[360px]` so short option lists keep their current footprint while long labels can expand up to 360px before the existing truncate kicks in. - Add `title={t(option.label)}` on the truncated label span so users can still hover to see the full text on extreme cases. Closes #4905 --- .../src/components/data-table/faceted-filter.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/default/src/components/data-table/faceted-filter.tsx b/web/default/src/components/data-table/faceted-filter.tsx index 9cca6b50a611..bd81a1530e04 100644 --- a/web/default/src/components/data-table/faceted-filter.tsx +++ b/web/default/src/components/data-table/faceted-filter.tsx @@ -107,7 +107,10 @@ export function DataTableFacetedFilter({ )} - + @@ -159,7 +162,10 @@ export function DataTableFacetedFilter({ ) : option.icon ? ( ) : null} - + {t(option.label)} {typeof option.count === 'number' ? ( From 146dd77b83a26c57b5070a391e3e8edf4ab017b9 Mon Sep 17 00:00:00 2001 From: Calcium-Ion Date: Tue, 19 May 2026 16:40:11 +0800 Subject: [PATCH 013/400] fix(keys): call submit handler directly to avoid stale form linkage (#4858) (#4967) Users reported that the API key edit drawer's "Save changes" button becomes unresponsive after the drawer has been open / idle for a while: no loading state, no request, no error. Reopening the drawer restores it because a fresh DOM is created. The button lived in `SheetFooter` (a portaled Base UI Sheet) and was linked to the form via the HTML `form='api-key-form'` attribute. Once the portal/DOM relationship goes stale, the click no longer triggers the form's submit event, hence the silent failure. Defensive fix: drop the cross-DOM `form` linkage and call `form.handleSubmit(onSubmit)` directly via `onClick`. The native submit path (Enter key, original `
`) is preserved. Closes #4858 --- .../src/features/keys/components/api-keys-mutate-drawer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index e3f8d55646fe..cc21e8012d91 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -610,8 +610,8 @@ export function ApiKeysMutateDrawer({ {t('Close')}
) } diff --git a/web/default/src/features/dashboard/hooks/use-status-data.ts b/web/default/src/features/dashboard/hooks/use-status-data.ts index d9a9c40bc284..8f10e8a59400 100644 --- a/web/default/src/features/dashboard/hooks/use-status-data.ts +++ b/web/default/src/features/dashboard/hooks/use-status-data.ts @@ -27,7 +27,7 @@ export function useStatusData( dataKey: string ): { items: T[]; loading: boolean } { const { status, loading } = useStatus() - const enabled = status?.[enabledKey] ?? false + const enabled = status ? status[enabledKey] !== false : false const items = (enabled ? status?.[dataKey] || [] : []) as T[] return { items, loading } @@ -56,3 +56,18 @@ export function useAnnouncements() { export function useFAQ() { return useStatusData('faq_enabled', 'faq') } + +/** + * Get dashboard content panel visibility + */ +export function useDashboardContentVisibility() { + const { status } = useStatus() + const hasStatus = Boolean(status) + + return { + apiInfo: hasStatus && status?.api_info_enabled !== false, + announcements: hasStatus && status?.announcements_enabled !== false, + faq: hasStatus && status?.faq_enabled !== false, + uptimeKuma: hasStatus && status?.uptime_kuma_enabled !== false, + } +} From 20d3e73734527cded251aff23202dfbf5a2584ca Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Wed, 20 May 2026 11:38:09 +0800 Subject: [PATCH 016/400] fix: filter perf metrics summary by active groups (#4976) --- controller/perf_metrics.go | 17 ++++++++--------- model/perf_metric.go | 13 ++++++++++--- model/task_cas_test.go | 3 +++ pkg/perf_metrics/metrics.go | 21 +++++++++++++++++++-- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/controller/perf_metrics.go b/controller/perf_metrics.go index 51e8d9ecda23..66d0787f2a92 100644 --- a/controller/perf_metrics.go +++ b/controller/perf_metrics.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" + "github.com/samber/lo" ) func GetPerfMetricsSummary(c *gin.Context) { @@ -18,7 +19,8 @@ func GetPerfMetricsSummary(c *gin.Context) { } } - result, err := perfmetrics.QuerySummaryAll(hours) + activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto") + result, err := perfmetrics.QuerySummaryAll(hours, activeGroups) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -72,12 +74,9 @@ func GetPerfMetrics(c *gin.Context) { } func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult { - activeGroups := ratio_setting.GetGroupRatioCopy() - filtered := make([]perfmetrics.GroupResult, 0, len(groups)) - for _, g := range groups { - if _, ok := activeGroups[g.Group]; ok || g.Group == "auto" { - filtered = append(filtered, g) - } - } - return filtered + activeRatios := ratio_setting.GetGroupRatioCopy() + return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool { + _, ok := activeRatios[g.Group] + return ok || g.Group == "auto" + }) } diff --git a/model/perf_metric.go b/model/perf_metric.go index dcc4a8bc7779..1667adfb8ae5 100644 --- a/model/perf_metric.go +++ b/model/perf_metric.go @@ -68,11 +68,18 @@ type PerfMetricSummary struct { GenerationMs int64 `json:"generation_ms"` } -func GetPerfMetricsSummaryAll(startTs int64, endTs int64) ([]PerfMetricSummary, error) { +func GetPerfMetricsSummaryAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummary, error) { var summaries []PerfMetricSummary - err := DB.Model(&PerfMetric{}). + query := DB.Model(&PerfMetric{}). Select("model_name, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms"). - Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs). + Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs) + if groups != nil { + if len(groups) == 0 { + return summaries, nil + } + query = query.Where(commonGroupCol+" IN ?", groups) + } + err := query. Group("model_name"). Having("SUM(request_count) > 0"). Find(&summaries).Error diff --git a/model/task_cas_test.go b/model/task_cas_test.go index ba34a73291bc..29455e3a2a01 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -26,6 +26,7 @@ func TestMain(m *testing.M) { common.RedisEnabled = false common.BatchUpdateEnabled = false common.LogConsumeEnabled = true + initCol() sqlDB, err := db.DB() if err != nil { @@ -43,6 +44,7 @@ func TestMain(m *testing.M) { &SubscriptionPlan{}, &SubscriptionOrder{}, &UserSubscription{}, + &PerfMetric{}, ); err != nil { panic("failed to migrate: " + err.Error()) } @@ -62,6 +64,7 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM subscription_orders") DB.Exec("DELETE FROM subscription_plans") DB.Exec("DELETE FROM user_subscriptions") + DB.Exec("DELETE FROM perf_metrics") }) } diff --git a/pkg/perf_metrics/metrics.go b/pkg/perf_metrics/metrics.go index e258ae1a492d..ab505d0d4d7b 100644 --- a/pkg/perf_metrics/metrics.go +++ b/pkg/perf_metrics/metrics.go @@ -122,7 +122,7 @@ func Query(params QueryParams) (QueryResult, error) { return buildQueryResult(params.Model, merged), nil } -func QuerySummaryAll(hours int) (SummaryAllResult, error) { +func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) { if hours <= 0 { hours = 24 } @@ -131,8 +131,9 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) { } endTs := time.Now().Unix() startTs := endTs - int64(hours)*3600 + allowedGroups := allowedGroupSet(groups) - rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs) + rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs, groups) if err != nil { return SummaryAllResult{}, err } @@ -153,6 +154,11 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) { if k.bucketTs < startTs || k.bucketTs > endTs { return true } + if allowedGroups != nil { + if _, ok := allowedGroups[k.group]; !ok { + return true + } + } snap := value.(*atomicBucket).snapshot() if snap.requestCount == 0 { return true @@ -193,6 +199,17 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) { return SummaryAllResult{Models: models}, nil } +func allowedGroupSet(groups []string) map[string]struct{} { + if groups == nil { + return nil + } + allowed := make(map[string]struct{}, len(groups)) + for _, group := range groups { + allowed[group] = struct{}{} + } + return allowed +} + func bucketStart(ts int64) int64 { bucketSeconds := perf_metrics_setting.GetBucketSeconds() if bucketSeconds <= 0 { From 58ba867dd6b547eb399c318f708f82fbd7774d98 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 21 May 2026 11:09:51 +0800 Subject: [PATCH 017/400] fix: improve channel test failure details UX (#4988) * fix: improve channel test failure details UX * fix: add accessible label to channel models region --- .../dialogs/channel-test-dialog.tsx | 670 ++++++++++++------ web/default/src/i18n/locales/en.json | 5 + web/default/src/i18n/locales/fr.json | 5 + web/default/src/i18n/locales/ja.json | 5 + web/default/src/i18n/locales/ru.json | 5 + web/default/src/i18n/locales/vi.json | 5 + web/default/src/i18n/locales/zh.json | 5 + 7 files changed, 493 insertions(+), 207 deletions(-) diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index 18ed116796ce..c3b93064588b 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -26,8 +26,10 @@ import { getPaginationRowModel, useReactTable, } from '@tanstack/react-table' -import { Loader2, Settings } from 'lucide-react' +import { Check, Copy, Info, Loader2, Settings } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { useIsMobile } from '@/hooks/use-mobile' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -48,6 +50,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' import { Table, @@ -114,6 +124,88 @@ const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([ 'openai-response-compact', ]) +const MODEL_PRICE_ERROR_CODE = 'model_price_error' +const FAILURE_SUMMARY_MAX_LENGTH = 96 + +type FailureStatusDisplay = { + summary: string + details?: string +} + +type FailureDetailsState = { + model: string + summary: string + details: string +} + +function normalizeInlineError(errorText: string) { + return errorText.replace(/\s+/g, ' ').trim() +} + +function getFirstErrorLine(errorText: string) { + return errorText + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +function truncateFailureSummary(summary: string) { + if (summary.length <= FAILURE_SUMMARY_MAX_LENGTH) { + return summary + } + + return `${summary.slice(0, FAILURE_SUMMARY_MAX_LENGTH).trimEnd()}...` +} + +function getFailureStatusDisplay({ + errorText, + fallbackSummary, + isModelPriceError, + modelPriceSummary, +}: { + errorText?: string + fallbackSummary: string + isModelPriceError: boolean + modelPriceSummary: string +}): FailureStatusDisplay { + const rawError = errorText?.trim() + + if (!rawError) { + return { summary: fallbackSummary } + } + + if (isModelPriceError) { + return { + summary: modelPriceSummary, + details: rawError === modelPriceSummary ? undefined : rawError, + } + } + + const firstLine = getFirstErrorLine(rawError) ?? rawError + const summary = truncateFailureSummary(normalizeInlineError(firstLine)) + const normalizedRawError = normalizeInlineError(rawError) + + return { + summary, + details: summary === normalizedRawError ? undefined : rawError, + } +} + +function getTestTableColumnClass(columnId: string) { + switch (columnId) { + case 'select': + return 'w-10 min-w-10' + case 'model': + return 'w-auto whitespace-nowrap' + case 'status': + return 'w-70 min-w-70 max-w-70 whitespace-normal' + case 'actions': + return 'bg-popover sticky right-0 z-20 w-24 min-w-24 border-l shadow-[-8px_0_8px_-8px_rgb(0_0_0_/_0.2)] whitespace-nowrap sm:w-28 sm:min-w-28' + default: + return undefined + } +} + export function ChannelTestDialog({ open, onOpenChange, @@ -129,6 +221,8 @@ export function ChannelTestDialog({ () => new Set() ) const [isBatchTesting, setIsBatchTesting] = useState(false) + const [failureDetails, setFailureDetails] = + useState(null) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10, @@ -142,6 +236,7 @@ export function ChannelTestDialog({ setRowSelection({}) setTestingModels(() => new Set()) setIsBatchTesting(false) + setFailureDetails(null) setPagination({ pageIndex: 0, pageSize: 10 }) }, []) @@ -199,6 +294,7 @@ export function ChannelTestDialog({ }, []) const updateTestResult = useCallback((key: string, result: TestResult) => { + setFailureDetails((current) => (current?.model === key ? null : current)) setTestResults((prev) => ({ ...prev, [key]: result, @@ -283,14 +379,16 @@ export function ChannelTestDialog({ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value) } - aria-label='Select all models' + aria-label={t('Select all models')} /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} - aria-label={`Select model ${row.original.model}`} + aria-label={t('Select model {{model}}', { + model: row.original.model, + })} /> ), enableSorting: false, @@ -299,17 +397,19 @@ export function ChannelTestDialog({ }, { accessorKey: 'model', - header: 'Model', + header: t('Model'), cell: ({ row }) => { const model = row.original.model const isDefault = defaultTestModel === model return ( -
- {model} +
+ + {model} + {isDefault && ( { const model = row.original.model const result = testResults[model] - - if (!result || result.status === 'idle') { - return ( - - ) - } - - if (result.status === 'testing') { - return ( -
- - Testing... -
- ) - } - - if (result.status === 'success') { - return ( -
- - {typeof result.responseTime === 'number' && ( - - {formatResponseTime(result.responseTime, t)} - - )} -
- ) - } - return ( -
- - {result.error && ( - - {result.error} - - )} - {result.errorCode === 'model_price_error' && ( - - )} -
+ ) }, enableSorting: false, @@ -391,7 +438,7 @@ export function ChannelTestDialog({ }, { id: 'actions', - header: 'Actions', + header: t('Actions'), cell: ({ row }) => { const model = row.original.model const isTestingModel = testingModels.has(model) @@ -406,7 +453,7 @@ export function ChannelTestDialog({ {isTestingModel && ( )} - Test + {t('Test')} ) }, @@ -443,160 +490,369 @@ export function ChannelTestDialog({ } return ( - - - - {t('Test Channel Connection')} - - {t('Test connectivity for:')} {currentRow.name} - - - -
-
-
- - { const itemValue = option.value - return ( - - {t(option.label)} - - ) - })} - - - -

- {t( - 'Override the endpoint used for testing. Leave empty to auto detect.' - )} -

-
-
- -
- - - {isStreamTest ? t('Enabled') : t('Disabled')} - + return { value: itemValue, label: t(option.label) } + }), + ]} + value={endpointType} + onValueChange={(v) => v !== null && setEndpointType(v)} + > + + + + + + {endpointTypeOptions.map((option) => { + const itemValue = option.value + return ( + + {t(option.label)} + + ) + })} + + + +

+ {t( + 'Override the endpoint used for testing. Leave empty to auto detect.' + )} +

-

- {t('Enable streaming mode for the test request.')} -

-
-
- -
-
-
-

{t('Channel models')}

+
+ +
+ + + {isStreamTest ? t('Enabled') : t('Disabled')} + +

- {t('Select models to run batch tests.')} + {t('Enable streaming mode for the test request.')}

- setSearchTerm(e.target.value)} - className='sm:w-64' - />
-
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() +
+
+
+

{t('Channel models')}

+

+ {t('Select models to run batch tests.')} +

+
+ setSearchTerm(e.target.value)} + className='sm:w-64' + /> +
+ +
+
+
+
+ + + + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + ))} - )) - ) : ( - - - {models.length - ? 'No models matched your search.' - : 'This channel has no configured models.'} - - - )} - -
+ ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {models.length + ? 'No models matched your search.' + : 'This channel has no configured models.'} + + + )} + + +
+ +
- +
- -
-
- - + +
+
+ { + if (!sheetOpen) { + setFailureDetails(null) + } + }} + /> + + ) +} + +function TestStatusCell({ + result, + model, + onOpenDetails, +}: { + result?: TestResult + model: string + onOpenDetails: (details: FailureDetailsState) => void +}) { + const { t } = useTranslation() + + if (!result || result.status === 'idle') { + return ( + + ) + } + + if (result.status === 'testing') { + return ( +
+ + {t('Testing...')} +
+ ) + } + + if (result.status === 'success') { + return ( +
+ + {typeof result.responseTime === 'number' && ( + + {formatResponseTime(result.responseTime, t)} + + )} +
+ ) + } + + return ( + + ) +} + +function FailureStatusContent({ + result, + model, + onOpenDetails, +}: { + result: TestResult + model: string + onOpenDetails: (details: FailureDetailsState) => void +}) { + const { t } = useTranslation() + const errorText = result.error?.trim() + const isModelPriceError = result.errorCode === MODEL_PRICE_ERROR_CODE + const modelPriceSummary = t( + 'Model price is not configured. Please complete model pricing in settings.' + ) + const { summary, details } = getFailureStatusDisplay({ + errorText, + fallbackSummary: t('Test failed'), + isModelPriceError, + modelPriceSummary, + }) + + return ( +
+ +

+ {summary} +

+
+ {isModelPriceError && ( + - - - + )} + {details && ( + + )} +
+
+ ) +} + +function FailureDetailsSheet({ + details, + onOpenChange, +}: { + details: FailureDetailsState | null + onOpenChange: (open: boolean) => void +}) { + const { t } = useTranslation() + const isMobile = useIsMobile() + const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false }) + + return ( + + + {details && ( + <> + + {t('Details')} + + {details.model} + + +
+
+
+ {t('Model')} +
+

{details.model}

+
+
+
+ {t('Failed')} +
+

+ {details.summary} +

+
+
+
+ {t('Details')} +
+
+                  {details.details}
+                
+
+
+ + + + + )} +
+
) } @@ -615,8 +871,8 @@ function TestModelsBulkActions({ const buttonLabel = selectedModels.length > 0 - ? `Test ${selectedModels.length} selected` - : 'Test selected models' + ? t('Test {{count}} selected', { count: selectedModels.length }) + : t('Test selected models') return ( diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 1560b01992de..ab736093e2ff 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2363,6 +2363,7 @@ "Model performance metrics": "Model performance metrics", "Model Price": "Model Price", "Model Price Not Configured": "Model Price Not Configured", + "Model price is not configured. Please complete model pricing in settings.": "Model price is not configured. Please complete model pricing in settings.", "Model prices": "Model prices", "Model prices reset successfully": "Model prices reset successfully", "Model Pricing": "Model Pricing", @@ -3512,6 +3513,7 @@ "Select all (filtered)": "Select all (filtered)", "Select all models": "Select all models", "Select All Visible": "Select All Visible", + "Select model {{model}}": "Select model {{model}}", "Select an operation mode and enter the amount": "Select an operation mode and enter the amount", "Select announcement type": "Select announcement type", "Select at least one field to overwrite.": "Select at least one field to overwrite.", @@ -3849,6 +3851,8 @@ "Templates appended": "Templates appended", "Tencent": "Tencent", "Termination requested": "Termination requested", + "Test": "Test", + "Test {{count}} selected": "Test {{count}} selected", "Test All Channels": "Test All Channels", "Test Channel Connection": "Test Channel Connection", "Test Connection": "Test Connection", @@ -3859,6 +3863,7 @@ "Test Mode": "Test Mode", "Test Model": "Test Model", "Test models and prompts from the browser": "Test models and prompts from the browser", + "Test selected models": "Test selected models", "Testing all enabled channels started. Please refresh to see results.": "Testing all enabled channels started. Please refresh to see results.", "Testing...": "Testing...", "Text": "Text", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 8b555700a150..7f1aae947347 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2363,6 +2363,7 @@ "Model performance metrics": "Indicateurs de performance des modèles", "Model Price": "Prix du modèle", "Model Price Not Configured": "Prix du modèle non configuré", + "Model price is not configured. Please complete model pricing in settings.": "Le prix du modèle n'est pas configuré. Veuillez compléter la tarification du modèle dans les paramètres.", "Model prices": "Prix des modèles", "Model prices reset successfully": "Prix des modèles réinitialisés avec succès", "Model Pricing": "Tarification des modèles", @@ -3512,6 +3513,7 @@ "Select all (filtered)": "Tout sélectionner (filtré)", "Select all models": "Sélectionner tous les modèles", "Select All Visible": "Sélectionner tout ce qui est visible", + "Select model {{model}}": "Sélectionner le modèle {{model}}", "Select an operation mode and enter the amount": "Sélectionnez un mode d'opération et entrez le montant", "Select announcement type": "Sélectionner le type d'annonce", "Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.", @@ -3849,6 +3851,8 @@ "Templates appended": "Modèles ajoutés", "Tencent": "Tencent", "Termination requested": "Arrêt demandé", + "Test": "Tester", + "Test {{count}} selected": "Tester {{count}} sélectionné(s)", "Test All Channels": "Tester tous les canaux", "Test Channel Connection": "Tester la connexion du canal", "Test Connection": "Tester la connexion", @@ -3859,6 +3863,7 @@ "Test Mode": "Mode test", "Test Model": "Tester le modèle", "Test models and prompts from the browser": "Tester les modèles et les prompts depuis le navigateur", + "Test selected models": "Tester les modèles sélectionnés", "Testing all enabled channels started. Please refresh to see results.": "Test de tous les canaux activés démarré. Veuillez actualiser pour voir les résultats.", "Testing...": "Test en cours...", "Text": "Texte", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 778867e9428e..c317c61264f3 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2363,6 +2363,7 @@ "Model performance metrics": "モデル性能メトリクス", "Model Price": "モデル価格", "Model Price Not Configured": "モデル価格が未設定", + "Model price is not configured. Please complete model pricing in settings.": "モデル価格が未設定です。設定でモデル料金を補完してください。", "Model prices": "モデル価格", "Model prices reset successfully": "モデル価格が正常にリセットされました", "Model Pricing": "モデル料金", @@ -3512,6 +3513,7 @@ "Select all (filtered)": "フィルタ結果をすべて選択(S)", "Select all models": "すべてのモデルを選択", "Select All Visible": "表示中のすべてを選択", + "Select model {{model}}": "モデル {{model}} を選択", "Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください", "Select announcement type": "アナウンスメントタイプを選択", "Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。", @@ -3849,6 +3851,8 @@ "Templates appended": "テンプレートが追加されました", "Tencent": "テンセント", "Termination requested": "終了リクエスト済み", + "Test": "テスト", + "Test {{count}} selected": "選択済み {{count}} 件をテスト", "Test All Channels": "すべてのチャネルをテスト", "Test Channel Connection": "チャネル接続をテスト", "Test Connection": "接続をテスト", @@ -3859,6 +3863,7 @@ "Test Mode": "テストモード", "Test Model": "モデルをテスト", "Test models and prompts from the browser": "ブラウザでモデルとプロンプトをテスト", + "Test selected models": "選択したモデルをテスト", "Testing all enabled channels started. Please refresh to see results.": "有効な全チャネルのテストを開始しました。結果を確認するにはページを更新してください。", "Testing...": "テスト中...", "Text": "テキスト", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 88ae65b07189..baa9f2909fbd 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2363,6 +2363,7 @@ "Model performance metrics": "Метрики производительности моделей", "Model Price": "Цена модели", "Model Price Not Configured": "Цена модели не настроена", + "Model price is not configured. Please complete model pricing in settings.": "Цена модели не настроена. Заполните тарификацию модели в настройках.", "Model prices": "Цены моделей", "Model prices reset successfully": "Цены моделей успешно сброшены", "Model Pricing": "Тарификация моделей", @@ -3512,6 +3513,7 @@ "Select all (filtered)": "& Выбрать все отфильтрованные", "Select all models": "Выбрать все модели", "Select All Visible": "Выбрать все видимые", + "Select model {{model}}": "Выбрать модель {{model}}", "Select an operation mode and enter the amount": "Выберите режим операции и введите сумму", "Select announcement type": "Выбрать тип объявления", "Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.", @@ -3849,6 +3851,8 @@ "Templates appended": "Шаблоны добавлены", "Tencent": "Tencent", "Termination requested": "Запрошено завершение", + "Test": "Проверить", + "Test {{count}} selected": "Проверить {{count}} выбранных", "Test All Channels": "Проверить все каналы", "Test Channel Connection": "Проверить подключение канала", "Test Connection": "Проверить подключение", @@ -3859,6 +3863,7 @@ "Test Mode": "Тестовый режим", "Test Model": "Проверить модель", "Test models and prompts from the browser": "Тестируйте модели и промпты в браузере", + "Test selected models": "Проверить выбранные модели", "Testing all enabled channels started. Please refresh to see results.": "Тестирование всех включенных каналов начато. Пожалуйста, обновите страницу, чтобы увидеть результаты.", "Testing...": "Тестирование...", "Text": "Текст", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 5e2e32001b2f..2bc63aae3298 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2363,6 +2363,7 @@ "Model performance metrics": "Chỉ số hiệu năng mô hình", "Model Price": "Giá mô hình", "Model Price Not Configured": "Giá mô hình chưa được cấu hình", + "Model price is not configured. Please complete model pricing in settings.": "Giá mô hình chưa được cấu hình. Vui lòng hoàn tất định giá mô hình trong cài đặt.", "Model prices": "Giá mô hình", "Model prices reset successfully": "Đã đặt lại giá mô hình thành công", "Model Pricing": "Định giá mô hình", @@ -3512,6 +3513,7 @@ "Select all (filtered)": "Chọn tất cả (đã lọc)", "Select all models": "Chọn tất cả mô hình", "Select All Visible": "Chọn tất cả hiển thị", + "Select model {{model}}": "Chọn mô hình {{model}}", "Select an operation mode and enter the amount": "Chọn chế độ thao tác và nhập số tiền", "Select announcement type": "Select notification type", "Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.", @@ -3849,6 +3851,8 @@ "Templates appended": "Đã thêm mẫu", "Tencent": "Tencent", "Termination requested": "Yêu cầu chấm dứt", + "Test": "Kiểm tra", + "Test {{count}} selected": "Kiểm tra {{count}} mục đã chọn", "Test All Channels": "Kiểm tra tất cả các kênh", "Test Channel Connection": "Check channel connection", "Test Connection": "Kiểm tra kết nối", @@ -3859,6 +3863,7 @@ "Test Mode": "Chế độ thử nghiệm", "Test Model": "Kiểm tra Mô hình", "Test models and prompts from the browser": "Kiểm thử mô hình và prompt trong trình duyệt", + "Test selected models": "Kiểm tra các mô hình đã chọn", "Testing all enabled channels started. Please refresh to see results.": "Bắt đầu kiểm tra tất cả các kênh đã kích hoạt. Vui lòng làm mới để xem kết quả.", "Testing...": "Đang kiểm tra...", "Text": "Văn bản", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 6da35355a926..d1c5a906d47f 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2363,6 +2363,7 @@ "Model performance metrics": "模型性能指标", "Model Price": "模型价格", "Model Price Not Configured": "模型价格未配置", + "Model price is not configured. Please complete model pricing in settings.": "模型价格未配置,请前往设置补充模型价格。", "Model prices": "模型价格", "Model prices reset successfully": "模型价格重置成功", "Model Pricing": "模型定价", @@ -3512,6 +3513,7 @@ "Select all (filtered)": "全选(筛选结果)", "Select all models": "选择所有模型", "Select All Visible": "全选当前", + "Select model {{model}}": "选择模型 {{model}}", "Select an operation mode and enter the amount": "选择操作模式并输入金额", "Select announcement type": "选择公告类型", "Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。", @@ -3849,6 +3851,8 @@ "Templates appended": "模板已追加", "Tencent": "腾讯", "Termination requested": "终止请求", + "Test": "测试", + "Test {{count}} selected": "测试 {{count}} 个已选择项", "Test All Channels": "测试所有渠道", "Test Channel Connection": "测试渠道连接", "Test Connection": "测试连接", @@ -3859,6 +3863,7 @@ "Test Mode": "测试模式", "Test Model": "测试模型", "Test models and prompts from the browser": "在浏览器中测试模型和提示词", + "Test selected models": "测试所选模型", "Testing all enabled channels started. Please refresh to see results.": "测试所有启用的通道已开始。请刷新以查看结果。", "Testing...": "测试中...", "Text": "文本", From 6f11d19877a1e58884a7947309d1f1f2a84fafff Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 21 May 2026 11:10:22 +0800 Subject: [PATCH 018/400] fix: normalize model pricing display drift (#4985) --- .../models/model-pricing-sheet.tsx | 17 ++---- .../models/model-ratio-visual-editor.tsx | 7 +-- .../system-settings/models/pricing-format.ts | 61 +++++++++++++++++++ 3 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 web/default/src/features/system-settings/models/pricing-format.ts diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index 63562f0f2937..18a8739c74e2 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -65,6 +65,7 @@ import { import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' +import { formatPricingNumber } from './pricing-format' import { TieredPricingEditor } from './tiered-pricing-editor' const createModelPricingSchema = (t: (key: string) => string) => @@ -216,16 +217,10 @@ function toNumberOrNull(value: unknown): number | null { return Number.isFinite(num) ? num : null } -function formatNumber(value: unknown): string { - const num = toNumberOrNull(value) - if (num === null) return '' - return Number.parseFloat(num.toFixed(12)).toString() -} - function ratioToBasePrice(ratio: unknown): string { const num = toNumberOrNull(ratio) if (num === null) return '' - return formatNumber(num * 2) + return formatPricingNumber(num * 2) } function deriveLanePrice( @@ -236,7 +231,7 @@ function deriveLanePrice( const ratioNumber = toNumberOrNull(ratio) const denominatorNumber = toNumberOrNull(denominator) if (ratioNumber === null || denominatorNumber === null) return fallback - return formatNumber(ratioNumber * denominatorNumber) + return formatPricingNumber(ratioNumber * denominatorNumber) } function createInitialLaneState(data?: ModelRatioData | null) { @@ -513,12 +508,12 @@ export function ModelPricingEditorPanel({ if (lane === 'audioOutput') { const audioInputPrice = toNumberOrNull(nextLanePrices.audioInput) if (audioInputPrice === null || audioInputPrice === 0) return '' - return formatNumber(priceNumber / audioInputPrice) + return formatPricingNumber(priceNumber / audioInputPrice) } const inputPrice = toNumberOrNull(nextPromptPrice) if (inputPrice === null || inputPrice === 0) return '' - return formatNumber(priceNumber / inputPrice) + return formatPricingNumber(priceNumber / inputPrice) } const syncLaneRatios = ( @@ -529,7 +524,7 @@ export function ModelPricingEditorPanel({ const inputPrice = toNumberOrNull(nextPromptPrice) setFormValue( 'ratio', - inputPrice !== null ? formatNumber(inputPrice / 2) : '' + inputPrice !== null ? formatPricingNumber(inputPrice / 2) : '' ) laneConfigs.forEach(({ key }) => { diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index d2e70bb9c3ae..19d11af9d6ec 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -65,6 +65,7 @@ import { ModelPricingSheet, type ModelRatioData, } from './model-pricing-sheet' +import { formatPricingNumber } from './pricing-format' type ModelRatioVisualEditorProps = { modelPrice: string @@ -106,15 +107,11 @@ const toNumberOrNull = (value?: string) => { return Number.isFinite(num) ? num : null } -const formatPrice = (value: number) => { - return Number.parseFloat(value.toFixed(12)).toString() -} - const ratioToPrice = (ratio?: string, denominator?: string) => { const ratioNumber = toNumberOrNull(ratio) const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2 if (ratioNumber === null || denominatorNumber === null) return '' - return formatPrice(ratioNumber * denominatorNumber) + return formatPricingNumber(ratioNumber * denominatorNumber) } const filterBySelectedValues = ( diff --git a/web/default/src/features/system-settings/models/pricing-format.ts b/web/default/src/features/system-settings/models/pricing-format.ts new file mode 100644 index 000000000000..18f4b9f2a9dc --- /dev/null +++ b/web/default/src/features/system-settings/models/pricing-format.ts @@ -0,0 +1,61 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +const DISPLAY_DECIMALS = 12 +const SNAP_DECIMALS = 8 +const SNAP_EPSILON = 1e-12 + +function toNumberOrNull(value: unknown): number | null { + if ( + value === '' || + value === null || + value === undefined || + value === false + ) { + return null + } + + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function roundToDecimals(value: number, decimals: number): number { + const factor = 10 ** decimals + return Math.round(value * factor) / factor +} + +function snapFloatDrift(value: number): number { + const tolerance = Math.max(SNAP_EPSILON, Math.abs(value) * Number.EPSILON * 8) + + for (let decimals = 0; decimals <= SNAP_DECIMALS; decimals += 1) { + const rounded = roundToDecimals(value, decimals) + if (Math.abs(value - rounded) <= tolerance) { + return rounded + } + } + + return value +} + +export function formatPricingNumber(value: unknown): string { + const num = toNumberOrNull(value) + if (num === null) return '' + + const normalized = snapFloatDrift(num) + return Number.parseFloat(normalized.toFixed(DISPLAY_DECIMALS)).toString() +} From 006e80165248f6d80f0bfb64bd09e36c40141a51 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 21 May 2026 11:16:17 +0800 Subject: [PATCH 019/400] fix: resolve model owned_by from active channels (#4416) * fix: resolve model owned_by from active channels * fix: respect token group when resolving model owners --- controller/model.go | 163 ++++++++++++++++++++++-------- controller/model_owned_by_test.go | 85 ++++++++++++++++ model/model_meta.go | 57 +++++++++++ model/model_owner_test.go | 141 ++++++++++++++++++++++++++ model/task_cas_test.go | 2 + 5 files changed, 405 insertions(+), 43 deletions(-) create mode 100644 controller/model_owned_by_test.go create mode 100644 model/model_owner_test.go diff --git a/controller/model.go b/controller/model.go index 4dbd45838dd8..cc2b1effac31 100644 --- a/controller/model.go +++ b/controller/model.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -109,9 +110,102 @@ func init() { }) } -func ListModels(c *gin.Context, modelType int) { - userOpenAiModels := make([]dto.OpenAIModels, 0) +func channelOwnerName(channelType int) string { + apiType, success := common.ChannelType2APIType(channelType) + if !success { + return strings.ToLower(constant.GetChannelTypeName(channelType)) + } + adaptor := relay.GetAdaptor(apiType) + if adaptor == nil { + return strings.ToLower(constant.GetChannelTypeName(channelType)) + } + adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: channelType, + }}) + if name := strings.TrimSpace(adaptor.GetChannelName()); name != "" { + return name + } + return strings.ToLower(constant.GetChannelTypeName(channelType)) +} + +func getPreferredModelOwners(modelNames []string, groups []string) map[string]string { + channelTypes, err := model.GetPreferredModelOwnerChannelTypes(modelNames, groups) + if err != nil { + common.SysLog(fmt.Sprintf("GetPreferredModelOwnerChannelTypes error: %v", err)) + return map[string]string{} + } + + ownerByChannelType := make(map[int]string) + owners := make(map[string]string, len(channelTypes)) + for modelName, channelType := range channelTypes { + owner, ok := ownerByChannelType[channelType] + if !ok { + owner = channelOwnerName(channelType) + ownerByChannelType[channelType] = owner + } + if owner != "" { + owners[modelName] = owner + } + } + return owners +} + +func buildOpenAIModel(modelName string, ownerByModel map[string]string) dto.OpenAIModels { + var oaiModel dto.OpenAIModels + if staticModel, ok := openAIModelsMap[modelName]; ok { + oaiModel = staticModel + } else { + oaiModel = dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: "custom", + } + } + if owner, ok := ownerByModel[modelName]; ok && owner != "" { + oaiModel.OwnedBy = owner + } + oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName) + return oaiModel +} + +type modelListGroups struct { + userGroup string + tokenGroup string + ownerGroups []string +} +func getModelListGroups(c *gin.Context) (modelListGroups, error) { + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + if userGroup == "" && (tokenGroup == "" || tokenGroup == "auto") { + var err error + userGroup, err = model.GetUserGroup(c.GetInt("id"), false) + if err != nil { + return modelListGroups{}, err + } + } + + if tokenGroup == "auto" { + return modelListGroups{ + userGroup: userGroup, + tokenGroup: tokenGroup, + ownerGroups: service.GetUserAutoGroup(userGroup), + }, nil + } + + group := userGroup + if tokenGroup != "" { + group = tokenGroup + } + return modelListGroups{ + userGroup: userGroup, + tokenGroup: tokenGroup, + ownerGroups: []string{group}, + }, nil +} + +func ListModels(c *gin.Context, modelType int) { acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled if !acceptUnsetRatioModel { userId := c.GetInt("id") @@ -123,6 +217,16 @@ func ListModels(c *gin.Context, modelType int) { } } + userModelNames := make([]string, 0) + groups, err := getModelListGroups(c) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "get user group failed", + }) + return + } + ownerGroups := groups.ownerGroups modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) if modelLimitEnable { s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) @@ -138,37 +242,12 @@ func ListModels(c *gin.Context, modelType int) { continue } } - if oaiModel, ok := openAIModelsMap[allowModel]; ok { - oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel) - userOpenAiModels = append(userOpenAiModels, oaiModel) - } else { - userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{ - Id: allowModel, - Object: "model", - Created: 1626777600, - OwnedBy: "custom", - SupportedEndpointTypes: model.GetModelSupportEndpointTypes(allowModel), - }) - } + userModelNames = append(userModelNames, allowModel) } } else { - userId := c.GetInt("id") - userGroup, err := model.GetUserGroup(userId, false) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "get user group failed", - }) - return - } - group := userGroup - tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) - if tokenGroup != "" { - group = tokenGroup - } var models []string - if tokenGroup == "auto" { - for _, autoGroup := range service.GetUserAutoGroup(userGroup) { + if groups.tokenGroup == "auto" { + for _, autoGroup := range ownerGroups { groupModels := model.GetGroupEnabledModels(autoGroup) for _, g := range groupModels { if !common.StringsContains(models, g) { @@ -177,7 +256,7 @@ func ListModels(c *gin.Context, modelType int) { } } } else { - models = model.GetGroupEnabledModels(group) + models = model.GetGroupEnabledModels(ownerGroups[0]) } for _, modelName := range models { if !acceptUnsetRatioModel { @@ -185,21 +264,19 @@ func ListModels(c *gin.Context, modelType int) { continue } } - if oaiModel, ok := openAIModelsMap[modelName]; ok { - oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName) - userOpenAiModels = append(userOpenAiModels, oaiModel) - } else { - userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{ - Id: modelName, - Object: "model", - Created: 1626777600, - OwnedBy: "custom", - SupportedEndpointTypes: model.GetModelSupportEndpointTypes(modelName), - }) - } + userModelNames = append(userModelNames, modelName) } } + ownerByModel := map[string]string{} + if len(ownerGroups) > 0 { + ownerByModel = getPreferredModelOwners(userModelNames, ownerGroups) + } + userOpenAiModels := make([]dto.OpenAIModels, 0, len(userModelNames)) + for _, modelName := range userModelNames { + userOpenAiModels = append(userOpenAiModels, buildOpenAIModel(modelName, ownerByModel)) + } + switch modelType { case constant.ChannelTypeAnthropic: useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels)) diff --git a/controller/model_owned_by_test.go b/controller/model_owned_by_test.go new file mode 100644 index 000000000000..bc2ef32f135c --- /dev/null +++ b/controller/model_owned_by_test.go @@ -0,0 +1,85 @@ +package controller + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestChannelOwnerNameUsesAdaptorChannelName(t *testing.T) { + tests := []struct { + name string + channelType int + expected string + }{ + { + name: "openai", + channelType: constant.ChannelTypeOpenAI, + expected: "openai", + }, + { + name: "codex", + channelType: constant.ChannelTypeCodex, + expected: "codex", + }, + { + name: "openrouter", + channelType: constant.ChannelTypeOpenRouter, + expected: "openrouter", + }, + { + name: "azure fallback", + channelType: constant.ChannelTypeAzure, + expected: "azure", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, channelOwnerName(tt.channelType)) + }) + } +} + +func TestBuildOpenAIModelOverridesOwnedBy(t *testing.T) { + modelItem := buildOpenAIModel("gpt-5.4", map[string]string{"gpt-5.4": "openai"}) + require.Equal(t, "gpt-5.4", modelItem.Id) + require.Equal(t, "openai", modelItem.OwnedBy) +} + +func TestBuildOpenAIModelFallsBackToCustomForUnknownModels(t *testing.T) { + modelItem := buildOpenAIModel("custom-test-model", nil) + require.Equal(t, "custom-test-model", modelItem.Id) + require.Equal(t, "custom", modelItem.OwnedBy) +} + +func TestGetModelListGroupsUsesUserGroupWhenTokenGroupIsEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + + groups, err := getModelListGroups(ctx) + require.NoError(t, err) + + require.Equal(t, "default", groups.userGroup) + require.Empty(t, groups.tokenGroup) + require.Equal(t, []string{"default"}, groups.ownerGroups) +} + +func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "vip") + + groups, err := getModelListGroups(ctx) + require.NoError(t, err) + + require.Equal(t, "default", groups.userGroup) + require.Equal(t, "vip", groups.tokenGroup) + require.Equal(t, []string{"vip"}, groups.ownerGroups) +} diff --git a/model/model_meta.go b/model/model_meta.go index 860b96024196..864212771624 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -2,6 +2,7 @@ package model import ( "strconv" + "strings" "github.com/QuantumNous/new-api/common" @@ -135,6 +136,62 @@ func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel return result, nil } +func normalizeLookupValues(values []string) []string { + seen := make(map[string]struct{}, len(values)) + normalized := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + return normalized +} + +func GetPreferredModelOwnerChannelTypes(modelNames []string, groups []string) (map[string]int, error) { + result := make(map[string]int) + modelNames = normalizeLookupValues(modelNames) + if len(modelNames) == 0 { + return result, nil + } + + type row struct { + Model string + ChannelType int + } + var rows []row + + query := DB.Table("abilities"). + Select("abilities.model as model, channels.type as channel_type"). + Joins("JOIN channels ON abilities.channel_id = channels.id"). + Where("abilities.model IN ? AND abilities.enabled = ? AND channels.status = ?", modelNames, true, common.ChannelStatusEnabled). + Order("COALESCE(abilities.priority, 0) DESC"). + Order("abilities.weight DESC"). + Order("abilities.channel_id ASC") + + groups = normalizeLookupValues(groups) + if len(groups) > 0 { + query = query.Where("abilities."+commonGroupCol+" IN ?", groups) + } + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + for _, r := range rows { + if _, ok := result[r.Model]; ok { + continue + } + result[r.Model] = r.ChannelType + } + return result, nil +} + func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) { var models []*Model db := DB.Model(&Model{}) diff --git a/model/model_owner_test.go b/model/model_owner_test.go new file mode 100644 index 000000000000..887663175730 --- /dev/null +++ b/model/model_owner_test.go @@ -0,0 +1,141 @@ +package model + +import ( + "fmt" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/require" +) + +func clearPreferredOwnerTables(t *testing.T) { + t.Helper() + require.NoError(t, DB.Exec("DELETE FROM abilities").Error) + require.NoError(t, DB.Exec("DELETE FROM channels").Error) +} + +func insertPreferredOwnerCandidate( + t *testing.T, + channelID int, + modelName string, + group string, + channelType int, + priority int64, + weight uint, + channelStatus int, + abilityEnabled bool, +) { + t.Helper() + require.NoError(t, DB.Create(&Channel{ + Id: channelID, + Type: channelType, + Key: fmt.Sprintf("key-%d", channelID), + Status: channelStatus, + Name: fmt.Sprintf("channel-%d", channelID), + }).Error) + require.NoError(t, DB.Create(&Ability{ + Group: group, + Model: modelName, + ChannelId: channelID, + Enabled: abilityEnabled, + Priority: &priority, + Weight: weight, + }).Error) +} + +func TestGetPreferredModelOwnerChannelTypes(t *testing.T) { + const modelName = "gpt-5.4" + + tests := []struct { + name string + setup func(t *testing.T) + groups []string + expected int + found bool + }{ + { + name: "openai only", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 0, 0, common.ChannelStatusEnabled, true) + }, + groups: []string{"default"}, + expected: constant.ChannelTypeOpenAI, + found: true, + }, + { + name: "codex only", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 0, 0, common.ChannelStatusEnabled, true) + }, + groups: []string{"default"}, + expected: constant.ChannelTypeCodex, + found: true, + }, + { + name: "priority wins", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 100, common.ChannelStatusEnabled, true) + insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 2, 0, common.ChannelStatusEnabled, true) + }, + groups: []string{"default"}, + expected: constant.ChannelTypeCodex, + found: true, + }, + { + name: "weight wins when priority is equal", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true) + insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 20, common.ChannelStatusEnabled, true) + }, + groups: []string{"default"}, + expected: constant.ChannelTypeCodex, + found: true, + }, + { + name: "channel id stabilizes exact ties", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 10, common.ChannelStatusEnabled, true) + insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true) + }, + groups: []string{"default"}, + expected: constant.ChannelTypeOpenAI, + found: true, + }, + { + name: "group filter excludes other groups", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 1, modelName, "vip", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, true) + insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusEnabled, true) + }, + groups: []string{"default"}, + expected: constant.ChannelTypeOpenAI, + found: true, + }, + { + name: "disabled candidates are ignored", + setup: func(t *testing.T) { + insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, false) + insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusManuallyDisabled, true) + }, + groups: []string{"default"}, + found: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearPreferredOwnerTables(t) + tt.setup(t) + + owners, err := GetPreferredModelOwnerChannelTypes([]string{modelName}, tt.groups) + require.NoError(t, err) + + got, ok := owners[modelName] + require.Equal(t, tt.found, ok) + if tt.found { + require.Equal(t, tt.expected, got) + } + }) + } +} diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 29455e3a2a01..052bf638f989 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -40,6 +40,7 @@ func TestMain(m *testing.M) { &Token{}, &Log{}, &Channel{}, + &Ability{}, &TopUp{}, &SubscriptionPlan{}, &SubscriptionOrder{}, @@ -60,6 +61,7 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM tokens") DB.Exec("DELETE FROM logs") DB.Exec("DELETE FROM channels") + DB.Exec("DELETE FROM abilities") DB.Exec("DELETE FROM top_ups") DB.Exec("DELETE FROM subscription_orders") DB.Exec("DELETE FROM subscription_plans") From ae6a03364d1e73d46d8e8f03a6c1805e558584c6 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Fri, 22 May 2026 10:32:11 +0800 Subject: [PATCH 020/400] perf: optimize request metadata extraction and disabled field filtering (#5009) * perf: optimize request metadata extraction and disabled field filtering * perf: optimize stream usage estimation path --- middleware/distributor.go | 54 +++++++++++++++++++ relay/channel/openai/helper.go | 77 +++++----------------------- relay/channel/openai/relay-openai.go | 11 ++-- relay/common/override_test.go | 11 ++++ relay/common/relay_info.go | 23 +++++++++ 5 files changed, 105 insertions(+), 71 deletions(-) diff --git a/middleware/distributor.go b/middleware/distributor.go index 2263fae3fae5..771719b98b01 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -3,6 +3,7 @@ package middleware import ( "errors" "fmt" + "io" "net/http" "slices" "strconv" @@ -20,6 +21,7 @@ import ( "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" ) type ModelRequest struct { @@ -170,6 +172,14 @@ func Distribute() func(c *gin.Context) { // - application/x-www-form-urlencoded // - multipart/form-data func getModelFromRequest(c *gin.Context) (*ModelRequest, error) { + if strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json") { + modelRequest, err := getModelFromJSONBody(c) + if err != nil { + return nil, errors.New(i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) + } + return modelRequest, nil + } + var modelRequest ModelRequest err := common.UnmarshalBodyReusable(c, &modelRequest) if err != nil { @@ -178,6 +188,50 @@ func getModelFromRequest(c *gin.Context) (*ModelRequest, error) { return &modelRequest, nil } +func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) { + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + requestBody, err := storage.Bytes() + if err != nil { + return nil, err + } + if !gjson.ValidBytes(requestBody) { + return nil, errors.New("invalid JSON request body") + } + + values := gjson.GetManyBytes(requestBody, "model", "group") + model, err := getJSONStringValue(values[0], "model") + if err != nil { + return nil, err + } + group, err := getJSONStringValue(values[1], "group") + if err != nil { + return nil, err + } + + if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil { + return nil, seekErr + } + c.Request.Body = io.NopCloser(storage) + + return &ModelRequest{ + Model: model, + Group: group, + }, nil +} + +func getJSONStringValue(result gjson.Result, field string) (string, error) { + if !result.Exists() || result.Type == gjson.Null { + return "", nil + } + if result.Type != gjson.String { + return "", fmt.Errorf("field %s must be a string", field) + } + return result.String(), nil +} + func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { var modelRequest ModelRequest shouldSelectChannel := true diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 08811a77205a..1a01d06da6dc 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -1,7 +1,6 @@ package openai import ( - "encoding/json" "strings" "github.com/QuantumNous/new-api/common" @@ -92,78 +91,28 @@ func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, res return nil } -func processTokens(relayMode int, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error { - streamResp := "[" + strings.Join(streamItems, ",") + "]" - +func processTokenData(relayMode int, data string, responseTextBuilder *strings.Builder, toolCount *int) error { switch relayMode { case relayconstant.RelayModeChatCompletions: - return processChatCompletions(streamResp, streamItems, responseTextBuilder, toolCount) - case relayconstant.RelayModeCompletions: - return processCompletions(streamResp, streamItems, responseTextBuilder) - } - return nil -} - -func processChatCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error { - var streamResponses []dto.ChatCompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil { - // 一次性解析失败,逐个解析 - common.SysLog("error unmarshalling stream response: " + err.Error()) - for _, item := range streamItems { - var streamResponse dto.ChatCompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil { - return err - } - if err := ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount); err != nil { - common.SysLog("error processing stream response: " + err.Error()) - } + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil { + return err } - return nil - } - - // 批量处理所有响应 - for _, streamResponse := range streamResponses { - for _, choice := range streamResponse.Choices { - responseTextBuilder.WriteString(choice.Delta.GetContentString()) - responseTextBuilder.WriteString(choice.Delta.GetReasoningContent()) - if choice.Delta.ToolCalls != nil { - if len(choice.Delta.ToolCalls) > *toolCount { - *toolCount = len(choice.Delta.ToolCalls) - } - for _, tool := range choice.Delta.ToolCalls { - responseTextBuilder.WriteString(tool.Function.Name) - responseTextBuilder.WriteString(tool.Function.Arguments) - } - } + return ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount) + case relayconstant.RelayModeCompletions: + var streamResponse dto.CompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil { + return err } + processCompletionsStreamResponse(streamResponse, responseTextBuilder) } return nil } -func processCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder) error { - var streamResponses []dto.CompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil { - // 一次性解析失败,逐个解析 - common.SysLog("error unmarshalling stream response: " + err.Error()) - for _, item := range streamItems { - var streamResponse dto.CompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil { - continue - } - for _, choice := range streamResponse.Choices { - responseTextBuilder.WriteString(choice.Text) - } - } - return nil - } - - // 批量处理所有响应 - for _, streamResponse := range streamResponses { - for _, choice := range streamResponse.Choices { - responseTextBuilder.WriteString(choice.Text) - } +func processCompletionsStreamResponse(streamResponse dto.CompletionsStreamResponse, responseTextBuilder *strings.Builder) { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Text) } - return nil } func handleLastResponse(lastStreamData string, responseId *string, createAt *int64, diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index c21d4399304d..d6a354f71a22 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -119,7 +119,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re var responseTextBuilder strings.Builder var toolCount int var usage = &dto.Usage{} - var streamItems []string // store stream items var lastStreamData string var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型 @@ -140,7 +139,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re } lastStreamData = data - streamItems = append(streamItems, data) + if err := processTokenData(info.RelayMode, data, &responseTextBuilder, &toolCount); err != nil { + logger.LogError(c, "error processing stream token data: "+err.Error()) + sr.Error(err) + } } }) @@ -175,11 +177,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re } } - // 处理token计算 - if err := processTokens(info.RelayMode, streamItems, &responseTextBuilder, &toolCount); err != nil { - logger.LogError(c, "error processing tokens: "+err.Error()) - } - if !containStreamUsage { usage = service.ResponseText2Usage(c, responseTextBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) usage.CompletionTokens += toolCount * 7 diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 8c7b77723014..79688113cef5 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -2054,6 +2054,17 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) { assertJSONEqual(t, `{"cache_control":{"type":"ephemeral"},"store":true}`, string(out)) } +func TestRemoveDisabledFieldsNoControlledFieldsKeepsBody(t *testing.T) { + input := `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}` + settings := dto.ChannelOtherSettings{} + + out, err := RemoveDisabledFields([]byte(input), settings, false) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + require.Equal(t, input, string(out)) +} + func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) { input := `{ "inference_geo":"eu", diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 64d4d4eedfaa..8a6c471e27d7 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/tidwall/gjson" ) type ThinkingContentInfo struct { @@ -785,6 +786,9 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelPassThroughEnabled { return jsonData, nil } + if !hasRemovableDisabledField(jsonData, channelOtherSettings) { + return jsonData, nil + } var data map[string]interface{} if err := common.Unmarshal(jsonData, &data); err != nil { @@ -851,6 +855,25 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther return jsonDataAfter, nil } +func hasRemovableDisabledField(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) bool { + values := gjson.GetManyBytes( + jsonData, + "service_tier", + "inference_geo", + "speed", + "store", + "safety_identifier", + "stream_options.include_obfuscation", + ) + + return (!channelOtherSettings.AllowServiceTier && values[0].Exists()) || + (!channelOtherSettings.AllowInferenceGeo && values[1].Exists()) || + (!channelOtherSettings.AllowSpeed && values[2].Exists()) || + (channelOtherSettings.DisableStore && values[3].Exists()) || + (!channelOtherSettings.AllowSafetyIdentifier && values[4].Exists()) || + (!channelOtherSettings.AllowIncludeObfuscation && values[5].Exists()) +} + // RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data // Currently supports removing functionResponse.id field which Vertex AI does not support func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) { From e13d6734549c70ec029d7c84c2c7dc5318d4d93f Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Fri, 22 May 2026 10:36:50 +0800 Subject: [PATCH 021/400] fix: update default frontend hardcoded route links (#5016) --- .../src/features/channels/components/channels-columns.tsx | 2 +- .../channels/components/dialogs/channel-test-dialog.tsx | 4 +++- .../src/features/playground/components/message-error.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index ee6a5d36c6ac..8747c862a85b 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -689,7 +689,7 @@ export function useChannelsColumns(): ColumnDef[] { onClick={(e) => { e.stopPropagation() if (!deploymentId) return - const targetUrl = `/console/deployment?deployment_id=${deploymentId}` + const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}` window.open(targetUrl, '_blank', 'noopener') }} /> diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index c3b93064588b..ff7df559ee28 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -759,7 +759,9 @@ function FailureStatusContent({ variant='outline' size='sm' className='h-7 w-fit px-2 text-xs' - onClick={() => window.open('/console/setting?tab=ratio', '_blank')} + onClick={() => + window.open('/system-settings/billing/model-pricing', '_blank') + } > {t('Go to Settings')} diff --git a/web/default/src/features/playground/components/message-error.tsx b/web/default/src/features/playground/components/message-error.tsx index 1562e86f669e..64967919b039 100644 --- a/web/default/src/features/playground/components/message-error.tsx +++ b/web/default/src/features/playground/components/message-error.tsx @@ -57,7 +57,7 @@ export function MessageError({ message, className = '' }: MessageErrorProps) { variant='outline' size='sm' onClick={() => - window.open('/console/setting?tab=ratio', '_blank') + window.open('/system-settings/billing/model-pricing', '_blank') } > From 8e5e89bb5b54ee1c78c3616feca96555865d286d Mon Sep 17 00:00:00 2001 From: JunXiaoRuo <47996900+JunXiaoRuo@users.noreply.github.com> Date: Fri, 22 May 2026 10:39:24 +0800 Subject: [PATCH 022/400] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=96=B0=E7=89=88=E5=89=8D=E7=AB=AFTurnstile=20=E5=BC=80?= =?UTF-8?q?=E5=90=AF=E5=90=8E=E6=B3=A8=E5=86=8C=E9=A1=B5=E6=9C=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=AA=8C=E8=AF=81=E7=9A=84=E9=97=AE=E9=A2=98=20(#5011?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- .../auth/sign-up/components/sign-up-form.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index b5ebd0b8bc40..6db51347e215 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -148,6 +148,8 @@ export function SignUpForm({ } } + if (!validateTurnstile()) return + setIsLoading(true) try { const res = await register({ @@ -318,18 +320,19 @@ export function SignUpForm({
- {/* Turnstile */} - {isTurnstileEnabled && ( -
- -
- )} )} + {/* Turnstile */} + {isTurnstileEnabled && ( +
+ +
+ )} + Date: Fri, 22 May 2026 11:00:58 +0800 Subject: [PATCH 023/400] =?UTF-8?q?[Feature=20Request]=20Waffo=20Pancake?= =?UTF-8?q?=20gateway=20=E2=80=94=20full=20integration=20with=20subscripti?= =?UTF-8?q?on=20support=20+=20admin=20catalog=20binding=20flow=20(#4935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/option.go | 11 +- controller/payment_webhook_availability.go | 17 +- .../payment_webhook_availability_test.go | 31 +- .../subscription_payment_waffo_pancake.go | 125 +++ controller/topup.go | 41 +- controller/topup_waffo_pancake.go | 332 ++++++- go.mod | 2 + go.sum | 4 + model/main.go | 2 + model/option.go | 57 +- model/subscription.go | 5 +- router/api-router.go | 14 +- service/waffo_pancake.go | 684 ++++++++------ service/waffo_pancake_test.go | 224 +++-- setting/payment_waffo_pancake.go | 23 +- web/classic/public/waffo-logo-dark.svg | 5 + web/classic/public/waffo-logo-light.svg | 5 + .../components/settings/PaymentSetting.jsx | 29 +- .../src/components/topup/RechargeCard.jsx | 17 +- web/classic/src/components/topup/index.jsx | 25 +- web/classic/src/i18n/locales/en.json | 1 + web/classic/src/i18n/locales/zh.json | 1 + .../SettingsPaymentGatewayWaffoPancake.jsx | 263 +----- web/default/public/waffo-logo-dark.svg | 5 + web/default/public/waffo-logo-light.svg | 5 + .../components/layout/components/footer.tsx | 102 +- web/default/src/features/subscriptions/api.ts | 36 + .../dialogs/subscription-purchase-dialog.tsx | 41 +- .../components/subscriptions-columns.tsx | 7 + .../subscriptions-mutate-drawer.tsx | 163 +++- .../features/subscriptions/lib/plan-form.ts | 3 + .../src/features/subscriptions/types.ts | 10 + .../system-settings/billing/index.tsx | 9 +- .../billing/section-registry.tsx | 12 +- .../integrations/payment-settings-section.tsx | 12 +- .../integrations/waffo-pancake-api.ts | 102 ++ .../waffo-pancake-settings-section.tsx | 891 +++++++++++++----- .../integrations/waffo-settings-section.tsx | 20 +- .../src/features/system-settings/types.ts | 11 +- .../components/subscription-plans-card.tsx | 2 + .../wallet/hooks/use-waffo-pancake-payment.ts | 9 +- web/default/src/features/wallet/lib/ui.tsx | 23 +- web/default/src/features/wallet/types.ts | 5 + web/default/src/i18n/locales/en.json | 41 + web/default/src/i18n/locales/zh.json | 41 + 45 files changed, 2407 insertions(+), 1061 deletions(-) create mode 100644 controller/subscription_payment_waffo_pancake.go create mode 100644 web/classic/public/waffo-logo-dark.svg create mode 100644 web/classic/public/waffo-logo-light.svg create mode 100644 web/default/public/waffo-logo-dark.svg create mode 100644 web/default/public/waffo-logo-light.svg create mode 100644 web/default/src/features/system-settings/integrations/waffo-pancake-api.ts diff --git a/controller/option.go b/controller/option.go index 4849bcc675d0..b5fdfdc1515b 100644 --- a/controller/option.go +++ b/controller/option.go @@ -42,15 +42,6 @@ func isPositiveOptionValue(value string) bool { return err == nil && floatValue > 0 } -func isVisiblePublicKeyOption(key string) bool { - switch key { - case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey": - return true - default: - return false - } -} - func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) { if strings.TrimSpace(raw) == "" { return @@ -95,7 +86,7 @@ func GetOptions(c *gin.Context) { strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "secret") || strings.HasSuffix(k, "api_key") - if isSensitiveKey && !isVisiblePublicKeyOption(k) { + if isSensitiveKey { continue } options = append(options, &model.Option{ diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go index 6b16e53f8d4e..aa26e5acf5ae 100644 --- a/controller/payment_webhook_availability.go +++ b/controller/payment_webhook_availability.go @@ -77,24 +77,15 @@ func isWaffoPancakeTopUpEnabled() bool { if !isPaymentComplianceConfirmed() { return false } - if !setting.WaffoPancakeEnabled { - return false - } - - return isWaffoPancakeWebhookConfigured() && - strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" && + // Presence-of-credentials = enabled. Webhook public keys ship inside + // the SDK; mode (test/prod) is read from each event. + return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" && strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" && - strings.TrimSpace(setting.WaffoPancakeStoreID) != "" && strings.TrimSpace(setting.WaffoPancakeProductID) != "" } func isWaffoPancakeWebhookConfigured() bool { - currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) - if setting.WaffoPancakeSandbox { - currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) - } - - return currentWebhookKey != "" + return isWaffoPancakeTopUpEnabled() } func isWaffoPancakeWebhookEnabled() bool { diff --git a/controller/payment_webhook_availability_test.go b/controller/payment_webhook_availability_test.go index f277602b58ea..002428be0bd1 100644 --- a/controller/payment_webhook_availability_test.go +++ b/controller/payment_webhook_availability_test.go @@ -114,47 +114,32 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) - originalEnabled := setting.WaffoPancakeEnabled - originalSandbox := setting.WaffoPancakeSandbox originalMerchantID := setting.WaffoPancakeMerchantID originalPrivateKey := setting.WaffoPancakePrivateKey - originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey - originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey - originalStoreID := setting.WaffoPancakeStoreID originalProductID := setting.WaffoPancakeProductID t.Cleanup(func() { - setting.WaffoPancakeEnabled = originalEnabled - setting.WaffoPancakeSandbox = originalSandbox setting.WaffoPancakeMerchantID = originalMerchantID setting.WaffoPancakePrivateKey = originalPrivateKey - setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey - setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey - setting.WaffoPancakeStoreID = originalStoreID setting.WaffoPancakeProductID = originalProductID }) - setting.WaffoPancakeEnabled = true - setting.WaffoPancakeSandbox = false - setting.WaffoPancakeMerchantID = "merchant" + // Presence of all three credentials enables the gateway. Webhook public + // keys are bundled in the SDK and there is no separate Enabled toggle — + // clear any of the three fields to disable. + setting.WaffoPancakeMerchantID = "" setting.WaffoPancakePrivateKey = "private" - setting.WaffoPancakeStoreID = "store" setting.WaffoPancakeProductID = "product" - setting.WaffoPancakeWebhookPublicKey = "" require.False(t, isWaffoPancakeWebhookEnabled()) - setting.WaffoPancakeWebhookPublicKey = "public" + setting.WaffoPancakeMerchantID = "merchant" require.True(t, isWaffoPancakeWebhookEnabled()) - setting.WaffoPancakeEnabled = false + setting.WaffoPancakeProductID = "" require.False(t, isWaffoPancakeWebhookEnabled()) - setting.WaffoPancakeEnabled = true - setting.WaffoPancakeSandbox = true - setting.WaffoPancakeWebhookTestKey = "" + setting.WaffoPancakeProductID = "product" + setting.WaffoPancakePrivateKey = "" require.False(t, isWaffoPancakeWebhookEnabled()) - - setting.WaffoPancakeWebhookTestKey = "test_public" - require.True(t, isWaffoPancakeWebhookEnabled()) } func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { diff --git a/controller/subscription_payment_waffo_pancake.go b/controller/subscription_payment_waffo_pancake.go new file mode 100644 index 000000000000..5df3d4b679e2 --- /dev/null +++ b/controller/subscription_payment_waffo_pancake.go @@ -0,0 +1,125 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + "github.com/thanhpk/randstr" +) + +type SubscriptionWaffoPancakePayRequest struct { + PlanId int `json:"plan_id"` +} + +func SubscriptionRequestWaffoPancakePay(c *gin.Context) { + var req SubscriptionWaffoPancakePayRequest + if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + + plan, err := model.GetSubscriptionPlanById(req.PlanId) + if err != nil { + common.ApiError(c, err) + return + } + if !plan.Enabled { + common.ApiErrorMsg(c, "套餐未启用") + return + } + if strings.TrimSpace(plan.WaffoPancakeProductId) == "" { + common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId") + return + } + // Plan targets its own Pancake product, so we only require credentials + // here — not the gateway-level WaffoPancakeProductID. + if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" || + strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" { + common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效") + return + } + + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + if user == nil { + common.ApiErrorMsg(c, "用户不存在") + return + } + + if plan.MaxPurchasePerUser > 0 { + count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id) + if err != nil { + common.ApiError(c, err) + return + } + if count >= int64(plan.MaxPurchasePerUser) { + common.ApiErrorMsg(c, "已达到该套餐购买上限") + return + } + } + + // WAFFO_PANCAKE_SUB- prefix (vs. wallet's WAFFO_PANCAKE-) drives webhook + // dispatch in WaffoPancakeWebhook. + tradeNo := fmt.Sprintf("WAFFO_PANCAKE_SUB-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6)) + + order := &model.SubscriptionOrder{ + UserId: userId, + PlanId: plan.Id, + Money: plan.PriceAmount, + TradeNo: tradeNo, + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := order.Insert(); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error())) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + expiresInSeconds := 45 * 60 + session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{ + ProductID: plan.WaffoPancakeProductId, + BuyerIdentity: service.WaffoPancakeBuyerIdentityFromUserID(user.Id), + PriceSnapshot: &service.WaffoPancakePriceSnapshot{ + Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2), + TaxCategory: "saas", + }, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + ExpiresInSeconds: &expiresInSeconds, + }) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error())) + order.Status = common.TopUpStatusFailed + _ = order.Update() + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount)) + + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "checkout_url": session.CheckoutURL, + "session_id": session.SessionID, + "expires_at": session.ExpiresAt, + "order_id": tradeNo, + "token": session.Token, + "token_expires_at": session.TokenExpiresAt, + }, + }) +} diff --git a/controller/topup.go b/controller/topup.go index 1de196794b65..69e1b5e304c4 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -52,6 +52,27 @@ func GetTopUpInfo(c *gin.Context) { } } + // Waffo Pancake displayed above the legacy Waffo gateway. + enableWaffoPancake := isWaffoPancakeTopUpEnabled() + if enableWaffoPancake { + hasWaffoPancake := false + for _, method := range payMethods { + if method["type"] == model.PaymentMethodWaffoPancake { + hasWaffoPancake = true + break + } + } + + if !hasWaffoPancake { + payMethods = append(payMethods, map[string]string{ + "name": "Waffo Pancake", + "type": model.PaymentMethodWaffoPancake, + "color": "rgba(var(--semi-orange-5), 1)", + "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), + }) + } + } + // 如果启用了 Waffo 支付,添加到支付方法列表 enableWaffo := isWaffoTopUpEnabled() if enableWaffo { @@ -74,26 +95,6 @@ func GetTopUpInfo(c *gin.Context) { } } - enableWaffoPancake := isWaffoPancakeTopUpEnabled() - if enableWaffoPancake { - hasWaffoPancake := false - for _, method := range payMethods { - if method["type"] == model.PaymentMethodWaffoPancake { - hasWaffoPancake = true - break - } - } - - if !hasWaffoPancake { - payMethods = append(payMethods, map[string]string{ - "name": "Waffo Pancake", - "type": model.PaymentMethodWaffoPancake, - "color": "rgba(var(--semi-orange-5), 1)", - "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), - }) - } - } - data := gin.H{ "enable_online_topup": isEpayTopUpEnabled(), "enable_stripe_topup": isStripeTopUpEnabled(), diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 11c581fa699f..98b00acab951 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -102,27 +102,254 @@ func getWaffoPancakeBuyerEmail(user *model.User) string { return "" } -func getWaffoPancakeReturnURL() string { - if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" { - return setting.WaffoPancakeReturnURL +// The admin config endpoints below accept typed-but-not-yet-saved creds in +// the body and fall back to persisted creds when the body is blank (see +// resolveWaffoPancakeAdminCreds). Only SaveWaffoPancake writes to OptionMap. + +type waffoPancakeCredsRequest struct { + MerchantID string `json:"merchant_id"` + PrivateKey string `json:"private_key"` +} + +type saveWaffoPancakeRequest struct { + MerchantID string `json:"merchant_id"` + PrivateKey string `json:"private_key"` + ReturnURL string `json:"return_url"` + StoreID string `json:"store_id"` + ProductID string `json:"product_id"` +} + +type createWaffoPancakePairRequest struct { + MerchantID string `json:"merchant_id"` + PrivateKey string `json:"private_key"` + ReturnURL string `json:"return_url"` +} + +// SaveWaffoPancake atomically persists all five operator-controlled fields. +// Catalog / pair endpoints are transient — only this one writes the OptionMap. +func SaveWaffoPancake(c *gin.Context) { + var req saveWaffoPancakeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + if err := service.SaveWaffoPancakeConfig( + c.Request.Context(), + req.MerchantID, + req.PrivateKey, + req.ReturnURL, + req.StoreID, + req.ProductID, + ); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q", + req.StoreID, req.ProductID, err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"}) + return } - return paymentReturnPath("/console/topup?show_history=true") + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "product_id": setting.WaffoPancakeProductID, + "store_id": setting.WaffoPancakeStoreID, + }, + }) } -func RequestWaffoPancakePay(c *gin.Context) { - if !setting.WaffoPancakeEnabled { - c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"}) +// resolveWaffoPancakeAdminCreds prefers body creds (typed-but-not-yet-saved +// values, for verification) and falls back to persisted creds when the body +// is blank (so returning admins don't have to re-paste the private key, +// which is stripped from GET /api/option/). +func resolveWaffoPancakeAdminCreds(bodyMerchantID, bodyPrivateKey string) (string, string) { + m := strings.TrimSpace(bodyMerchantID) + k := strings.TrimSpace(bodyPrivateKey) + if m == "" && k == "" { + return setting.WaffoPancakeMerchantID, setting.WaffoPancakePrivateKey + } + return m, k +} + +// CreateWaffoPancakePair mints a Store + OnetimeProduct pair in one round- +// trip. Surfaces an orphan-store flag when the product half fails so the +// frontend can preselect / retry without losing context. +func CreateWaffoPancakePair(c *gin.Context) { + var req createWaffoPancakePairRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + } + merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey) + if merchantID == "" || privateKey == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"}) + return + } + result, err := service.CreateWaffoPancakePrimaryPair( + c.Request.Context(), merchantID, privateKey, req.ReturnURL, + ) + if err != nil { + orphan := result != nil && result.OrphanStore + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q", + orphan, func() string { + if result == nil { + return "" + } + return result.StoreID + }(), err.Error(), + )) + data := gin.H{"error": err.Error()} + if orphan { + data["store_id"] = result.StoreID + data["store_name"] = result.StoreName + data["orphan_store"] = true + } + c.JSON(http.StatusOK, gin.H{"message": "error", "data": data}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "store_id": result.StoreID, + "store_name": result.StoreName, + "product_id": result.ProductID, + "product_name": result.ProductName, + }, + }) +} + +// ListWaffoPancakeCatalog returns the merchant's Stores + OnetimeProducts. +// Doubles as a credential probe (a successful 200 proves the resolved creds +// authenticate). See resolveWaffoPancakeAdminCreds for credential resolution. +func ListWaffoPancakeCatalog(c *gin.Context) { + var req waffoPancakeCredsRequest + // An empty body means "use persisted creds"; only fail on malformed JSON. + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + } + merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey) + if merchantID == "" || privateKey == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"}) + return + } + catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog}) +} + +type createWaffoPancakeSubscriptionProductRequest struct { + Name string `json:"name"` + Amount string `json:"amount"` +} + +// CreateWaffoPancakeSubscriptionProduct mints an OnetimeProduct (not +// SubscriptionProduct — see service.CreateWaffoPancakeProductForPlan) +// sized to a plan's `name` + `amount`, using persisted Pancake credentials +// + StoreID. Reads from the form, not the plan row, so newly-typed unsaved +// plans can mint a product too. +func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) { + var req createWaffoPancakeSubscriptionProductRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + } + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"}) + return + } + if strings.TrimSpace(req.Amount) == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"}) + return + } + merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "") + storeID := strings.TrimSpace(setting.WaffoPancakeStoreID) + if merchantID == "" || privateKey == "" || storeID == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"}) + return + } + productID, err := service.CreateWaffoPancakeProductForPlan( + c.Request.Context(), + merchantID, + privateKey, + storeID, + req.Name, + req.Amount, + setting.WaffoPancakeReturnURL, + ) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q", + storeID, req.Name, req.Amount, err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "product_id": productID, + "product_name": req.Name, + "store_id": storeID, + }, + }) +} + +// ListWaffoPancakeSubscriptionProductOptions returns the OnetimeProducts +// in the saved Pancake store, for the subscription-plan dropdown. The name +// reflects new-api's plan concept; under the hood it's still OnetimeProducts. +func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) { + merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "") + storeID := strings.TrimSpace(setting.WaffoPancakeStoreID) + if merchantID == "" || privateKey == "" || storeID == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"}) return } - currentWebhookKey := setting.WaffoPancakeWebhookPublicKey - if setting.WaffoPancakeSandbox { - currentWebhookKey = setting.WaffoPancakeWebhookTestKey + catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"}) + return } - if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" || - strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" || - strings.TrimSpace(currentWebhookKey) == "" || - strings.TrimSpace(setting.WaffoPancakeStoreID) == "" || - strings.TrimSpace(setting.WaffoPancakeProductID) == "" { + products := []service.WaffoPancakeCatalogProduct{} + for _, store := range catalog.Stores { + if store.ID == storeID { + products = store.OnetimeProducts + break + } + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "store_id": storeID, + "products": products, + }, + }) +} + +func getWaffoPancakeBuyerIdentity(user *model.User) string { + if user == nil { + return "" + } + return service.WaffoPancakeBuyerIdentityFromUserID(user.Id) +} + +func RequestWaffoPancakePay(c *gin.Context) { + if !isWaffoPancakeTopUpEnabled() { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"}) return } @@ -175,17 +402,13 @@ func RequestWaffoPancakePay(c *gin.Context) { expiresInSeconds := 45 * 60 session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{ - StoreID: setting.WaffoPancakeStoreID, - ProductID: setting.WaffoPancakeProductID, - ProductType: "onetime", - Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)), + ProductID: setting.WaffoPancakeProductID, + BuyerIdentity: getWaffoPancakeBuyerIdentity(user), PriceSnapshot: &service.WaffoPancakePriceSnapshot{ Amount: formatWaffoPancakeAmount(payMoney), - TaxIncluded: false, TaxCategory: "saas", }, BuyerEmail: getWaffoPancakeBuyerEmail(user), - SuccessURL: getWaffoPancakeReturnURL(), ExpiresInSeconds: &expiresInSeconds, }) if err != nil { @@ -200,10 +423,12 @@ func RequestWaffoPancakePay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "success", "data": gin.H{ - "checkout_url": session.CheckoutURL, - "session_id": session.SessionID, - "expires_at": session.ExpiresAt, - "order_id": tradeNo, + "checkout_url": session.CheckoutURL, + "session_id": session.SessionID, + "expires_at": session.ExpiresAt, + "order_id": tradeNo, + "token": session.Token, + "token_expires_at": session.TokenExpiresAt, }, }) } @@ -215,6 +440,19 @@ func WaffoPancakeWebhook(c *gin.Context) { return } + // :env splits test vs prod traffic at the routing layer — operator + // registers each URL in the matching webhook slot in Pancake's dashboard. + // We then enforce event.mode == expectedEnv to catch mis-registrations. + expectedEnv := strings.TrimSpace(c.Param("env")) + if expectedEnv != "test" && expectedEnv != "prod" { + logger.LogWarn(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s", + expectedEnv, c.Request.RequestURI, c.ClientIP(), + )) + c.String(http.StatusNotFound, "unknown env") + return + } + bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) @@ -232,15 +470,57 @@ func WaffoPancakeWebhook(c *gin.Context) { return } + if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s", + expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(), + )) + c.String(http.StatusOK, "OK") + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP())) if event.NormalizedEventType() != "order.completed" { c.String(http.StatusOK, "OK") return } + // Subscription vs top-up dispatch by trade_no prefix (written at + // session-creation time): WAFFO_PANCAKE_SUB- vs WAFFO_PANCAKE-. + rawTradeNo := strings.TrimSpace(event.Data.OrderID) + isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-") + + if isSubscription { + tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q", + event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(), + )) + c.String(http.StatusOK, "OK") + return + } + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error())) + c.String(http.StatusInternalServerError, "retry") + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP())) + c.String(http.StatusOK, "OK") + return + } + tradeNo, err := service.ResolveWaffoPancakeTradeNo(event) if err != nil { - logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error())) + // LogError (not LogWarn): covers order-not-found and buyer-identity + // mismatch — both warrant human attention. 200 OK so Waffo doesn't + // retry a permanently-unresolvable webhook. + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q", + event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(), + )) c.String(http.StatusOK, "OK") return } diff --git a/go.mod b/go.mod index f34ecc198bba..672c7418a82f 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,8 @@ require ( gorm.io/gorm v1.25.2 ) +require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 + require ( github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect diff --git a/go.sum b/go.sum index 6a97e299587c..e16f7e20f554 100644 --- a/go.sum +++ b/go.sum @@ -308,6 +308,10 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw= github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= +github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/ag9d7CwE/TxH3Hls= +github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= +github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U= +github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= 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= diff --git a/model/main.go b/model/main.go index 16cd373fb203..9083ee57ab90 100644 --- a/model/main.go +++ b/model/main.go @@ -399,6 +399,7 @@ func ensureSubscriptionPlanTableSQLite() error { ` + "`sort_order`" + ` integer DEFAULT 0, ` + "`stripe_price_id`" + ` varchar(128) DEFAULT '', ` + "`creem_product_id`" + ` varchar(128) DEFAULT '', +` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '', ` + "`max_purchase_per_user`" + ` integer DEFAULT 0, ` + "`upgrade_group`" + ` varchar(64) DEFAULT '', ` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0, @@ -432,6 +433,7 @@ PRIMARY KEY (` + "`id`" + `) {Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"}, {Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"}, {Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"}, + {Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"}, {Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"}, {Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"}, {Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"}, diff --git a/model/option.go b/model/option.go index e0a3048d34f2..ed1af72ebb12 100644 --- a/model/option.go +++ b/model/option.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/system_setting" + "gorm.io/gorm" ) type Option struct { @@ -106,18 +107,13 @@ func InitOptionMap() { common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64) common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp) common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString() - common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled) - common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox) common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey - common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey - common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey - common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID - common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL - common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64) common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp) + common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID + common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error { return updateOptionMap(key, value) } +// UpdateOptionsBulk persists multiple key/value pairs in a single database +// transaction, then dispatches them through updateOptionMap in one pass. If +// any DB write fails the whole transaction rolls back and no in-memory state +// is touched — safe for callers that must commit a set of related options +// atomically (e.g. payment gateway binding). +func UpdateOptionsBulk(values map[string]string) error { + if len(values) == 0 { + return nil + } + err := DB.Transaction(func(tx *gorm.DB) error { + for k, v := range values { + option := Option{Key: k} + if err := tx.FirstOrCreate(&option, Option{Key: k}).Error; err != nil { + return err + } + option.Value = v + if err := tx.Save(&option).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + for k, v := range values { + if err := updateOptionMap(k, v); err != nil { + return err + } + } + return nil +} + func updateOptionMap(key string, value string) (err error) { common.OptionMapRWMutex.Lock() defer common.OptionMapRWMutex.Unlock() @@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) { setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64) case "WaffoMinTopUp": setting.WaffoMinTopUp, _ = strconv.Atoi(value) - case "WaffoPancakeEnabled": - setting.WaffoPancakeEnabled = value == "true" - case "WaffoPancakeSandbox": - setting.WaffoPancakeSandbox = value == "true" case "WaffoPancakeMerchantID": setting.WaffoPancakeMerchantID = value case "WaffoPancakePrivateKey": setting.WaffoPancakePrivateKey = value - case "WaffoPancakeWebhookPublicKey": - setting.WaffoPancakeWebhookPublicKey = value - case "WaffoPancakeWebhookTestKey": - setting.WaffoPancakeWebhookTestKey = value + case "WaffoPancakeReturnURL": + setting.WaffoPancakeReturnURL = value case "WaffoPancakeStoreID": setting.WaffoPancakeStoreID = value case "WaffoPancakeProductID": setting.WaffoPancakeProductID = value - case "WaffoPancakeReturnURL": - setting.WaffoPancakeReturnURL = value - case "WaffoPancakeCurrency": - setting.WaffoPancakeCurrency = value case "WaffoPancakeUnitPrice": setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64) case "WaffoPancakeMinTopUp": diff --git a/model/subscription.go b/model/subscription.go index da8fdae94101..4ff5a204a9bc 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -159,8 +159,9 @@ type SubscriptionPlan struct { Enabled bool `json:"enabled" gorm:"default:true"` SortOrder int `json:"sort_order" gorm:"type:int;default:0"` - StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"` - CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"` + StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"` + CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"` + WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"` // Max purchases per user (0 = unlimited) MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"` diff --git a/router/api-router.go b/router/api-router.go index da026ed92f4d..7dfc648eed3e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -56,7 +56,9 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) - //apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook) + // :env separates test vs prod URLs so the operator can register each + // in Pancake's matching webhook slot; handler enforces env match. + apiRouter.POST("/waffo-pancake/webhook/:env", controller.WaffoPancakeWebhook) // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) @@ -100,8 +102,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) - //selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) - //selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) + selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) + selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) @@ -154,6 +156,7 @@ func SetApiRouter(router *gin.Engine) { subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) + subscriptionRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWaffoPancakePay) } subscriptionAdminRoute := apiRouter.Group("/subscription/admin") subscriptionAdminRoute.Use(middleware.AdminAuth()) @@ -186,6 +189,11 @@ func SetApiRouter(router *gin.Engine) { optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 + optionRoute.POST("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog) + optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair) + optionRoute.POST("/waffo-pancake/save", controller.SaveWaffoPancake) + optionRoute.POST("/waffo-pancake/subscription-product", controller.CreateWaffoPancakeSubscriptionProduct) + optionRoute.POST("/waffo-pancake/subscription-product-options", controller.ListWaffoPancakeSubscriptionProductOptions) } // Custom OAuth provider management (root only) diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 9033c37f37d1..d603ece19a40 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -1,398 +1,472 @@ package service import ( - "bytes" "context" - "crypto" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/pem" "fmt" - "io" - "math" - "net/http" - "strconv" "strings" - "time" - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" + pancake "github.com/waffo-com/waffo-pancake-sdk-go" ) -const ( - waffoPancakeAuthBaseURL = "https://waffo-pancake-auth-service.vercel.app" - waffoPancakeCheckoutPath = "/v1/actions/checkout/create-session" - waffoPancakeDefaultTolerance = 5 * time.Minute -) - +// WaffoPancakePriceSnapshot is the per-session price override sent with checkout. type WaffoPancakePriceSnapshot struct { - Amount string `json:"amount"` - TaxIncluded bool `json:"taxIncluded"` - TaxCategory string `json:"taxCategory"` + Amount string + TaxCategory string } +// WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession. +// BuyerIdentity (merchant-controlled, stable per user) is what survives the +// buyer editing email at checkout — see WaffoPancakeBuyerIdentityFromUserID. type WaffoPancakeCreateSessionParams struct { - StoreID string `json:"storeId"` - ProductID string `json:"productId"` - ProductType string `json:"productType"` - Currency string `json:"currency"` - PriceSnapshot *WaffoPancakePriceSnapshot `json:"priceSnapshot,omitempty"` - BuyerEmail string `json:"buyerEmail,omitempty"` - SuccessURL string `json:"successUrl,omitempty"` - ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"` + ProductID string + BuyerIdentity string + PriceSnapshot *WaffoPancakePriceSnapshot + BuyerEmail string + ExpiresInSeconds *int } +// WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession. +// CheckoutURL already carries the `#token=...` fragment; Token / TokenExpiresAt +// are exposed separately for self-service flows driven from new-api's own UI. type WaffoPancakeCheckoutSession struct { - SessionID string `json:"sessionId"` - CheckoutURL string `json:"checkoutUrl"` - ExpiresAt string `json:"expiresAt"` - OrderID string `json:"orderId"` + SessionID string + CheckoutURL string + ExpiresAt string + OrderID string + Token string + TokenExpiresAt string } -type waffoPancakeAPIError struct { - Message string `json:"message"` - Layer string `json:"layer"` +// WaffoPancakeWebhookEvent mirrors the SDK's WebhookEvent shape using plain +// strings so controllers don't have to import the SDK package. +type WaffoPancakeWebhookEvent struct { + ID string + Timestamp string + EventType string + EventID string + StoreID string + Mode string + Data WaffoPancakeWebhookData } -type waffoPancakeCreateSessionResponse struct { - Data *WaffoPancakeCheckoutSession `json:"data"` - Errors []waffoPancakeAPIError `json:"errors"` +type WaffoPancakeWebhookData struct { + OrderID string + BuyerEmail string + Currency string + Amount string + TaxAmount string + ProductName string + MerchantProvidedBuyerIdentity string } -type waffoPancakeWebhookData struct { - ID string `json:"id"` - OrderID string `json:"orderId"` - BuyerEmail string `json:"buyerEmail"` - Currency string `json:"currency"` - Amount dto.StringValue `json:"amount"` - TaxAmount dto.StringValue `json:"taxAmount"` - ProductName string `json:"productName"` +// NormalizedEventType returns the event type or empty string for a nil event. +func (e *WaffoPancakeWebhookEvent) NormalizedEventType() string { + if e == nil { + return "" + } + return e.EventType } -type waffoPancakeWebhookEvent struct { - ID string `json:"id"` - Timestamp string `json:"timestamp"` - EventType string `json:"eventType"` - EventID string `json:"eventId"` - StoreID string `json:"storeId"` - Mode string `json:"mode"` - Data waffoPancakeWebhookData `json:"data"` +// newWaffoPancakeClient builds an SDK client from persisted settings. The +// runtime checkout / webhook paths use this; configuration endpoints use +// newWaffoPancakeClientFromCreds so the operator can verify typed-but-not- +// yet-saved credentials. +func newWaffoPancakeClient() (*pancake.Client, error) { + return pancake.New(pancake.Config{ + MerchantID: setting.WaffoPancakeMerchantID, + PrivateKey: setting.WaffoPancakePrivateKey, + }) } -func (e *waffoPancakeWebhookEvent) NormalizedEventType() string { - if e == nil { - return "" +func newWaffoPancakeClientFromCreds(merchantID, privateKey string) (*pancake.Client, error) { + if strings.TrimSpace(merchantID) == "" || strings.TrimSpace(privateKey) == "" { + return nil, fmt.Errorf("merchant id and private key are required") } - return e.EventType + return pancake.New(pancake.Config{ + MerchantID: merchantID, + PrivateKey: privateKey, + }) } +// CreateWaffoPancakeCheckoutSession creates an Authenticated-mode checkout +// session: the order is bound to BuyerIdentity (stable per user) so it stays +// attributable even if the buyer edits the email on Waffo's checkout form. func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) { if params == nil { return nil, fmt.Errorf("missing checkout params") } - - body, err := common.Marshal(params) - if err != nil { - return nil, fmt.Errorf("marshal Waffo Pancake checkout payload: %w", err) + if strings.TrimSpace(params.BuyerIdentity) == "" { + return nil, fmt.Errorf("missing buyer identity") } - - privateKey, err := normalizeRSAPrivateKey(setting.WaffoPancakePrivateKey) + client, err := newWaffoPancakeClient() if err != nil { - return nil, err + return nil, fmt.Errorf("build Waffo Pancake client: %w", err) + } + + sdkParams := pancake.AuthenticatedCheckoutParams{ + CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{ + ProductID: params.ProductID, + Currency: "USD", + BuyerEmail: optionalString(params.BuyerEmail), + ExpiresInSeconds: params.ExpiresInSeconds, + }, + BuyerIdentity: params.BuyerIdentity, + } + if params.PriceSnapshot != nil { + sdkParams.PriceSnapshot = &pancake.PriceInfo{ + Amount: params.PriceSnapshot.Amount, + TaxCategory: pancake.TaxCategory(params.PriceSnapshot.TaxCategory), + } } - timestamp := strconv.FormatInt(time.Now().Unix(), 10) - signature, err := signWaffoPancakeRequest(http.MethodPost, waffoPancakeCheckoutPath, timestamp, string(body), privateKey) + session, err := client.Checkout.Authenticated.Create(ctx, sdkParams) if err != nil { return nil, err } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, waffoPancakeAuthBaseURL+waffoPancakeCheckoutPath, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("build Waffo Pancake checkout request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Merchant-Id", setting.WaffoPancakeMerchantID) - req.Header.Set("X-Timestamp", timestamp) - req.Header.Set("X-Signature", signature) - if setting.WaffoPancakeSandbox { - req.Header.Set("X-Environment", "test") - } else { - req.Header.Set("X-Environment", "prod") - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request Waffo Pancake checkout session: %w", err) + if session == nil || strings.TrimSpace(session.CheckoutURL) == "" || strings.TrimSpace(session.SessionID) == "" { + return nil, fmt.Errorf("Waffo Pancake returned empty checkout session") } - defer resp.Body.Close() + return &WaffoPancakeCheckoutSession{ + SessionID: session.SessionID, + CheckoutURL: session.CheckoutURL, + ExpiresAt: session.ExpiresAt, + Token: session.Token, + TokenExpiresAt: session.TokenExpiresAt, + }, nil +} - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read Waffo Pancake checkout response: %w", err) +func optionalString(s string) *string { + if strings.TrimSpace(s) == "" { + return nil } + v := s + return &v +} - var result waffoPancakeCreateSessionResponse - if err := common.Unmarshal(responseBody, &result); err != nil { - return nil, fmt.Errorf("decode Waffo Pancake checkout response: %w", err) - } - if resp.StatusCode >= http.StatusBadRequest { - if len(result.Errors) > 0 { - return nil, fmt.Errorf("Waffo Pancake error (%d): %s", resp.StatusCode, result.Errors[0].Message) - } - return nil, fmt.Errorf("Waffo Pancake checkout request failed with status %d", resp.StatusCode) - } - if len(result.Errors) > 0 { - return nil, fmt.Errorf("Waffo Pancake error: %s", result.Errors[0].Message) - } - if result.Data == nil || result.Data.CheckoutURL == "" || strings.TrimSpace(result.Data.SessionID) == "" { - return nil, fmt.Errorf("Waffo Pancake returned empty checkout session") - } - return result.Data, nil +// WaffoPancakeBuyerIdentityFromUserID renders the canonical buyer identity +// for checkout. Webhook handlers compare against the value rendered here to +// reject identity mismatches, so both call sites must use this function. +func WaffoPancakeBuyerIdentityFromUserID(userID int) string { + return fmt.Sprintf("new-api-user-%d", userID) } -func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) { - environment := resolveWaffoPancakeWebhookEnvironment(payload) - return verifyWaffoPancakeWebhook(payload, signatureHeader, environment) +// VerifyConfiguredWaffoPancakeWebhook verifies the signature header. The SDK +// picks the matching test / prod public key from the payload's `mode` field. +func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*WaffoPancakeWebhookEvent, error) { + evt, err := pancake.VerifyWebhookTyped[pancake.WebhookEventData](payload, signatureHeader, nil) + if err != nil { + return nil, err + } + identity := "" + if evt.Data.MerchantProvidedBuyerIdentity != nil { + identity = *evt.Data.MerchantProvidedBuyerIdentity + } + return &WaffoPancakeWebhookEvent{ + ID: evt.ID, + Timestamp: evt.Timestamp, + EventType: evt.EventType, + EventID: evt.EventID, + StoreID: evt.StoreID, + Mode: string(evt.Mode), + Data: WaffoPancakeWebhookData{ + OrderID: evt.Data.OrderID, + BuyerEmail: evt.Data.BuyerEmail, + Currency: evt.Data.Currency, + Amount: evt.Data.Amount, + TaxAmount: evt.Data.TaxAmount, + ProductName: evt.Data.ProductName, + MerchantProvidedBuyerIdentity: identity, + }, + }, nil } -func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) { +// ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp +// trade_no, rejecting any payload whose buyer identity doesn't match the one +// we recorded at checkout — defence-in-depth on top of signature verification. +func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) { if event == nil { return "", fmt.Errorf("missing webhook event") } - - if tradeNo := strings.TrimSpace(event.Data.OrderID); tradeNo != "" { - topUp := model.GetTopUpByTradeNo(tradeNo) - if topUp != nil && topUp.PaymentMethod == model.PaymentMethodWaffoPancake { - return tradeNo, nil - } + tradeNo := strings.TrimSpace(event.Data.OrderID) + if tradeNo == "" { + return "", fmt.Errorf("missing webhook orderId") + } + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake { return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo) } - - return "", fmt.Errorf("missing webhook orderId") + expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId) + actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) + if actualIdentity != expectedIdentity { + return "", fmt.Errorf( + "waffo pancake buyer identity mismatch for tradeNo=%s: expected=%q actual=%q", + tradeNo, + expectedIdentity, + actualIdentity, + ) + } + return tradeNo, nil } -func normalizeRSAPrivateKey(raw string) (string, error) { - return normalizePEMKey(raw, "PRIVATE KEY", "RSA PRIVATE KEY") +// ResolveWaffoPancakeSubscriptionTradeNo is the SubscriptionOrder counterpart +// of ResolveWaffoPancakeTradeNo. +func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (string, error) { + if event == nil { + return "", fmt.Errorf("missing webhook event") + } + tradeNo := strings.TrimSpace(event.Data.OrderID) + if tradeNo == "" { + return "", fmt.Errorf("missing webhook orderId") + } + order := model.GetSubscriptionOrderByTradeNo(tradeNo) + if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake { + return "", fmt.Errorf("waffo pancake subscription order not found for webhook orderId=%s", tradeNo) + } + expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId) + actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) + if actualIdentity != expectedIdentity { + return "", fmt.Errorf( + "waffo pancake buyer identity mismatch for subscription tradeNo=%s: expected=%q actual=%q", + tradeNo, + expectedIdentity, + actualIdentity, + ) + } + return tradeNo, nil } -func normalizeRSAPublicKey(raw string) (string, error) { - return normalizePEMKey(raw, "PUBLIC KEY", "RSA PUBLIC KEY") -} +// Deterministic default names for "+ Create": stable bodies mean stable +// X-Idempotency-Key, which lets Pancake dedupe retries server-side. +const ( + defaultWaffoPancakeStoreName = "new-api-store" + defaultWaffoPancakeProductName = "new-api-charge-product" +) -func normalizePEMKey(raw string, pkcs8Type string, pkcs1Type string) (string, error) { - if strings.TrimSpace(raw) == "" { - return "", fmt.Errorf("%s is empty", strings.ToLower(pkcs8Type)) +// CreateWaffoPancakePrimaryStore creates a Pancake Store using in-flight +// (not-yet-persisted) credentials and returns the new store ID. +func CreateWaffoPancakePrimaryStore(ctx context.Context, merchantID, privateKey string) (string, error) { + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) + if err != nil { + return "", err } - - normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n")) - if strings.Contains(normalized, "BEGIN ") { - block, _ := pem.Decode([]byte(normalized)) - if block == nil { - return "", fmt.Errorf("invalid PEM encoded %s", strings.ToLower(pkcs8Type)) - } - return string(pem.EncodeToMemory(block)), nil + storeRes, err := client.Stores.Create(ctx, pancake.CreateStoreParams{ + Name: defaultWaffoPancakeStoreName, + }) + if err != nil { + return "", fmt.Errorf("create Waffo Pancake store: %w", err) } + return storeRes.Store.ID, nil +} - der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", "")) +// CreateWaffoPancakeProductForPlan mints (and publishes) a Pancake +// OnetimeProduct priced at `amount` USD, used as a subscription plan's +// SubscriptionPlan.WaffoPancakeProductId. +// +// OnetimeProduct (not SubscriptionProduct) because new-api has no renewal- +// event handling; Pancake auto-renewing without new-api extending user +// access would be a UX divergence. Revisit if renewal handling is added. +func CreateWaffoPancakeProductForPlan(ctx context.Context, merchantID, privateKey, storeID, name, amount, returnURL string) (string, error) { + storeID = strings.TrimSpace(storeID) + if storeID == "" { + return "", fmt.Errorf("store id is required to create a product") + } + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("plan name is required") + } + amount = strings.TrimSpace(amount) + if amount == "" { + return "", fmt.Errorf("plan price is required") + } + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) + if err != nil { + return "", err + } + prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{ + StoreID: storeID, + Name: name, + Prices: pancake.Prices{ + "USD": { + Amount: amount, + TaxCategory: pancake.TaxCategory("saas"), + }, + }, + SuccessURL: optionalString(strings.TrimSpace(returnURL)), + }) if err != nil { - return "", fmt.Errorf("invalid base64 encoded %s: %w", strings.ToLower(pkcs8Type), err) + return "", fmt.Errorf("create Waffo Pancake plan product: %w", err) } - - pemType := pkcs8Type - if pkcs8Type == "PRIVATE KEY" { - if _, err := x509.ParsePKCS8PrivateKey(der); err != nil { - if _, err := x509.ParsePKCS1PrivateKey(der); err == nil { - pemType = pkcs1Type - } else { - return "", fmt.Errorf("invalid RSA private key") - } - } - } else { - if _, err := x509.ParsePKIXPublicKey(der); err != nil { - if _, err := x509.ParsePKCS1PublicKey(der); err == nil { - pemType = pkcs1Type - } else { - return "", fmt.Errorf("invalid RSA public key") - } - } + productID := prodRes.Product.ID + if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil { + return "", fmt.Errorf("publish Waffo Pancake plan product: %w", err) } - - return string(pem.EncodeToMemory(&pem.Block{Type: pemType, Bytes: der})), nil + return productID, nil } -func signWaffoPancakeRequest(method string, path string, timestamp string, body string, privateKeyPEM string) (string, error) { - block, _ := pem.Decode([]byte(privateKeyPEM)) - if block == nil { - return "", fmt.Errorf("invalid RSA private key PEM") +// CreateWaffoPancakePrimaryProduct mints (and publishes) the wallet-top-up +// OnetimeProduct under storeID. Per-checkout price overrides via PriceSnapshot +// are what make the "1.00" seed price irrelevant at runtime. +func CreateWaffoPancakePrimaryProduct(ctx context.Context, merchantID, privateKey, storeID, returnURL string) (string, error) { + storeID = strings.TrimSpace(storeID) + if storeID == "" { + return "", fmt.Errorf("store id is required to create a product") } - - var privateKey *rsa.PrivateKey - switch block.Type { - case "PRIVATE KEY": - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return "", fmt.Errorf("parse PKCS#8 private key: %w", err) - } - parsed, ok := key.(*rsa.PrivateKey) - if !ok { - return "", fmt.Errorf("private key is not RSA") - } - privateKey = parsed - case "RSA PRIVATE KEY": - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return "", fmt.Errorf("parse PKCS#1 private key: %w", err) - } - privateKey = key - default: - return "", fmt.Errorf("unsupported private key type: %s", block.Type) - } - - canonicalRequest := buildWaffoPancakeCanonicalRequest(method, path, timestamp, body) - digest := sha256.Sum256([]byte(canonicalRequest)) - signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:]) + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) + if err != nil { + return "", err + } + prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{ + StoreID: storeID, + Name: defaultWaffoPancakeProductName, + Prices: pancake.Prices{ + "USD": { + Amount: "1.00", // overridden at checkout via PriceSnapshot + TaxCategory: pancake.TaxCategory("saas"), + }, + }, + SuccessURL: optionalString(strings.TrimSpace(returnURL)), + }) if err != nil { - return "", fmt.Errorf("sign Waffo Pancake request: %w", err) + return "", fmt.Errorf("create Waffo Pancake product: %w", err) } - return base64.StdEncoding.EncodeToString(signature), nil + productID := prodRes.Product.ID + if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil { + return "", fmt.Errorf("publish Waffo Pancake product: %w", err) + } + return productID, nil } -func buildWaffoPancakeCanonicalRequest(method string, path string, timestamp string, body string) string { - bodyHash := sha256.Sum256([]byte(body)) - return fmt.Sprintf( - "%s\n%s\n%s\n%s", - strings.ToUpper(method), - path, - timestamp, - base64.StdEncoding.EncodeToString(bodyHash[:]), - ) +// WaffoPancakePairResult is the response of CreateWaffoPancakePrimaryPair. +// When OrphanStore is true the store was created but the product wasn't, +// so the caller can surface a partial-failure message with StoreID. +type WaffoPancakePairResult struct { + StoreID string + StoreName string + ProductID string + ProductName string + OrphanStore bool } -func verifyWaffoPancakeWebhook(payload string, signatureHeader string, environment string) (*waffoPancakeWebhookEvent, error) { - if signatureHeader == "" { - return nil, fmt.Errorf("missing X-Waffo-Signature header") - } - - timestampPart, signaturePart := parseWaffoPancakeSignatureHeader(signatureHeader) - if timestampPart == "" || signaturePart == "" { - return nil, fmt.Errorf("malformed X-Waffo-Signature header") - } - - timestampMs, err := strconv.ParseInt(timestampPart, 10, 64) +// CreateWaffoPancakePrimaryPair mints a Store + OnetimeProduct in one +// round-trip — the canonical "+ Create" entry point. Nothing is persisted +// to settings; the operator's final Save commits the chosen IDs. +func CreateWaffoPancakePrimaryPair(ctx context.Context, merchantID, privateKey, returnURL string) (*WaffoPancakePairResult, error) { + storeID, err := CreateWaffoPancakePrimaryStore(ctx, merchantID, privateKey) if err != nil { - return nil, fmt.Errorf("invalid timestamp in X-Waffo-Signature header") - } - if math.Abs(float64(time.Now().UnixMilli()-timestampMs)) > float64(waffoPancakeDefaultTolerance.Milliseconds()) { - return nil, fmt.Errorf("webhook timestamp outside tolerance window") - } - - signatureInput := fmt.Sprintf("%s.%s", timestampPart, payload) - if err := verifyWaffoPancakeWebhookWithKey(signatureInput, signaturePart, resolveWaffoPancakeWebhookPublicKey(environment)); err != nil { - return nil, fmt.Errorf("invalid webhook signature") - } - - var event waffoPancakeWebhookEvent - if err := common.Unmarshal([]byte(payload), &event); err != nil { - return nil, fmt.Errorf("parse Waffo Pancake webhook payload: %w", err) + return nil, err } - return &event, nil + productID, err := CreateWaffoPancakePrimaryProduct(ctx, merchantID, privateKey, storeID, returnURL) + if err != nil { + return &WaffoPancakePairResult{ + StoreID: storeID, + StoreName: defaultWaffoPancakeStoreName, + OrphanStore: true, + }, fmt.Errorf("store created at %s but product creation failed: %w", storeID, err) + } + return &WaffoPancakePairResult{ + StoreID: storeID, + StoreName: defaultWaffoPancakeStoreName, + ProductID: productID, + ProductName: defaultWaffoPancakeProductName, + }, nil } -func parseWaffoPancakeSignatureHeader(header string) (string, string) { - var timestampPart string - var signaturePart string - for _, pair := range strings.Split(header, ",") { - key, value, found := strings.Cut(strings.TrimSpace(pair), "=") - if !found { - continue - } - switch key { - case "t": - timestampPart = value - case "v1": - signaturePart = value - } +// SaveWaffoPancakeConfig persists the operator-controlled fields atomically +// at the end of the configuration flow via model.UpdateOptionsBulk (single +// DB transaction). A blank privateKey is treated as "keep current" +// (Stripe-style API-secret UX) and is omitted from the bulk payload. +func SaveWaffoPancakeConfig(ctx context.Context, merchantID, privateKey, returnURL, storeID, productID string) error { + merchantID = strings.TrimSpace(merchantID) + storeID = strings.TrimSpace(storeID) + productID = strings.TrimSpace(productID) + if merchantID == "" || storeID == "" || productID == "" { + return fmt.Errorf("merchant id, store id, and product id are required to save") + } + values := map[string]string{ + "WaffoPancakeMerchantID": merchantID, + "WaffoPancakeReturnURL": strings.TrimSpace(returnURL), + "WaffoPancakeStoreID": storeID, + "WaffoPancakeProductID": productID, + } + if pk := strings.TrimSpace(privateKey); pk != "" { + values["WaffoPancakePrivateKey"] = pk + } + if err := model.UpdateOptionsBulk(values); err != nil { + return fmt.Errorf("persist Waffo Pancake config: %w", err) } - return timestampPart, signaturePart + return nil } -func resolveWaffoPancakeWebhookEnvironment(payload string) string { - var envelope struct { - Mode string `json:"mode"` - } - if err := common.Unmarshal([]byte(payload), &envelope); err != nil { - if setting.WaffoPancakeSandbox { - return "test" - } - return "prod" - } +type WaffoPancakeCatalogProduct struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} - switch strings.ToLower(strings.TrimSpace(envelope.Mode)) { - case "test": - return "test" - case "prod": - return "prod" - default: - if setting.WaffoPancakeSandbox { - return "test" - } - return "prod" - } +// WaffoPancakeCatalogStore nests its OnetimeProducts so the UI can render a +// dependent store→product select without a second round-trip. +type WaffoPancakeCatalogStore struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ProdEnabled bool `json:"prodEnabled"` + OnetimeProducts []WaffoPancakeCatalogProduct `json:"onetimeProducts"` } -func resolveWaffoPancakeWebhookPublicKey(environment string) string { - if environment == "prod" { - return strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) - } - return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) +type WaffoPancakeCatalog struct { + Stores []WaffoPancakeCatalogStore `json:"stores"` } -func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error { - publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey) +// ListWaffoPancakeCatalog queries Pancake's GraphQL `stores` for the +// merchant's stores + onetime products. A successful call also proves +// the supplied credentials authenticate (doubles as a credential probe). +func ListWaffoPancakeCatalog(ctx context.Context, merchantID, privateKey string) (*WaffoPancakeCatalog, error) { + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) if err != nil { - return err - } - - block, _ := pem.Decode([]byte(publicKeyPEM)) - if block == nil { - return fmt.Errorf("invalid RSA public key PEM") - } - - var publicKey *rsa.PublicKey - switch block.Type { - case "PUBLIC KEY": - key, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return fmt.Errorf("parse PKIX public key: %w", err) - } - parsed, ok := key.(*rsa.PublicKey) - if !ok { - return fmt.Errorf("public key is not RSA") - } - publicKey = parsed - case "RSA PUBLIC KEY": - key, err := x509.ParsePKCS1PublicKey(block.Bytes) - if err != nil { - return fmt.Errorf("parse PKCS#1 public key: %w", err) - } - publicKey = key - default: - return fmt.Errorf("unsupported public key type: %s", block.Type) + return nil, err } - signature, err := base64.StdEncoding.DecodeString(signaturePart) + type queryShape struct { + Stores []WaffoPancakeCatalogStore `json:"stores"` + } + // `limit: 100` because the API returns a single store when limit is + // omitted, even for multi-store merchants. Bump to paginated fetches + // (via `offset`) if real catalogs ever cross the cap. + resp, err := pancake.GraphQLQuery[queryShape](ctx, client, pancake.GraphQLParams{ + Query: `query { + stores(limit: 100) { + id + name + status + prodEnabled + onetimeProducts { + id + name + status + } + } + }`, + }) if err != nil { - return fmt.Errorf("decode webhook signature: %w", err) - } - - digest := sha256.Sum256([]byte(signatureInput)) - if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil { - return fmt.Errorf("verify webhook signature: %w", err) + return nil, fmt.Errorf("query Waffo Pancake catalog: %w", err) + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("waffo pancake catalog query returned %d errors: %s", + len(resp.Errors), resp.Errors[0].Message) + } + // Drop non-active products. Operators should only see items they can + // actually bind without later hitting "product unavailable" at checkout. + stores := resp.Data.Stores + for i := range stores { + active := stores[i].OnetimeProducts[:0] + for _, p := range stores[i].OnetimeProducts { + if strings.EqualFold(strings.TrimSpace(p.Status), "active") { + active = append(active, p) + } + } + stores[i].OnetimeProducts = active } - return nil + return &WaffoPancakeCatalog{Stores: stores}, nil } diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index eeb1012b0766..43df1bf5fa80 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -8,7 +8,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/setting" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -29,7 +28,7 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { model.DB = db model.LOG_DB = db - require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{})) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.SubscriptionOrder{})) t.Cleanup(func() { sqlDB, err := db.DB() @@ -41,21 +40,6 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { return db } -func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T) { - var result waffoPancakeCreateSessionResponse - err := common.Unmarshal([]byte(`{ - "data": { - "sessionId": "cs_550e8400-e29b-41d4-a716-446655440000", - "checkoutUrl": "https://checkout.waffo.ai/my-store-abc123/checkout/cs_550e8400-e29b-41d4-a716-446655440000", - "expiresAt": "2026-01-22T10:30:00.000Z" - } - }`), &result) - require.NoError(t, err) - require.NotNil(t, result.Data) - require.Equal(t, "cs_550e8400-e29b-41d4-a716-446655440000", result.Data.SessionID) - require.Empty(t, result.Data.OrderID) -} - func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) { db := setupWaffoPancakeTestDB(t) @@ -64,21 +48,79 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te Amount: 10, Money: 29, TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", - PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) - tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ - Data: waffoPancakeWebhookData{ - OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId), }, }) require.NoError(t, err) require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo) } +func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 42, + Amount: 10, + Money: 29, + TradeNo: "ORD_identity_mismatch_case", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + // Webhook reports the right order but a different buyer — could be a + // crossed-wires bug or a tampered payload. Either way: reject. + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "ORD_identity_mismatch_case", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + +func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 7, + Amount: 10, + Money: 29, + TradeNo: "ORD_missing_identity", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + // An empty MerchantProvidedBuyerIdentity means the order was either created + // via the (now-deprecated) anonymous flow or the field was stripped — also + // reject so that we never credit anonymous orders to a specific user. + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "ORD_missing_identity", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) { db := setupWaffoPancakeTestDB(t) @@ -95,14 +137,15 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. Amount: 10, Money: 29, TradeNo: "WAFFO_PANCAKE-42-123456-abc123", - PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) - tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ - Data: waffoPancakeWebhookData{ + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ OrderID: "ORD_unknown", BuyerEmail: user.Email, Amount: "29.00", @@ -112,46 +155,107 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. require.Empty(t, tradeNo) } -func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) { - originalSandbox := setting.WaffoPancakeSandbox - t.Cleanup(func() { - setting.WaffoPancakeSandbox = originalSandbox - }) +// Parity tests for ResolveWaffoPancakeSubscriptionTradeNo — same four cases +// as the TopUp resolver above, exercised against SubscriptionOrder records. +// Drift between the two webhook flows is a real risk because they share +// the same buyer-identity defence-in-depth pattern. - testCases := []struct { - name string - payload string - expected string - sandbox bool - }{ - { - name: "test mode", - payload: `{"mode":"test"}`, - expected: "test", - }, - { - name: "prod mode", - payload: `{"mode":"prod"}`, - expected: "prod", - }, - { - name: "missing mode falls back to sandbox", - payload: `{}`, - expected: "test", - sandbox: true, +func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 1, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId), }, - { - name: "invalid mode falls back to prod", - payload: `{"mode":"staging"}`, - expected: "prod", + }) + require.NoError(t, err) + require.Equal(t, "WAFFO_PANCAKE_SUB-1-1700000000-abc123", tradeNo) +} + +func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 42, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-42-mismatch", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-42-mismatch", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + +func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 7, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-7-missing-identity", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - setting.WaffoPancakeSandbox = tc.sandbox - environment := resolveWaffoPancakeWebhookEnvironment(tc.payload) - require.Equal(t, tc.expected, environment) - }) +func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 42, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-42-real-order", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-unknown", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) } diff --git a/setting/payment_waffo_pancake.go b/setting/payment_waffo_pancake.go index d655059a9a20..32396aa67236 100644 --- a/setting/payment_waffo_pancake.go +++ b/setting/payment_waffo_pancake.go @@ -1,16 +1,15 @@ package setting +// Waffo Pancake hosted checkout configuration. Gateway is enabled once +// MerchantID + PrivateKey + ProductID are populated (no separate Enabled +// flag, matching Stripe / Creem). StoreID + ProductID are operator-bound +// via SaveWaffoPancakeConfig. var ( - WaffoPancakeEnabled bool - WaffoPancakeSandbox bool - WaffoPancakeMerchantID string - WaffoPancakePrivateKey string - WaffoPancakeWebhookPublicKey string - WaffoPancakeWebhookTestKey string - WaffoPancakeStoreID string - WaffoPancakeProductID string - WaffoPancakeReturnURL string - WaffoPancakeCurrency string = "USD" - WaffoPancakeUnitPrice float64 = 1.0 - WaffoPancakeMinTopUp int = 1 + WaffoPancakeMerchantID string + WaffoPancakePrivateKey string + WaffoPancakeReturnURL string + WaffoPancakeUnitPrice float64 = 1.0 + WaffoPancakeMinTopUp int = 1 + WaffoPancakeStoreID string + WaffoPancakeProductID string ) diff --git a/web/classic/public/waffo-logo-dark.svg b/web/classic/public/waffo-logo-dark.svg new file mode 100644 index 000000000000..18b5df03c621 --- /dev/null +++ b/web/classic/public/waffo-logo-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/classic/public/waffo-logo-light.svg b/web/classic/public/waffo-logo-light.svg new file mode 100644 index 000000000000..a7bdce05a8ed --- /dev/null +++ b/web/classic/public/waffo-logo-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/classic/src/components/settings/PaymentSetting.jsx b/web/classic/src/components/settings/PaymentSetting.jsx index 362473e2721e..bb9b2dfdfbf2 100644 --- a/web/classic/src/components/settings/PaymentSetting.jsx +++ b/web/classic/src/components/settings/PaymentSetting.jsx @@ -53,16 +53,9 @@ const PaymentSetting = () => { StripeMinTopUp: 1, StripePromotionCodesEnabled: false, - WaffoPancakeEnabled: false, - WaffoPancakeSandbox: false, WaffoPancakeMerchantID: '', WaffoPancakePrivateKey: '', - WaffoPancakeStoreID: '', - WaffoPancakeProductID: '', WaffoPancakeReturnURL: '', - WaffoPancakeCurrency: 'USD', - WaffoPancakeUnitPrice: 1.0, - WaffoPancakeMinTopUp: 1, 'payment_setting.compliance_confirmed': false, 'payment_setting.compliance_terms_version': '', 'payment_setting.compliance_confirmed_at': 0, @@ -171,21 +164,13 @@ const PaymentSetting = () => { case 'MinTopUp': case 'StripeUnitPrice': case 'StripeMinTopUp': - case 'WaffoPancakeUnitPrice': - case 'WaffoPancakeMinTopUp': newInputs[item.key] = parseFloat(item.value); break; case 'WaffoPancakeMerchantID': case 'WaffoPancakePrivateKey': - case 'WaffoPancakeStoreID': - case 'WaffoPancakeProductID': case 'WaffoPancakeReturnURL': - case 'WaffoPancakeCurrency': newInputs[item.key] = item.value; break; - case 'WaffoPancakeSandbox': - newInputs[item.key] = toBoolean(item.value); - break; default: if (item.key.endsWith('Enabled')) { newInputs[item.key] = toBoolean(item.value); @@ -320,6 +305,13 @@ const PaymentSetting = () => { hideSectionTitle /> + + + { hideSectionTitle /> - {/**/} - {/* */} - {/**/}
diff --git a/web/classic/src/components/topup/RechargeCard.jsx b/web/classic/src/components/topup/RechargeCard.jsx index f89d8ed7e9b2..4fe2035a1a35 100644 --- a/web/classic/src/components/topup/RechargeCard.jsx +++ b/web/classic/src/components/topup/RechargeCard.jsx @@ -47,6 +47,7 @@ import { } from 'lucide-react'; import { IconGift } from '@douyinfe/semi-icons'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; +import { useActualTheme } from '../../context/Theme'; import { getCurrencyConfig } from '../../helpers/render'; import SubscriptionPlansCard from './SubscriptionPlansCard'; @@ -102,6 +103,7 @@ const RechargeCard = ({ const redeemFormApiRef = useRef(null); const initialTabSetRef = useRef(false); const showAmountSkeleton = useMinimumLoadingTime(amountLoading); + const actualTheme = useActualTheme(); const [activeTab, setActiveTab] = useState('topup'); const shouldShowSubscription = !subscriptionLoading && subscriptionPlans.length > 0; @@ -355,9 +357,18 @@ const RechargeCard = ({ }} /> ) : payMethod.type === 'waffo_pancake' ? ( - ) : ( { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); @@ -454,8 +471,12 @@ const TopUp = () => { const { message, data } = res.data; if (message === 'success') { const checkoutUrl = data?.checkout_url || ''; - if (checkoutUrl) { - window.open(checkoutUrl, '_blank'); + if (checkoutUrl && isSafeHttpCheckoutUrl(checkoutUrl)) { + // In-tab redirect (not window.open) — popup blocker fires after + // the await loses user-gesture context. + window.location.href = checkoutUrl; + } else if (checkoutUrl) { + showError(t('支付跳转地址不安全')); } else { showError(t('支付请求失败')); } diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index ea6bca1b4c7e..17511d2a5552 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -1720,6 +1720,7 @@ "支付渠道": "Payment Channels", "支付设置": "Payment", "支付请求失败": "Payment request failed", + "支付跳转地址不安全": "Unsafe payment redirect URL", "支付返回地址": "Return URL", "支付金额": "Payment Amount", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index 88ac70c139f9..b70e8ffb955c 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -1154,6 +1154,7 @@ "支付方式": "支付方式", "支付设置": "支付设置", "支付请求失败": "支付请求失败", + "支付跳转地址不安全": "支付跳转地址不安全", "支付金额": "支付金额", "支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。", diff --git a/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx b/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx index 202576dff465..afe3fa0fb073 100644 --- a/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx +++ b/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx @@ -26,25 +26,14 @@ import { showSuccess, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; -import { BookOpen, TriangleAlert } from 'lucide-react'; +import { BookOpen } from 'lucide-react'; const defaultInputs = { - WaffoPancakeEnabled: false, - WaffoPancakeSandbox: false, WaffoPancakeMerchantID: '', WaffoPancakePrivateKey: '', - WaffoPancakeWebhookPublicKey: '', - WaffoPancakeWebhookTestKey: '', - WaffoPancakeStoreID: '', - WaffoPancakeProductID: '', WaffoPancakeReturnURL: '', - WaffoPancakeCurrency: 'USD', - WaffoPancakeUnitPrice: 1.0, - WaffoPancakeMinTopUp: 1, }; -const toBoolean = (value) => value === true || value === 'true'; - export default function SettingsPaymentGatewayWaffoPancake(props) { const { t } = useTranslation(); const sectionTitle = props.hideSectionTitle @@ -58,26 +47,9 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { if (!props.options || !formApiRef.current) return; const currentInputs = { - WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled), - WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox), WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '', WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '', - WaffoPancakeWebhookPublicKey: - props.options.WaffoPancakeWebhookPublicKey || '', - WaffoPancakeWebhookTestKey: - props.options.WaffoPancakeWebhookTestKey || '', - WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '', - WaffoPancakeProductID: props.options.WaffoPancakeProductID || '', WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '', - WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD', - WaffoPancakeUnitPrice: - props.options.WaffoPancakeUnitPrice !== undefined - ? parseFloat(props.options.WaffoPancakeUnitPrice) - : 1.0, - WaffoPancakeMinTopUp: - props.options.WaffoPancakeMinTopUp !== undefined - ? parseFloat(props.options.WaffoPancakeMinTopUp) - : 1, }; setInputs(currentInputs); @@ -93,90 +65,23 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { ...inputs, ...(formApiRef.current?.getValues?.() || {}), }; - values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled); - values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox); - const currentWebhookField = values.WaffoPancakeSandbox - ? 'WaffoPancakeWebhookTestKey' - : 'WaffoPancakeWebhookPublicKey'; - const currentWebhookLabel = values.WaffoPancakeSandbox - ? t('Webhook 公钥(测试环境)') - : t('Webhook 公钥(生产环境)'); - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) { - showError(t('请输入商户 ID')); - return; - } - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) { - showError(t('请输入 Store ID')); - return; - } - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) { - showError(t('请输入 Product ID')); - return; - } - - if ( - values.WaffoPancakeEnabled && - !String(values[currentWebhookField] || '').trim() - ) { - showError(currentWebhookLabel); - return; - } - - if ( - values.WaffoPancakeEnabled && - Number(values.WaffoPancakeUnitPrice) <= 0 - ) { - showError(t('充值价格必须大于 0')); - return; - } - - if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) { - showError(t('最低充值美元数量必须大于 0')); - return; - } setLoading(true); try { + // Classic admin only persists the three operator-typed fields. + // Store/Product binding is handled exclusively by the default + // frontend's catalog flow (see waffo-pancake-settings-section.tsx) + // because picking entities from a live catalog needs the Select + + // dependent-dropdown UX that the classic Semi-UI page doesn't have. const options = [ - { - key: 'WaffoPancakeEnabled', - value: values.WaffoPancakeEnabled ? 'true' : 'false', - }, - { - key: 'WaffoPancakeSandbox', - value: values.WaffoPancakeSandbox ? 'true' : 'false', - }, { key: 'WaffoPancakeMerchantID', value: values.WaffoPancakeMerchantID || '', }, - { - key: 'WaffoPancakeStoreID', - value: values.WaffoPancakeStoreID || '', - }, - { - key: 'WaffoPancakeProductID', - value: values.WaffoPancakeProductID || '', - }, { key: 'WaffoPancakeReturnURL', value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''), }, - { - key: 'WaffoPancakeCurrency', - value: values.WaffoPancakeCurrency || 'USD', - }, - { - key: 'WaffoPancakeUnitPrice', - value: String(values.WaffoPancakeUnitPrice), - }, - { - key: 'WaffoPancakeMinTopUp', - value: String(values.WaffoPancakeMinTopUp), - }, ]; if ((values.WaffoPancakePrivateKey || '').trim()) { @@ -186,20 +91,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { }); } - if ((values.WaffoPancakeWebhookPublicKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookPublicKey', - value: values.WaffoPancakeWebhookPublicKey, - }); - } - - if ((values.WaffoPancakeWebhookTestKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookTestKey', - value: values.WaffoPancakeWebhookTestKey, - }); - } - const results = await Promise.all( options.map((opt) => API.put('/api/option/', { @@ -237,103 +128,43 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { icon={} description={ <> - Waffo Pancake 的商户、商品和签名密钥请 + Waffo Pancake 商户 ID 与私钥请在 - 点击此处 + Waffo Pancake 控制台 - 获取,建议先在测试环境完成联调。 + 获取,保存后系统会自动在该商户名下创建 Store + Product,无需手动配置; + 环境(test / 生产)由你粘贴的 API 私钥本身决定。 + 请在 Pancake 控制台把下面两个回调地址分别注册到 Test Mode 和 Production Mode + 两个 webhook 位置,分开走避免测试流量污染生产数据:
- {t('回调地址')}: + {t('Test 回调地址')}: {props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')} - /api/waffo-pancake/webhook + /api/waffo-pancake/webhook/test +
+ {t('Production 回调地址')}: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/waffo-pancake/webhook/prod } style={{ marginBottom: 12 }} /> - } - description={t( - '请确认 Merchant、Store、Product 和所选环境密钥一致。', - )} - style={{ marginBottom: 16 }} - /> - - - - - - - - - - - - - + - - - - - - - - - - - - @@ -341,7 +172,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { field='WaffoPancakeReturnURL' label={t('支付返回地址')} placeholder={t('例如:https://example.com/console/topup')} - extraText={t('留空则自动使用当前站点的默认充值页地址')} /> @@ -350,57 +180,18 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} style={{ marginTop: 16 }} > - - - - + - - - - - - - - - diff --git a/web/default/public/waffo-logo-dark.svg b/web/default/public/waffo-logo-dark.svg new file mode 100644 index 000000000000..18b5df03c621 --- /dev/null +++ b/web/default/public/waffo-logo-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/default/public/waffo-logo-light.svg b/web/default/public/waffo-logo-light.svg new file mode 100644 index 000000000000..a7bdce05a8ed --- /dev/null +++ b/web/default/public/waffo-logo-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/default/src/components/layout/components/footer.tsx b/web/default/src/components/layout/components/footer.tsx index b27e2f9554ae..4e18e6724c41 100644 --- a/web/default/src/components/layout/components/footer.tsx +++ b/web/default/src/components/layout/components/footer.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useMemo } from 'react' +import { Fragment, useMemo } from 'react' import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { useSystemConfig } from '@/hooks/use-system-config' +import { useStatus } from '@/hooks/use-status' interface FooterLink { text: string @@ -74,23 +75,75 @@ function FooterLinkItem(props: { link: FooterLink }) { ) } -function ProjectAttribution(props: { currentYear: number }) { +// Renders User Agreement / Privacy Policy links inline with the parent's +// copyright row when either is configured in System Settings → Site. Emits +// fragmented siblings so the parent flex container's gap controls spacing. +function LegalLinks(props: { leadingSeparator?: boolean }) { const { t } = useTranslation() + const { status } = useStatus() + const items: { key: string; label: string; href: string }[] = [] + if (status?.user_agreement_enabled) { + items.push({ + key: 'user-agreement', + label: t('User Agreement'), + href: '/user-agreement', + }) + } + if (status?.privacy_policy_enabled) { + items.push({ + key: 'privacy-policy', + label: t('Privacy Policy'), + href: '/privacy-policy', + }) + } + if (items.length === 0) { + return null + } + return ( + <> + {items.map((item, index) => ( + + {(props.leadingSeparator || index > 0) && ( + + )} + + {item.label} + + + ))} + + ) +} +// inline=true returns just the inner span for composition in a parent flex +// row. inline=false wraps in a centered/right-aligned div (default). +function ProjectAttribution(props: { currentYear: number; inline?: boolean }) { + const { t } = useTranslation() + const content = ( + + © {props.currentYear}{' '} + + {t('New API')} + + . {t(NEW_API_FOOTER_ATTRIBUTION_KEY)} + + ) + if (props.inline) { + return content + } return (
- - © {props.currentYear}{' '} - - {t('New API')} - - . {t(NEW_API_FOOTER_ATTRIBUTION_KEY)} - + {content}
) } @@ -182,8 +235,9 @@ export function Footer(props: FooterProps) { className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left' dangerouslySetInnerHTML={{ __html: footerHtml }} /> -
- +
+ +
@@ -235,12 +289,16 @@ export function Footer(props: FooterProps) { )} - {/* Bottom section */} -
-

- © {currentYear} {displayName}.{' '} - {props.copyright ?? t('footer.defaultCopyright')} -

+ {/* Copyright + optional legal links inline on the left, project + attribution on the right; wraps on narrow screens. */} +
+
+ + © {currentYear} {displayName}.{' '} + {props.copyright ?? t('footer.defaultCopyright')} + + +
diff --git a/web/default/src/features/subscriptions/api.ts b/web/default/src/features/subscriptions/api.ts index 653b6f79c7e4..23196b4fa97a 100644 --- a/web/default/src/features/subscriptions/api.ts +++ b/web/default/src/features/subscriptions/api.ts @@ -122,6 +122,42 @@ export async function paySubscriptionCreem( return res.data } +export async function paySubscriptionWaffoPancake( + data: SubscriptionPayRequest +): Promise { + const res = await api.post('/api/subscription/waffo-pancake/pay', data) + return res.data +} + +// Mints a Pancake OnetimeProduct (see controller for the OnetimeProduct vs +// SubscriptionProduct rationale) using persisted creds + StoreID. +export async function createWaffoPancakeSubscriptionProduct(data: { + name: string + amount: string +}): Promise< + ApiResponse<{ product_id: string; product_name: string; store_id: string }> +> { + const res = await api.post( + '/api/option/waffo-pancake/subscription-product', + data + ) + return res.data +} + +// Returns the OnetimeProducts in the saved Pancake store; empty when the +// gateway isn't fully configured. +export async function listWaffoPancakeSubscriptionProductOptions(): Promise< + ApiResponse<{ + store_id: string + products: { id: string; name: string; status: string }[] + }> +> { + const res = await api.post( + '/api/option/waffo-pancake/subscription-product-options' + ) + return res.data +} + export async function paySubscriptionEpay( data: SubscriptionPayRequest & { payment_method: string } ): Promise { diff --git a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx index c2748294c9ee..c640b01d96a9 100644 --- a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx @@ -42,6 +42,7 @@ import { paySubscriptionStripe, paySubscriptionCreem, paySubscriptionEpay, + paySubscriptionWaffoPancake, } from '../../api' import { formatDuration, formatResetPeriod } from '../../lib' import type { PlanRecord } from '../../types' @@ -57,6 +58,7 @@ interface Props { plan: PlanRecord | null enableStripe?: boolean enableCreem?: boolean + enableWaffoPancake?: boolean enableOnlineTopUp?: boolean epayMethods?: PaymentMethod[] purchaseLimit?: number @@ -81,9 +83,11 @@ export function SubscriptionPurchaseDialog(props: Props) { const hasStripe = props.enableStripe && !!plan.stripe_price_id const hasCreem = props.enableCreem && !!plan.creem_product_id + const hasWaffoPancake = + props.enableWaffoPancake && !!plan.waffo_pancake_product_id const hasEpay = props.enableOnlineTopUp && (props.epayMethods || []).length > 0 - const hasAnyPayment = hasStripe || hasCreem || hasEpay + const hasAnyPayment = hasStripe || hasCreem || hasWaffoPancake || hasEpay const selectedEpayMethodLabel = (props.epayMethods || []).find((m) => m.type === selectedEpayMethod) ?.name || @@ -139,6 +143,29 @@ export function SubscriptionPurchaseDialog(props: Props) { } } + // In-tab redirect (not window.open) — user-gesture context is lost + // across the await, so a popup would be blocked. Same as the wallet hook. + const handlePayWaffoPancake = async () => { + setPaying(true) + try { + const res = await paySubscriptionWaffoPancake({ plan_id: plan.id }) + if (res.message === 'success' && res.data?.checkout_url) { + toast.success(t('Redirecting to payment page...')) + window.location.href = res.data.checkout_url + } else { + toast.error( + res.message && res.message !== 'success' + ? res.message + : t('Payment request failed') + ) + } + } catch { + toast.error(t('Payment request failed')) + } finally { + setPaying(false) + } + } + const isSafari = typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent) @@ -262,7 +289,7 @@ export function SubscriptionPurchaseDialog(props: Props) {

{t('Select payment method')}

- {(hasStripe || hasCreem) && ( + {(hasStripe || hasCreem || hasWaffoPancake) && (
{hasStripe && ( + )}
)} {hasEpay && ( diff --git a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx index f478cb7539f3..a3e3d1c2fec7 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx @@ -162,6 +162,13 @@ export function useSubscriptionsColumns(): ColumnDef[] { {plan.creem_product_id && ( )} + {plan.waffo_pancake_product_id && ( + + )} ) }, diff --git a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx index 654d0ee8d5b9..a842a0426d94 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx @@ -51,7 +51,13 @@ import { SheetTitle, } from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' -import { createPlan, updatePlan, getGroups } from '../api' +import { + createPlan, + updatePlan, + getGroups, + createWaffoPancakeSubscriptionProduct, + listWaffoPancakeSubscriptionProductOptions, +} from '../api' import { getDurationUnitOptions, getResetPeriodOptions } from '../constants' import { getPlanFormSchema, @@ -79,6 +85,10 @@ export function SubscriptionsMutateDrawer({ const { triggerRefresh } = useSubscriptions() const [isSubmitting, setIsSubmitting] = useState(false) const [groupOptions, setGroupOptions] = useState([]) + const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false) + const [pancakeProducts, setPancakeProducts] = useState< + { id: string; name: string; status: string }[] + >([]) const schema = getPlanFormSchema(t) const form = useForm({ @@ -98,11 +108,35 @@ export function SubscriptionsMutateDrawer({ if (res.success) setGroupOptions(res.data || []) }) .catch(() => {}) + // Best-effort — empty list still lets the operator use "+ Create". + listWaffoPancakeSubscriptionProductOptions() + .then((res) => { + if ( + res.message === 'success' && + typeof res.data === 'object' && + res.data && + Array.isArray((res.data as { products?: unknown }).products) + ) { + setPancakeProducts( + (res.data as { products: typeof pancakeProducts }).products + ) + } else { + setPancakeProducts([]) + } + }) + .catch(() => setPancakeProducts([])) } }, [open, currentRow, form]) const durationUnit = form.watch('duration_unit') const resetPeriod = form.watch('quota_reset_period') + // Gate "+ Create on Pancake" on the same checks the mint handler runs. + const watchedTitle = form.watch('title') + const watchedPrice = form.watch('price_amount') + const pancakeCreateReady = + typeof watchedTitle === 'string' && + watchedTitle.trim().length > 0 && + Number(watchedPrice ?? 0) > 0 const onSubmit = async (values: PlanFormValues) => { setIsSubmitting(true) @@ -130,6 +164,72 @@ export function SubscriptionsMutateDrawer({ } } + // Mints a Pancake OnetimeProduct (not SubscriptionProduct — see + // controller) using persisted creds + the form's title/price, then + // pins the returned PROD_ ID into the form field. + const handleCreatePancakeProduct = async () => { + const title = form.getValues('title').trim() + const priceAmount = Number(form.getValues('price_amount') || 0) + if (!title) { + toast.error(t('Plan title is required')) + return + } + if (priceAmount <= 0) { + toast.error(t('Plan price must be greater than zero')) + return + } + setCreatingPancakeProduct(true) + try { + const res = await createWaffoPancakeSubscriptionProduct({ + name: title, + amount: priceAmount.toFixed(2), + }) + if ( + res.message === 'success' && + typeof res.data === 'object' && + res.data + ) { + const created = res.data as { product_id: string; product_name: string } + form.setValue('waffo_pancake_product_id', created.product_id, { + shouldDirty: true, + }) + // Refetch from GraphQL so the dropdown reflects authoritative state. + try { + const refresh = await listWaffoPancakeSubscriptionProductOptions() + if ( + refresh.message === 'success' && + typeof refresh.data === 'object' && + refresh.data && + Array.isArray((refresh.data as { products?: unknown }).products) + ) { + setPancakeProducts( + (refresh.data as { products: typeof pancakeProducts }).products + ) + } + } catch { + // Best-effort — form value already points at the new product; + // raw-ID fallback covers the missing label. + } + toast.success( + `${t('Waffo Pancake product created')}: ${created.product_id}` + ) + } else { + const reason = typeof res.data === 'string' ? res.data : undefined + toast.error( + reason + ? `${t('Waffo Pancake product creation failed')}: ${reason}` + : t('Waffo Pancake product creation failed') + ) + } + } catch (err) { + toast.error( + `${t('Waffo Pancake product creation failed')}: ${err instanceof Error ? err.message : String(err)}` + ) + } finally { + setCreatingPancakeProduct(false) + } + } + const durationUnitOpts = getDurationUnitOptions(t) const resetPeriodOpts = getResetPeriodOptions(t) @@ -546,6 +646,67 @@ export function SubscriptionsMutateDrawer({ )} /> + + { + // Raw-ID fallback for IDs not yet in the catalog. + const items = pancakeProducts.map((p) => ({ + value: p.id, + label: `${p.name} (${p.id})`, + })) + if ( + field.value && + !pancakeProducts.some((p) => p.id === field.value) + ) { + items.push({ value: field.value, label: field.value }) + } + return ( + + Waffo Pancake Product ID +
+ + +
+ + {t( + 'Creates a Pancake product in the saved store using this plan’s title and price. Requires Waffo Pancake to be fully configured in Payment settings first.' + )} + + +
+ ) + }} + /> diff --git a/web/default/src/features/subscriptions/lib/plan-form.ts b/web/default/src/features/subscriptions/lib/plan-form.ts index a4f212078245..6dde24e2fc12 100644 --- a/web/default/src/features/subscriptions/lib/plan-form.ts +++ b/web/default/src/features/subscriptions/lib/plan-form.ts @@ -43,6 +43,7 @@ export function getPlanFormSchema(t: TFunction) { upgrade_group: z.string().optional(), stripe_price_id: z.string().optional(), creem_product_id: z.string().optional(), + waffo_pancake_product_id: z.string().optional(), }) } @@ -64,6 +65,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = { upgrade_group: '', stripe_price_id: '', creem_product_id: '', + waffo_pancake_product_id: '', } export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { @@ -83,6 +85,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { upgrade_group: plan.upgrade_group || '', stripe_price_id: plan.stripe_price_id || '', creem_product_id: plan.creem_product_id || '', + waffo_pancake_product_id: plan.waffo_pancake_product_id || '', } } diff --git a/web/default/src/features/subscriptions/types.ts b/web/default/src/features/subscriptions/types.ts index 43148409e701..29f88394702e 100644 --- a/web/default/src/features/subscriptions/types.ts +++ b/web/default/src/features/subscriptions/types.ts @@ -40,6 +40,7 @@ export const subscriptionPlanSchema = z.object({ upgrade_group: z.string().optional(), stripe_price_id: z.string().optional(), creem_product_id: z.string().optional(), + waffo_pancake_product_id: z.string().optional(), }) export type SubscriptionPlan = z.infer @@ -94,8 +95,17 @@ export interface SubscriptionPayResponse { success: boolean message?: string data?: { + // Stripe-style hosted checkout link. pay_link?: string + // Waffo Pancake / Creem hosted checkout URL. checkout_url?: string + // Pancake-only: order metadata + self-service buyer session token, + // surfaced for future flows (refund / cancel from new-api's own UI). + session_id?: string + expires_at?: number | string + order_id?: string + token?: string + token_expires_at?: number | string } url?: string } diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 3b006f772e54..93817224015c 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -96,18 +96,11 @@ const defaultBillingSettings: BillingSettings = { WaffoNotifyUrl: '', WaffoReturnUrl: '', WaffoPayMethods: '[]', - WaffoPancakeEnabled: false, - WaffoPancakeSandbox: false, WaffoPancakeMerchantID: '', WaffoPancakePrivateKey: '', - WaffoPancakeWebhookPublicKey: '', - WaffoPancakeWebhookTestKey: '', + WaffoPancakeReturnURL: '', WaffoPancakeStoreID: '', WaffoPancakeProductID: '', - WaffoPancakeReturnURL: '', - WaffoPancakeCurrency: 'USD', - WaffoPancakeUnitPrice: 1, - WaffoPancakeMinTopUp: 1, 'checkin_setting.enabled': false, 'checkin_setting.min_quota': 1000, 'checkin_setting.max_quota': 10000, diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index ee829e23cf07..2e43d66e9bea 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -177,20 +177,12 @@ const BILLING_SECTIONS = [ WaffoPayMethods: settings.WaffoPayMethods ?? '[]', }} waffoPancakeDefaultValues={{ - WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false, - WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false, WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '', WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '', - WaffoPancakeWebhookPublicKey: - settings.WaffoPancakeWebhookPublicKey ?? '', - WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '', - WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '', - WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '', WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '', - WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD', - WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1, - WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1, }} + waffoPancakeProvisionedStoreID={settings.WaffoPancakeStoreID ?? ''} + waffoPancakeProvisionedProductID={settings.WaffoPancakeProductID ?? ''} complianceDefaults={{ confirmed: settings['payment_setting.compliance_confirmed'] ?? false, termsVersion: diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx index e53721f0657e..96add9aeba4f 100644 --- a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx @@ -149,6 +149,8 @@ type PaymentSettingsSectionProps = { defaultValues: PaymentFormValues waffoDefaultValues: WaffoSettingsValues waffoPancakeDefaultValues: WaffoPancakeSettingsValues + waffoPancakeProvisionedStoreID?: string + waffoPancakeProvisionedProductID?: string complianceDefaults: PaymentComplianceDefaults } @@ -156,6 +158,8 @@ export function PaymentSettingsSection({ defaultValues, waffoDefaultValues, waffoPancakeDefaultValues, + waffoPancakeProvisionedStoreID, + waffoPancakeProvisionedProductID, complianceDefaults, }: PaymentSettingsSectionProps) { const { t } = useTranslation() @@ -1468,11 +1472,15 @@ export function PaymentSettingsSection({ - + - + {/* eslint-enable react-hooks/refs */}
) diff --git a/web/default/src/features/system-settings/integrations/waffo-pancake-api.ts b/web/default/src/features/system-settings/integrations/waffo-pancake-api.ts new file mode 100644 index 000000000000..f0a6e1154a20 --- /dev/null +++ b/web/default/src/features/system-settings/integrations/waffo-pancake-api.ts @@ -0,0 +1,102 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +// Catalog / pair / save admin endpoints. Match +// controller/topup_waffo_pancake.go: empty body creds make the backend +// fall back to persisted OptionMap values, so returning admins don't +// have to re-paste the private key (stripped from GET /api/option/). + +export interface CatalogProduct { + id: string + name: string + status: string +} + +export interface CatalogStore { + id: string + name: string + status: string + prodEnabled: boolean + onetimeProducts: CatalogProduct[] +} + +export interface PairResult { + store_id: string + store_name: string + product_id: string + product_name: string +} + +export interface PairOrphanError { + error?: string + orphan_store?: boolean + store_id?: string + store_name?: string +} + +interface BackendBody { + message?: string + data?: T | string +} + +export type CatalogResponse = BackendBody<{ stores: CatalogStore[] }> +export type PairResponse = BackendBody +export type SaveResponse = BackendBody<{ product_id: string; store_id: string }> + +export async function listWaffoPancakeCatalog( + merchantID: string, + privateKey: string +): Promise { + const res = await api.post( + '/api/option/waffo-pancake/catalog', + { merchant_id: merchantID, private_key: privateKey } + ) + return res.data +} + +export async function createWaffoPancakePair(params: { + merchantID: string + privateKey: string + returnURL: string +}): Promise { + const res = await api.post('/api/option/waffo-pancake/pair', { + merchant_id: params.merchantID, + private_key: params.privateKey, + return_url: params.returnURL, + }) + return res.data +} + +export async function saveWaffoPancakeConfig(params: { + merchantID: string + privateKey: string + returnURL: string + storeID: string + productID: string +}): Promise { + const res = await api.post('/api/option/waffo-pancake/save', { + merchant_id: params.merchantID, + private_key: params.privateKey, + return_url: params.returnURL, + store_id: params.storeID, + product_id: params.productID, + }) + return res.data +} diff --git a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx index 73becb892196..8b0fa16a94ad 100644 --- a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx @@ -16,293 +16,714 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useState } from 'react' +import * as React from 'react' +import * as z from 'zod' import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Switch } from '@/components/ui/switch' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { SettingsSection } from '../components/settings-section' -import { useUpdateOption } from '../hooks/use-update-option' import { removeTrailingSlash } from './utils' +import { + type CatalogStore, + type PairOrphanError, + type PairResult, + createWaffoPancakePair, + listWaffoPancakeCatalog, + saveWaffoPancakeConfig, +} from './waffo-pancake-api' -export interface WaffoPancakeSettingsValues { - WaffoPancakeEnabled: boolean - WaffoPancakeSandbox: boolean - WaffoPancakeMerchantID: string - WaffoPancakePrivateKey: string - WaffoPancakeWebhookPublicKey: string - WaffoPancakeWebhookTestKey: string - WaffoPancakeStoreID: string - WaffoPancakeProductID: string +// Only operator-typed fields. Nothing else lands in OptionMap until Save. +const waffoPancakeSchema = z.object({ + WaffoPancakeMerchantID: z.string(), + WaffoPancakePrivateKey: z.string(), +}) + +export type WaffoPancakeSettingsValues = z.infer & { WaffoPancakeReturnURL: string - WaffoPancakeCurrency: string - WaffoPancakeUnitPrice: number - WaffoPancakeMinTopUp: number } interface Props { defaultValues: WaffoPancakeSettingsValues + provisionedStoreID?: string + provisionedProductID?: string } +const PANCAKE_DASHBOARD_URL = 'https://pancake.waffo.ai/merchant/dashboard' +const DEFAULT_NEW_STORE_NAME = 'new-api-store' +const DEFAULT_NEW_PRODUCT_NAME = 'new-api-charge-product' +const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}` + export function WaffoPancakeSettingsSection(props: Props) { const { t } = useTranslation() - const updateOption = useUpdateOption() - const [loading, setLoading] = useState(false) - const form = useForm({ - defaultValues: props.defaultValues, + + const [storeID, setStoreID] = React.useState( + props.provisionedStoreID ?? '' + ) + const [productID, setProductID] = React.useState( + props.provisionedProductID ?? '' + ) + + const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>( + 'idle' + ) + const [catalog, setCatalog] = React.useState([]) + // Seed dropdowns from saved bindings so they render on first paint instead + // of waiting for the async catalog fetch to confirm them. + const [chosenStoreID, setChosenStoreID] = React.useState( + props.provisionedStoreID ?? '' + ) + const [chosenProductID, setChosenProductID] = React.useState( + props.provisionedProductID ?? '' + ) + const [returnURL, setReturnURL] = React.useState( + props.defaultValues.WaffoPancakeReturnURL ?? '' + ) + const [creatingPair, setCreatingPair] = React.useState(false) + + const initialRef = React.useRef(props.defaultValues) + const defaultsSignature = React.useMemo( + () => JSON.stringify(props.defaultValues), + [props.defaultValues] + ) + + // "merchantID|privateKey" of the last verified pair; debounced verify + // skips when nothing changed. + const lastVerifiedSignature = React.useRef('') + const fetchSerialRef = React.useRef(0) + + const form = useForm({ + resolver: zodResolver(waffoPancakeSchema), + mode: 'onChange', + defaultValues: { + WaffoPancakeMerchantID: props.defaultValues.WaffoPancakeMerchantID, + WaffoPancakePrivateKey: props.defaultValues.WaffoPancakePrivateKey, + }, }) - useEffect(() => { - form.reset(props.defaultValues) - }, [props.defaultValues, form]) + // Mount-only — never re-sync from props after the first render. The + // backend strips PrivateKey from GET /api/option/, so a re-sync would + // wipe whatever the operator just typed. + const didMountRef = React.useRef(false) + React.useEffect(() => { + const parsed = JSON.parse(defaultsSignature) as WaffoPancakeSettingsValues + initialRef.current = parsed + if (didMountRef.current) return + didMountRef.current = true + form.reset({ + WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID, + WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey, + }) + setReturnURL(parsed.WaffoPancakeReturnURL ?? '') + lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}` + }, [defaultsSignature, form]) - const handleSave = async () => { - const values = form.getValues() - const enabled = !!values.WaffoPancakeEnabled - const sandbox = !!values.WaffoPancakeSandbox + React.useEffect(() => { + setStoreID(props.provisionedStoreID ?? '') + }, [props.provisionedStoreID]) - if (enabled && !values.WaffoPancakeMerchantID.trim()) { - toast.error(t('Merchant ID is required')) - return - } + React.useEffect(() => { + setProductID(props.provisionedProductID ?? '') + }, [props.provisionedProductID]) - if (enabled && !values.WaffoPancakeStoreID.trim()) { - toast.error(t('Store ID is required')) - return - } + const productsForChosenStore = React.useMemo(() => { + if (!chosenStoreID) return [] + return catalog.find((s) => s.id === chosenStoreID)?.onetimeProducts ?? [] + }, [catalog, chosenStoreID]) - if (enabled && !values.WaffoPancakeProductID.trim()) { - toast.error(t('Product ID is required')) - return + // Raw-ID fallback items render the trigger before the catalog loads or + // when the saved entity has been deleted upstream. + const storeSelectItems = React.useMemo(() => { + const items = catalog.map((s) => ({ + value: s.id, + label: `${s.name} (${s.id})`, + })) + if (chosenStoreID && !catalog.some((s) => s.id === chosenStoreID)) { + items.push({ value: chosenStoreID, label: chosenStoreID }) + } + return items + }, [catalog, chosenStoreID]) + const productSelectItems = React.useMemo(() => { + const items = productsForChosenStore.map((p) => ({ + value: p.id, + label: `${p.name} (${p.id})`, + })) + if ( + chosenProductID && + !productsForChosenStore.some((p) => p.id === chosenProductID) + ) { + items.push({ value: chosenProductID, label: chosenProductID }) } + return items + }, [productsForChosenStore, chosenProductID]) + + // Verifies typed creds against Pancake (via /catalog) and refreshes the + // dropdown options. `preselect` overrides the post-load anchor selection; + // omitting it defaults to: saved binding → first store with products. + const verifyAndFetchCatalog = React.useCallback( + async ( + merchantID: string, + privateKey: string, + preselect?: { storeID?: string; productID?: string } + ) => { + const serial = ++fetchSerialRef.current + let stores: CatalogStore[] = [] + try { + const body = await listWaffoPancakeCatalog(merchantID, privateKey) + if (serial !== fetchSerialRef.current) return + if ( + body?.message === 'success' && + typeof body.data === 'object' && + body.data + ) { + stores = (body.data as { stores: CatalogStore[] }).stores ?? [] + } else { + const reason = typeof body?.data === 'string' ? body.data : undefined + toast.error( + reason + ? `${t('Credentials verification failed')}: ${reason}` + : t( + 'Credentials verification failed — double-check Merchant ID and API private key.' + ) + ) + setPhase('idle') + return + } + } catch (err) { + if (serial !== fetchSerialRef.current) return + toast.error( + `${t('Credentials verification failed')}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + setPhase('idle') + return + } + if (serial !== fetchSerialRef.current) return + + setCatalog(stores) + if (preselect) { + setChosenStoreID(preselect.storeID ?? '') + setChosenProductID(preselect.productID ?? '') + } else { + // Default anchor: bound product if found, else first product of + // the first store with any — saves a click for new operators. + const boundStore = stores.find((s) => + s.onetimeProducts.some((p) => p.id === productID) + ) + if (boundStore && productID) { + setChosenStoreID(boundStore.id) + setChosenProductID(productID) + } else { + const storeWithProducts = stores.find( + (s) => s.onetimeProducts.length > 0 + ) + if (storeWithProducts) { + setChosenStoreID(storeWithProducts.id) + setChosenProductID(storeWithProducts.onetimeProducts[0].id) + } else { + setChosenStoreID('') + setChosenProductID('') + } + } + } + setPhase('idle') + }, + [productID, t] + ) + + const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || '' + const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || '' + React.useEffect(() => { + const m = watchedMerchantID.trim() + const k = watchedPrivateKey.trim() + if (!m || !k) return + const signature = `${m}|${k}` + if (signature === lastVerifiedSignature.current) return + const timer = setTimeout(() => { + lastVerifiedSignature.current = signature + setPhase('verifying') + void verifyAndFetchCatalog(m, k) + }, 800) + return () => clearTimeout(timer) + }, [watchedMerchantID, watchedPrivateKey, verifyAndFetchCatalog]) + + // Initial-load verify: GET /api/option/ strips PrivateKey so a returning + // admin opens the page with empty key. Send blank creds in the body — + // the catalog controller falls back to the persisted OptionMap creds. + const initialLoadRef = React.useRef(false) + React.useEffect(() => { + if (initialLoadRef.current) return + if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return + initialLoadRef.current = true + setPhase('verifying') + void verifyAndFetchCatalog('', '') + }, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog]) + + // Returns typed creds when the operator edited either field; otherwise + // blanks so the backend falls back to persisted creds. Without this, + // returning admins (saved merchant ID but empty key field) would send + // a mixed-state body that the backend rejects. + const readCreds = () => { + const formMerchant = ( + form.getValues('WaffoPancakeMerchantID') || '' + ).trim() + const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() + const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim() + const edited = formMerchant !== saved || formKey.length > 0 + if (!edited) return { merchantID: '', privateKey: '' } + return { merchantID: formMerchant, privateKey: formKey } + } - const requiredWebhookKey = sandbox - ? values.WaffoPancakeWebhookTestKey - : values.WaffoPancakeWebhookPublicKey - if (enabled && !String(requiredWebhookKey || '').trim()) { + // The minted product's SuccessURL is pinned to the current Return URL + // field, so we prompt before creating when that field is empty. + const handleCreatePair = async () => { + if (!credsReady) { toast.error( - sandbox - ? t('Webhook public key (sandbox) is required') - : t('Webhook public key (production) is required') + t('Fill in both Merchant ID and API Private Key before creating.') ) return } - - if (enabled && Number(values.WaffoPancakeUnitPrice) <= 0) { - toast.error(t('Unit price must be greater than 0')) - return - } - - if (enabled && Number(values.WaffoPancakeMinTopUp) < 1) { - toast.error(t('Minimum top-up amount must be at least 1')) - return + const { merchantID, privateKey } = readCreds() + const trimmedReturn = removeTrailingSlash(returnURL.trim()) + if (!trimmedReturn) { + if ( + !window.confirm( + t( + 'Payment return URL is empty. Create the product without a SuccessURL redirect?' + ) + ) + ) { + return + } } - - setLoading(true) + setCreatingPair(true) try { - const options: { key: string; value: string }[] = [ - { key: 'WaffoPancakeEnabled', value: enabled ? 'true' : 'false' }, - { key: 'WaffoPancakeSandbox', value: sandbox ? 'true' : 'false' }, - { - key: 'WaffoPancakeMerchantID', - value: values.WaffoPancakeMerchantID || '', - }, - { - key: 'WaffoPancakeStoreID', - value: values.WaffoPancakeStoreID || '', - }, - { - key: 'WaffoPancakeProductID', - value: values.WaffoPancakeProductID || '', - }, - { - key: 'WaffoPancakeReturnURL', - value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''), - }, - { - key: 'WaffoPancakeCurrency', - value: values.WaffoPancakeCurrency || 'USD', - }, - { - key: 'WaffoPancakeUnitPrice', - value: String(values.WaffoPancakeUnitPrice ?? 1), - }, - { - key: 'WaffoPancakeMinTopUp', - value: String(values.WaffoPancakeMinTopUp ?? 1), - }, - ] - - if ((values.WaffoPancakePrivateKey || '').trim()) { - options.push({ - key: 'WaffoPancakePrivateKey', - value: values.WaffoPancakePrivateKey, + const body = await createWaffoPancakePair({ + merchantID, + privateKey, + returnURL: trimmedReturn, + }) + if ( + body?.message === 'success' && + typeof body.data === 'object' && + body.data + ) { + const created = body.data as PairResult + // Refetch from GraphQL rather than trusting the response body so the + // dropdowns reflect authoritative state, then anchor on minted IDs. + setPhase('verifying') + await verifyAndFetchCatalog(merchantID, privateKey, { + storeID: created.store_id, + productID: created.product_id, }) + toast.success( + `${t('Store + product created')}: ${created.store_id} / ${created.product_id}` + ) + return } - - if ((values.WaffoPancakeWebhookPublicKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookPublicKey', - value: values.WaffoPancakeWebhookPublicKey, - }) - } - - if ((values.WaffoPancakeWebhookTestKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookTestKey', - value: values.WaffoPancakeWebhookTestKey, + const errData = + body && typeof body.data === 'object' && body.data !== null + ? (body.data as PairOrphanError) + : null + if (errData?.orphan_store && errData.store_id) { + setPhase('verifying') + await verifyAndFetchCatalog(merchantID, privateKey, { + storeID: errData.store_id, + productID: '', }) } + const reason = + errData?.error ?? + (typeof body?.data === 'string' ? body.data : undefined) + toast.error( + reason ? `${t('Creation failed')}: ${reason}` : t('Creation failed') + ) + } catch (err) { + toast.error( + `${t('Creation failed')}: ${err instanceof Error ? err.message : String(err)}` + ) + } finally { + setCreatingPair(false) + } + } - for (const option of options) { - await updateOption.mutateAsync(option) + const handleSave = async () => { + // Sends raw form values (not readCreds): SaveWaffoPancakeConfig already + // treats a blank PrivateKey as "keep existing", and MerchantID stays + // populated from props for returning admins. + const merchantID = ( + form.getValues('WaffoPancakeMerchantID') || '' + ).trim() + const privateKey = ( + form.getValues('WaffoPancakePrivateKey') || '' + ).trim() + if (!merchantID) { + toast.error(t('Merchant ID is required')) + return + } + if (!chosenStoreID || !chosenProductID) { + toast.error(t('Pick or create both a store and a product before saving.')) + return + } + setPhase('saving') + try { + const body = await saveWaffoPancakeConfig({ + merchantID, + privateKey, + returnURL: removeTrailingSlash(returnURL.trim()), + storeID: chosenStoreID, + productID: chosenProductID, + }) + if ( + body?.message === 'success' && + typeof body.data === 'object' && + body.data + ) { + const saved = body.data as { product_id: string; store_id: string } + setStoreID(saved.store_id) + setProductID(saved.product_id) + toast.success(t('Waffo Pancake settings saved')) + } else { + const reason = typeof body?.data === 'string' ? body.data : undefined + toast.error( + reason + ? `${t('Waffo Pancake save failed')}: ${reason}` + : t('Waffo Pancake save failed') + ) } - toast.success(t('Updated successfully')) - } catch { - toast.error(t('Update failed')) + } catch (err) { + toast.error( + `${t('Waffo Pancake save failed')}: ${ + err instanceof Error ? err.message : String(err) + }` + ) } finally { - setLoading(false) + setPhase('idle') } } + const verifying = phase === 'verifying' + const saving = phase === 'saving' + + // "Not edited" = MerchantID unchanged AND PrivateKey field blank, in + // which case the backend falls back to persisted creds. Otherwise we + // require both fields filled (mixed states would fail signature check). + const savedMerchantID = ( + props.defaultValues.WaffoPancakeMerchantID || '' + ).trim() + const formMerchantID = watchedMerchantID.trim() + const formPrivateKey = watchedPrivateKey.trim() + const credsEdited = + formMerchantID !== savedMerchantID || formPrivateKey.length > 0 + const hasSavedCreds = savedMerchantID.length > 0 + const credsReady = credsEdited + ? formMerchantID.length > 0 && formPrivateKey.length > 0 + : hasSavedCreds + const hasCatalog = catalog.length > 0 + + let bindStatusMessage: string + if (!credsReady) { + bindStatusMessage = t('Fill in the credentials above to begin.') + } else if (verifying) { + bindStatusMessage = t( + 'Verifying credentials and pulling stores from your Pancake account...' + ) + } else if (hasCatalog) { + bindStatusMessage = t( + 'Mint a fresh pair below — or pick an existing one further down. Click Save when ready.' + ) + } else { + bindStatusMessage = t( + 'No stores on this merchant yet. Set a return URL and click Create to mint your first pair.' + ) + } + return ( - - - +
+
+

{t('Waffo Pancake MoR')}

+

{t( - 'Obtain the merchant, store, product and signing keys from your Waffo dashboard. Webhook URL: /api/waffo-pancake/webhook' + 'Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.' )} - - - -

-
- - form.setValue('WaffoPancakeEnabled', value) - } - /> - -
-
- - form.setValue('WaffoPancakeSandbox', value) - } - /> - -
-
- - -
+

+
+ e.preventDefault()} + className='space-y-4' + data-no-autosubmit='true' + > + {/* Blue box — webhook configuration only. */} +
+

{t('Webhook Configuration:')}

+
    +
  • + {t('Webhook URL (Test):')}{' '} + + {'/api/waffo-pancake/webhook/test'} + +
  • +
  • + {t('Webhook URL (Production):')}{' '} + + {'/api/waffo-pancake/webhook/prod'} + +
  • +
  • + {t( + 'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.' + )} +
  • +
  • + {t('Configure at:')}{' '} + + {t('Waffo Pancake Dashboard')} + +
  • +
+
-
-
- - -
-
- - ( + + {t('Merchant ID')} + + field.onChange(event.target.value)} + /> + + + + )} /> -
-
- - -
-
-
-
- -