tokenFactory/controller/option.go

704 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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": "",
})
}