Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 97 additions & 14 deletions backend/internal/handler/admin/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
AIToolRewriteRules: dto.ParseAIToolRewriteRules(settings.AIToolRewriteRules),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
RiskControlEnabled: settings.RiskControlEnabled,
Expand Down Expand Up @@ -340,6 +341,67 @@ func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []serv
return result
}

func normalizeAIToolRewriteRules(input []dto.AIToolRewriteRule) ([]dto.AIToolRewriteRule, string) {
const (
maxAIToolRewriteRules = 50
maxAIToolRewriteRuleScopeLen = 32
maxAIToolRewriteRulePatternLen = 20 * 1024
)

if len(input) > maxAIToolRewriteRules {
return nil, "Too many AI tool rewrite rules (max 50)"
}

validPlatforms := map[string]struct{}{
"": {},
"anthropic": {},
"openai": {},
"gemini": {},
"antigravity": {},
}
validClients := map[string]struct{}{
"": {},
"claude": {},
"codex": {},
"codex-ws": {},
"gemini": {},
"opencode": {},
}

rules := make([]dto.AIToolRewriteRule, 0, len(input))
for _, rule := range input {
platform := strings.ToLower(strings.TrimSpace(rule.Platform))
client := strings.ToLower(strings.TrimSpace(rule.Client))
if len(platform) > maxAIToolRewriteRuleScopeLen {
return nil, "AI tool rewrite rule platform is too long (max 32 characters)"
}
if len(client) > maxAIToolRewriteRuleScopeLen {
return nil, "AI tool rewrite rule client is too long (max 32 characters)"
}
if _, ok := validPlatforms[platform]; !ok {
return nil, "AI tool rewrite rule platform is invalid"
}
if _, ok := validClients[client]; !ok {
return nil, "AI tool rewrite rule client is invalid"
}
if len(rule.Find) > maxAIToolRewriteRulePatternLen {
return nil, "AI tool rewrite rule find text is too long (max 20KB)"
}
if len(rule.Replace) > maxAIToolRewriteRulePatternLen {
return nil, "AI tool rewrite rule replace text is too long (max 20KB)"
}
rules = append(rules, dto.AIToolRewriteRule{
Enabled: rule.Enabled,
Platform: platform,
Client: client,
Find: rule.Find,
Replace: rule.Replace,
})
}

return rules, ""
}

// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
Expand Down Expand Up @@ -430,20 +492,21 @@ type UpdateSettingsRequest struct {
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`

// OEM设置
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
AIToolRewriteRules *[]dto.AIToolRewriteRule `json:"ai_tool_rewrite_rules"`

// 默认配置
DefaultConcurrency int `json:"default_concurrency"`
Expand Down Expand Up @@ -1220,6 +1283,21 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
customEndpointsJSON = string(endpointBytes)
}

aiToolRewriteRulesJSON := previousSettings.AIToolRewriteRules
if req.AIToolRewriteRules != nil {
rules, errMessage := normalizeAIToolRewriteRules(*req.AIToolRewriteRules)
if errMessage != "" {
response.BadRequest(c, errMessage)
return
}
ruleBytes, err := json.Marshal(rules)
if err != nil {
response.BadRequest(c, "Failed to serialize AI tool rewrite rules")
return
}
aiToolRewriteRulesJSON = string(ruleBytes)
}

// Ops metrics collector interval validation (seconds).
if req.OpsMetricsIntervalSeconds != nil {
v := *req.OpsMetricsIntervalSeconds
Expand Down Expand Up @@ -1360,6 +1438,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TablePageSizeOptions: req.TablePageSizeOptions,
CustomMenuItems: customMenuJSON,
CustomEndpoints: customEndpointsJSON,
AIToolRewriteRules: aiToolRewriteRulesJSON,
DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance,
AffiliateRebateRate: affiliateRebateRate,
Expand Down Expand Up @@ -1744,6 +1823,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TablePageSizeOptions: updatedSettings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
AIToolRewriteRules: dto.ParseAIToolRewriteRules(updatedSettings.AIToolRewriteRules),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
Expand Down Expand Up @@ -2157,6 +2237,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.CustomEndpoints != after.CustomEndpoints {
changed = append(changed, "custom_endpoints")
}
if before.AIToolRewriteRules != after.AIToolRewriteRules {
changed = append(changed, "ai_tool_rewrite_rules")
}
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
changed = append(changed, "enable_fingerprint_unification")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,129 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
}

func TestSettingHandler_UpdateSettings_PersistsAIToolRewriteRules(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{
values: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
service.SettingKeyAIToolRewriteRules: "[]",
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)

body := map[string]any{
"promo_code_enabled": true,
"ai_tool_rewrite_rules": []map[string]any{
{
"enabled": true,
"platform": " OpenAI ",
"client": " Codex ",
"find": `model = "gpt-5.4"`,
"replace": `model = "gpt-5.5"`,
},
},
}
rawBody, err := json.Marshal(body)
require.NoError(t, err)

rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")

handler.UpdateSettings(c)

require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, `[{"enabled":true,"platform":"openai","client":"codex","find":"model = \"gpt-5.4\"","replace":"model = \"gpt-5.5\""}]`, repo.values[service.SettingKeyAIToolRewriteRules])

var resp response.Response
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp.Data.(map[string]any)
require.True(t, ok)
rules, ok := data["ai_tool_rewrite_rules"].([]any)
require.True(t, ok)
require.Len(t, rules, 1)
rule, ok := rules[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "openai", rule["platform"])
require.Equal(t, "codex", rule["client"])
}

func TestSettingHandler_UpdateSettings_RejectsInvalidAIToolRewriteRules(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{
values: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
service.SettingKeyAIToolRewriteRules: `[{"enabled":true,"platform":"openai","client":"codex","find":"old","replace":"new"}]`,
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)

body := map[string]any{
"promo_code_enabled": true,
"ai_tool_rewrite_rules": []map[string]any{
{
"enabled": true,
"platform": "invalid",
"client": "codex",
"find": "old",
"replace": "new",
},
},
}
rawBody, err := json.Marshal(body)
require.NoError(t, err)

rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")

handler.UpdateSettings(c)

require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t, `[{"enabled":true,"platform":"openai","client":"codex","find":"old","replace":"new"}]`, repo.values[service.SettingKeyAIToolRewriteRules])
}

func TestSettingHandler_UpdateSettings_RejectsTooManyAIToolRewriteRules(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{
values: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
service.SettingKeyAIToolRewriteRules: "[]",
},
}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)

rules := make([]map[string]any, 51)
for i := range rules {
rules[i] = map[string]any{
"enabled": true,
"find": "old",
"replace": "new",
}
}
body := map[string]any{
"promo_code_enabled": true,
"ai_tool_rewrite_rules": rules,
}
rawBody, err := json.Marshal(body)
require.NoError(t, err)

rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
c.Request.Header.Set("Content-Type", "application/json")

handler.UpdateSettings(c)

require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, "[]", repo.values[service.SettingKeyAIToolRewriteRules])
}

func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{
Expand Down
53 changes: 39 additions & 14 deletions backend/internal/handler/dto/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ type CustomEndpoint struct {
Description string `json:"description"`
}

// AIToolRewriteRule 表示“使用密钥”弹窗配置内容的字符串替换规则。
type AIToolRewriteRule struct {
Enabled bool `json:"enabled"`
Platform string `json:"platform"`
Client string `json:"client"`
Find string `json:"find"`
Replace string `json:"replace"`
}

// SystemSettings represents the admin settings API response payload.
type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
Expand Down Expand Up @@ -107,20 +116,21 @@ type SystemSettings struct {
GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`

SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
AIToolRewriteRules []AIToolRewriteRule `json:"ai_tool_rewrite_rules"`

DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
Expand Down Expand Up @@ -260,6 +270,7 @@ type PublicSettings struct {
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
AIToolRewriteRules []AIToolRewriteRule `json:"ai_tool_rewrite_rules"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
Expand Down Expand Up @@ -395,3 +406,17 @@ func ParseCustomEndpoints(raw string) []CustomEndpoint {
}
return items
}

// ParseAIToolRewriteRules 将 JSON 字符串解析为 AI 工具配置替换规则。
// 输入为空或格式无效时返回空数组。
func ParseAIToolRewriteRules(raw string) []AIToolRewriteRule {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []AIToolRewriteRule{}
}
var items []AIToolRewriteRule
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []AIToolRewriteRule{}
}
return items
}
1 change: 1 addition & 0 deletions backend/internal/handler/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
AIToolRewriteRules: dto.ParseAIToolRewriteRules(settings.AIToolRewriteRules),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
Expand Down
Loading
Loading