package controller import ( "fmt" "net/http" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/console_setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" ) var completionRatioMetaOptionKeys = []string{ "ModelPrice", "ModelRatio", "CompletionRatio", "CacheRatio", "CreateCacheRatio", "ImageRatio", "AudioRatio", "AudioCompletionRatio", "VideoRatio", "VideoCompletionRatio", "VideoPrice", "VideoPricingRules", "ChannelVideoPricingRules", "ImagePrice", "ImagePricingRules", "ChannelImagePricingRules", } // modelPricingOptionKeys 包含值格式为模型名→价格/倍率 JSON 对象的选项 key。 // 对这些 key 的更新会按模型粒度比较新旧值,仅记录实际变化的条目。 var modelPricingOptionKeys = map[string]bool{ "ModelPrice": true, "ModelRatio": true, "CompletionRatio": true, "CacheRatio": true, "CreateCacheRatio": true, "ImageRatio": true, "AudioRatio": true, "AudioCompletionRatio": true, "VideoRatio": true, "VideoCompletionRatio": true, "VideoPrice": true, "VideoPricingRules": true, "ChannelVideoPricingRules": true, "ImagePrice": true, "ImagePricingRules": true, "ChannelImagePricingRules": true, "GroupRatio": true, "ChannelModelPrice": true, "ChannelModelRatio": true, "GroupModelPrice": true, } // computeModelPricingDiff 比较模型定价类选项的新旧 JSON 值,返回仅包含变更条目的 FieldChange 列表。 // oldJSON 和 newJSON 应为形如 {"model-a": 1.5, "model-b": 0.8} 的 JSON 字符串。 func computeModelPricingDiff(oldJSON, newJSON string) []service.FieldChange { if oldJSON == "" { oldJSON = "{}" } if newJSON == "" { newJSON = "{}" } if strings.TrimSpace(oldJSON) == strings.TrimSpace(newJSON) { return nil } var oldMap, newMap map[string]any if err := common.UnmarshalJsonStr(oldJSON, &oldMap); err != nil { return nil } if err := common.UnmarshalJsonStr(newJSON, &newMap); err != nil { return nil } var changes []service.FieldChange // 检测修改和新增的条目 for k, newV := range newMap { oldV, existed := oldMap[k] newStr := formatPricingValue(newV) if !existed { changes = append(changes, service.FieldChange{ Field: k, OldValue: "", NewValue: newStr, }) } else { oldStr := formatPricingValue(oldV) if oldStr != newStr { changes = append(changes, service.FieldChange{ Field: k, OldValue: oldStr, NewValue: newStr, }) } } } // 检测删除的条目 for k, oldV := range oldMap { if _, stillExists := newMap[k]; !stillExists { changes = append(changes, service.FieldChange{ Field: k, OldValue: formatPricingValue(oldV), NewValue: "", }) } } return changes } // formatPricingValue 将定价值格式化为可读字符串。 func formatPricingValue(v any) string { switch val := v.(type) { case float64: if val == float64(int64(val)) { return fmt.Sprintf("%d", int64(val)) } return fmt.Sprintf("%g", val) case string: return val default: return fmt.Sprintf("%v", val) } } func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) { if strings.TrimSpace(raw) == "" { return } var parsed map[string]any if err := common.UnmarshalJsonStr(raw, &parsed); err != nil { return } for modelName := range parsed { modelNames[modelName] = struct{}{} } } func buildCompletionRatioMetaValue(optionValues map[string]string) string { modelNames := make(map[string]struct{}) for _, key := range completionRatioMetaOptionKeys { collectModelNamesFromOptionValue(optionValues[key], modelNames) } meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames)) for modelName := range modelNames { meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName) } jsonBytes, err := common.Marshal(meta) if err != nil { return "{}" } return string(jsonBytes) } func GetOptions(c *gin.Context) { // 已审核供应商仅返回其自有模型相关配置项,避免读取全局敏感配置。 if c.GetInt("role") < common.RoleAdminUser { ownedModels, err := collectSupplierOwnedModelNames(c.GetInt("id")) if err != nil { common.ApiError(c, err) return } options := make([]*model.Option, 0, len(supplierEditableModelOptionKeys)) common.OptionMapRWMutex.Lock() for key := range supplierEditableModelOptionKeys { value := strings.TrimSpace(common.Interface2String(common.OptionMap[key])) filteredValue, filterErr := filterModelJSONByOwnedModels(value, ownedModels) if filterErr != nil { continue } options = append(options, &model.Option{ Key: key, Value: filteredValue, }) } common.OptionMapRWMutex.Unlock() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": options, }) return } var options []*model.Option optionValues := make(map[string]string) common.OptionMapRWMutex.Lock() for k, v := range common.OptionMap { // YipayAppSecret 在循环结束后单独追加,以 operation_setting 为准并避免与 OptionMap 不同步 if k == "YipayAppSecret" { continue } // OSS AccessKey 与 Secret 在循环结束后单独追加(脱敏) if k == "oss_setting.access_key_id" || k == "oss_setting.access_key_secret" { continue } // 阿里云短信 AccessKey 在循环结束后单独追加(脱敏)。 if k == "SMSAccessKeyID" || k == "SMSAccessKeySecret" { continue } value := common.Interface2String(v) if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "secret") || strings.HasSuffix(k, "api_key") { continue } options = append(options, &model.Option{ Key: k, Value: value, }) for _, optionKey := range completionRatioMetaOptionKeys { if optionKey == k { optionValues[k] = value break } } } rawYipay := strings.TrimSpace(operation_setting.YipayAppSecret) if rawYipay == "" { if v, ok := common.OptionMap["YipayAppSecret"]; ok { rawYipay = strings.TrimSpace(common.Interface2String(v)) } } yipayDisp := "" if rawYipay != "" { yipayDisp = common.MaskCredentialForAdminDisplay(rawYipay) } options = append(options, &model.Option{ Key: "YipayAppSecret", Value: yipayDisp, }) rawOssID := strings.TrimSpace(operation_setting.GetOssSetting().AccessKeyID) if rawOssID == "" { if v, ok := common.OptionMap["oss_setting.access_key_id"]; ok { rawOssID = strings.TrimSpace(common.Interface2String(v)) } } ossIDDisp := "" if rawOssID != "" { ossIDDisp = common.MaskCredentialForAdminDisplay(rawOssID) } options = append(options, &model.Option{ Key: "oss_setting.access_key_id", Value: ossIDDisp, }) rawOssSecret := strings.TrimSpace(operation_setting.GetOssSetting().AccessKeySecret) if rawOssSecret == "" { if v, ok := common.OptionMap["oss_setting.access_key_secret"]; ok { rawOssSecret = strings.TrimSpace(common.Interface2String(v)) } } ossSecretDisp := "" if rawOssSecret != "" { ossSecretDisp = common.MaskCredentialForAdminDisplay(rawOssSecret) } options = append(options, &model.Option{ Key: "oss_setting.access_key_secret", Value: ossSecretDisp, }) rawSMSID := strings.TrimSpace(common.SMSAccessKeyID) if rawSMSID == "" { if v, ok := common.OptionMap["SMSAccessKeyID"]; ok { rawSMSID = strings.TrimSpace(common.Interface2String(v)) } } smsIDDisp := "" if rawSMSID != "" { smsIDDisp = common.MaskCredentialForAdminDisplay(rawSMSID) } options = append(options, &model.Option{ Key: "SMSAccessKeyID", Value: smsIDDisp, }) rawSMSSecret := strings.TrimSpace(common.SMSAccessKeySecret) if rawSMSSecret == "" { if v, ok := common.OptionMap["SMSAccessKeySecret"]; ok { rawSMSSecret = strings.TrimSpace(common.Interface2String(v)) } } smsSecretDisp := "" if rawSMSSecret != "" { smsSecretDisp = common.MaskCredentialForAdminDisplay(rawSMSSecret) } options = append(options, &model.Option{ Key: "SMSAccessKeySecret", Value: smsSecretDisp, }) common.OptionMapRWMutex.Unlock() options = append(options, &model.Option{ Key: "CompletionRatioMeta", Value: buildCompletionRatioMetaValue(optionValues), }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": options, }) } type OptionUpdateRequest struct { Key string `json:"key"` Value any `json:"value"` } func UpdateOption(c *gin.Context) { var option OptionUpdateRequest err := common.DecodeJson(c.Request.Body, &option) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "无效的参数", }) return } switch option.Value.(type) { case bool: option.Value = common.Interface2String(option.Value.(bool)) case float64: option.Value = common.Interface2String(option.Value.(float64)) case int: option.Value = common.Interface2String(option.Value.(int)) default: option.Value = fmt.Sprintf("%v", option.Value) } valStr := strings.TrimSpace(option.Value.(string)) // 已审核供应商仅可更新自己模型范围内的倍率相关配置,不可修改其他全局设置。 if c.GetInt("role") < common.RoleAdminUser { if _, ok := supplierEditableModelOptionKeys[option.Key]; !ok { c.JSON(http.StatusForbidden, gin.H{ "success": false, "message": "供应商仅可修改模型倍率相关配置", }) return } ownedModels, err := collectSupplierOwnedModelNames(c.GetInt("id")) if err != nil { common.ApiError(c, err) return } common.OptionMapRWMutex.Lock() currentValue := strings.TrimSpace(common.Interface2String(common.OptionMap[option.Key])) common.OptionMapRWMutex.Unlock() mergedValue, err := mergeModelJSONByOwnedModels(currentValue, valStr, ownedModels) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "配置格式错误,仅支持 JSON 对象", }) return } if err := model.UpdateOption(option.Key, mergedValue); err != nil { common.ApiError(c, err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } if option.Key == "YipayAppSecret" && strings.TrimSpace(operation_setting.YipayAppSecret) != "" { if valStr == common.MaskCredentialForAdminDisplay(operation_setting.YipayAppSecret) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } } if option.Key == "oss_setting.access_key_id" && strings.TrimSpace(operation_setting.GetOssSetting().AccessKeyID) != "" { if valStr == common.MaskCredentialForAdminDisplay(operation_setting.GetOssSetting().AccessKeyID) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } } if option.Key == "oss_setting.access_key_secret" && strings.TrimSpace(operation_setting.GetOssSetting().AccessKeySecret) != "" { if valStr == common.MaskCredentialForAdminDisplay(operation_setting.GetOssSetting().AccessKeySecret) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } } if option.Key == "SMSAccessKeySecret" && strings.TrimSpace(common.SMSAccessKeySecret) != "" { if valStr == common.MaskCredentialForAdminDisplay(common.SMSAccessKeySecret) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } } if option.Key == "SMSAccessKeyID" && strings.TrimSpace(common.SMSAccessKeyID) != "" { if valStr == common.MaskCredentialForAdminDisplay(common.SMSAccessKeyID) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) return } } switch option.Key { case "GitHubOAuthEnabled": if option.Value == "true" && common.GitHubClientId == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!", }) return } case "discord.enabled": if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!", }) return } case "oidc.enabled": if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!", }) return } case "LinuxDOOAuthEnabled": if option.Value == "true" && common.LinuxDOClientId == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!", }) return } case "EmailDomainRestrictionEnabled": if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!", }) return } case "WeChatAuthEnabled": if option.Value == "true" && common.WeChatServerAddress == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用微信登录,请先填入微信登录相关配置信息!", }) return } case "TurnstileCheckEnabled": if option.Value == "true" && common.TurnstileSiteKey == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!", }) return } case "TelegramOAuthEnabled": if option.Value == "true" && common.TelegramBotToken == "" { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!", }) return } case "GroupRatio": err = ratio_setting.CheckGroupRatio(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "ImageRatio": err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "图片倍率设置失败: " + err.Error(), }) return } case "AudioRatio": err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "音频倍率设置失败: " + err.Error(), }) return } case "AudioCompletionRatio": err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "音频补全倍率设置失败: " + err.Error(), }) return } case "VideoRatio": err = ratio_setting.UpdateVideoRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "视频倍率设置失败: " + err.Error(), }) return } case "VideoCompletionRatio": err = ratio_setting.UpdateVideoCompletionRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "视频输出倍率设置失败: " + err.Error(), }) return } case "VideoPrice": err = ratio_setting.UpdateVideoPriceByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "视频按次价格设置失败: " + err.Error(), }) return } case "VideoPricingRules": err = ratio_setting.UpdateVideoPricingRulesByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "视频规则价格设置失败: " + err.Error(), }) return } case "ChannelVideoPricingRules": err = ratio_setting.UpdateChannelVideoPricingRulesByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "渠道视频规则价格设置失败: " + err.Error(), }) return } case "ImagePrice": err = ratio_setting.UpdateImagePriceByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "图片按张价格设置失败: " + err.Error(), }) return } case "ImagePricingRules": err = ratio_setting.UpdateImagePricingRulesByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "图片规则价格设置失败: " + err.Error(), }) return } case "ChannelImagePricingRules": err = ratio_setting.UpdateChannelImagePricingRulesByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "渠道图片规则价格设置失败: " + err.Error(), }) return } case "CreateCacheRatio": err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "缓存创建倍率设置失败: " + err.Error(), }) return } case "ModelRequestRateLimitGroup": err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "RateLimitUserWhitelist": err = setting.CheckRateLimitUserWhitelistJSON(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "AutomaticDisableStatusCodes": _, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "AutomaticRetryStatusCodes": _, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "console_setting.api_info": err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "console_setting.announcements": err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "console_setting.faq": err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } case "console_setting.uptime_kuma_groups": err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), }) return } } // 捕获旧值,用于计算模型定价类选项的字段级变更 common.OptionMapRWMutex.Lock() oldVal := strings.TrimSpace(common.Interface2String(common.OptionMap[option.Key])) common.OptionMapRWMutex.Unlock() err = model.UpdateOption(option.Key, option.Value.(string)) if err != nil { common.ApiError(c, err) return } // 对模型定价类选项,仅记录实际发生变化的模型条目 if modelPricingOptionKeys[option.Key] { changes := computeModelPricingDiff(oldVal, valStr) if len(changes) > 0 { service.RecordUpdateWithDiff(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, changes, "") } } else { service.RecordUpdateOperation(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, "") } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) }