From a2f238b3f84ca21885c485c87375ec2fe24d7aba Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 30 May 2026 22:41:21 +0800 Subject: [PATCH] refactor: replace APIKey with HasAPIKey in AIConfig and related components --- internal/pkg/dto/response/ai_response.go | 4 +- internal/pkg/dto/response/ai_response_test.go | 30 +++++++ internal/services/ai_config_service.go | 9 +- internal/services/ai_config_service_test.go | 82 +++++++++++++++++++ .../dashboard/ai-configs/_components/edit.tsx | 2 +- web/app/dashboard/ai-configs/page.tsx | 9 +- web/lib/api/admin.ts | 2 +- 7 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 internal/pkg/dto/response/ai_response_test.go create mode 100644 internal/services/ai_config_service_test.go diff --git a/internal/pkg/dto/response/ai_response.go b/internal/pkg/dto/response/ai_response.go index 853dede..c0e007c 100644 --- a/internal/pkg/dto/response/ai_response.go +++ b/internal/pkg/dto/response/ai_response.go @@ -30,7 +30,7 @@ type AIConfigResponse struct { Name string `json:"name"` Provider enums.AIProvider `json:"provider"` BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` + HasAPIKey bool `json:"hasApiKey"` ModelType enums.AIModelType `json:"modelType"` ModelName string `json:"modelName"` Dimension int `json:"dimension"` @@ -51,7 +51,7 @@ func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse { Name: item.Name, Provider: item.Provider, BaseURL: item.BaseURL, - APIKey: item.APIKey, + HasAPIKey: item.APIKey != "", ModelType: item.ModelType, ModelName: item.ModelName, Dimension: item.Dimension, diff --git a/internal/pkg/dto/response/ai_response_test.go b/internal/pkg/dto/response/ai_response_test.go new file mode 100644 index 0000000..74651d8 --- /dev/null +++ b/internal/pkg/dto/response/ai_response_test.go @@ -0,0 +1,30 @@ +package response + +import ( + "encoding/json" + "testing" + + "cs-ai-agent/internal/models" +) + +func TestBuildAIConfigResponseOmitsAPIKey(t *testing.T) { + payload, err := json.Marshal(BuildAIConfigResponse(&models.AIConfig{ + ID: 1, + Name: "test", + APIKey: "sk-secret", + })) + if err != nil { + t.Fatalf("marshal response error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal response error = %v", err) + } + if _, ok := decoded["apiKey"]; ok { + t.Fatalf("apiKey should not be exposed: %s", payload) + } + if got, ok := decoded["hasApiKey"].(bool); !ok || !got { + t.Fatalf("hasApiKey = %v, want true: %s", decoded["hasApiKey"], payload) + } +} diff --git a/internal/services/ai_config_service.go b/internal/services/ai_config_service.go index 82c63b1..6df7d4a 100644 --- a/internal/services/ai_config_service.go +++ b/internal/services/ai_config_service.go @@ -110,11 +110,10 @@ func (s *aIConfigService) UpdateAIConfig(req request.UpdateAIConfigRequest, oper return err } - return repositories.AIConfigRepository.Updates(sqls.DB(), req.ID, map[string]any{ + columns := map[string]any{ "name": item.Name, "provider": item.Provider, "base_url": item.BaseURL, - "api_key": item.APIKey, "model_type": item.ModelType, "model_name": item.ModelName, "dimension": item.Dimension, @@ -128,7 +127,11 @@ func (s *aIConfigService) UpdateAIConfig(req request.UpdateAIConfigRequest, oper "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), - }) + } + if item.APIKey != "" { + columns["api_key"] = item.APIKey + } + return repositories.AIConfigRepository.Updates(sqls.DB(), req.ID, columns) } func (s *aIConfigService) DeleteAIConfig(id int64, operator *dto.AuthPrincipal) error { diff --git a/internal/services/ai_config_service_test.go b/internal/services/ai_config_service_test.go new file mode 100644 index 0000000..6dfb09c --- /dev/null +++ b/internal/services/ai_config_service_test.go @@ -0,0 +1,82 @@ +package services + +import ( + "testing" + "time" + + "cs-ai-agent/internal/models" + "cs-ai-agent/internal/pkg/dto" + "cs-ai-agent/internal/pkg/dto/request" + "cs-ai-agent/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestUpdateAIConfigKeepsAPIKeyWhenRequestAPIKeyBlank(t *testing.T) { + db := setupAIConfigServiceTestDB(t) + item := &models.AIConfig{ + Name: "old", + Provider: enums.AIProviderOpenAI, + BaseURL: "https://old.example.com", + APIKey: "sk-existing", + ModelType: enums.AIModelTypeLLM, + ModelName: "old-model", + TimeoutMS: 30000, + AuditFields: models.AuditFields{CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + if err := db.Create(item).Error; err != nil { + t.Fatalf("create ai config error = %v", err) + } + + err := AIConfigService.UpdateAIConfig(request.UpdateAIConfigRequest{ + ID: item.ID, + CreateAIConfigRequest: request.CreateAIConfigRequest{ + Name: "new", + Provider: enums.AIProviderOpenAI, + BaseURL: "https://new.example.com", + APIKey: " ", + ModelType: enums.AIModelTypeLLM, + ModelName: "new-model", + TimeoutMS: 120000, + }, + }, &dto.AuthPrincipal{UserID: 1, Username: "admin"}) + if err != nil { + t.Fatalf("UpdateAIConfig() error = %v", err) + } + + var updated models.AIConfig + if err := db.First(&updated, item.ID).Error; err != nil { + t.Fatalf("get updated ai config error = %v", err) + } + if updated.APIKey != "sk-existing" { + t.Fatalf("expected api key to be preserved, got %q", updated.APIKey) + } +} + +func setupAIConfigServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.AIConfig{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/web/app/dashboard/ai-configs/_components/edit.tsx b/web/app/dashboard/ai-configs/_components/edit.tsx index 3cf554b..27b0d7d 100644 --- a/web/app/dashboard/ai-configs/_components/edit.tsx +++ b/web/app/dashboard/ai-configs/_components/edit.tsx @@ -88,7 +88,7 @@ function buildForm(item: AIConfig | null): EditForm { name: item.name, provider: item.provider, baseUrl: item.baseUrl, - apiKey: item.apiKey, + apiKey: "", modelType: item.modelType, modelName: item.modelName, dimension: String(item.dimension), diff --git a/web/app/dashboard/ai-configs/page.tsx b/web/app/dashboard/ai-configs/page.tsx index b0b9059..313708b 100644 --- a/web/app/dashboard/ai-configs/page.tsx +++ b/web/app/dashboard/ai-configs/page.tsx @@ -78,13 +78,6 @@ function getModelTypeLabel(value: AIModelType, t: TFunction) { ); } -function maskAPIKey(value: string) { - const text = value.trim(); - if (!text) return "-"; - if (text.length <= 8) return "****"; - return `${text.slice(0, 4)}****${text.slice(-4)}`; -} - function getNextStatus(item: AIConfig) { return item.status === Status.Ok ? Status.Disabled : Status.Ok; } @@ -178,7 +171,7 @@ export default function DashboardAIConfigsPage() {
{item.baseUrl}
- {t("aiConfig.apiKey", { key: maskAPIKey(item.apiKey) })} + {t("aiConfig.apiKey", { key: item.hasApiKey ? "****" : "-" })}
), diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 6427bfb..0b78833 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -1181,7 +1181,7 @@ export type AIConfig = { name: string provider: string baseUrl: string - apiKey: string + hasApiKey: boolean modelType: string modelName: string dimension: number