From 6f6dc3032c6dc9aacdf077cd975a757c10823a41 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Wed, 31 Dec 2025 15:31:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=AE=BE=E7=BD=AE):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E8=AE=BE=E7=BD=AE=E4=BF=9D=E5=AD=98=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. Setting.value 字段设置了 NotEmpty() 约束,导致保存空字符串值时验证失败 2. 数据库 settings 表缺少 key 字段的唯一约束,导致 ON CONFLICT 语句执行失败 修复: - 移除 ent/schema/setting.go 中 value 字段的 NotEmpty() 约束 - 新增迁移 015_fix_settings_unique_constraint.sql 添加缺失的唯一约束 - 添加3个回归测试确保空值保存功能正常 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/ent/runtime/runtime.go | 4 -- backend/ent/schema/setting.go | 1 - backend/ent/setting/setting.go | 2 - backend/ent/setting_create.go | 5 -- backend/ent/setting_update.go | 10 ---- .../setting_repo_integration_test.go | 56 +++++++++++++++++++ .../015_fix_settings_unique_constraint.sql | 19 +++++++ 7 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/015_fix_settings_unique_constraint.sql diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index da0accd7..0b254b3e 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -415,10 +415,6 @@ func init() { return nil } }() - // settingDescValue is the schema descriptor for value field. - settingDescValue := settingFields[1].Descriptor() - // setting.ValueValidator is a validator for the "value" field. It is called by the builders before save. - setting.ValueValidator = settingDescValue.Validators[0].(func(string) error) // settingDescUpdatedAt is the schema descriptor for updated_at field. settingDescUpdatedAt := settingFields[2].Descriptor() // setting.DefaultUpdatedAt holds the default value on creation for the updated_at field. diff --git a/backend/ent/schema/setting.go b/backend/ent/schema/setting.go index 3f896fab..0acfde59 100644 --- a/backend/ent/schema/setting.go +++ b/backend/ent/schema/setting.go @@ -36,7 +36,6 @@ func (Setting) Fields() []ent.Field { NotEmpty(). Unique(), field.String("value"). - NotEmpty(). SchemaType(map[string]string{ dialect.Postgres: "text", }), diff --git a/backend/ent/setting/setting.go b/backend/ent/setting/setting.go index feb86b87..79abe970 100644 --- a/backend/ent/setting/setting.go +++ b/backend/ent/setting/setting.go @@ -44,8 +44,6 @@ func ValidColumn(column string) bool { var ( // KeyValidator is a validator for the "key" field. It is called by the builders before save. KeyValidator func(string) error - // ValueValidator is a validator for the "value" field. It is called by the builders before save. - ValueValidator func(string) error // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. DefaultUpdatedAt func() time.Time // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. diff --git a/backend/ent/setting_create.go b/backend/ent/setting_create.go index 66c1231e..553261e7 100644 --- a/backend/ent/setting_create.go +++ b/backend/ent/setting_create.go @@ -102,11 +102,6 @@ func (_c *SettingCreate) check() error { if _, ok := _c.mutation.Value(); !ok { return &ValidationError{Name: "value", err: errors.New(`ent: missing required field "Setting.value"`)} } - if v, ok := _c.mutation.Value(); ok { - if err := setting.ValueValidator(v); err != nil { - return &ValidationError{Name: "value", err: fmt.Errorf(`ent: validator failed for field "Setting.value": %w`, err)} - } - } if _, ok := _c.mutation.UpdatedAt(); !ok { return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "Setting.updated_at"`)} } diff --git a/backend/ent/setting_update.go b/backend/ent/setting_update.go index 007fa36e..42d016d6 100644 --- a/backend/ent/setting_update.go +++ b/backend/ent/setting_update.go @@ -110,11 +110,6 @@ func (_u *SettingUpdate) check() error { return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "Setting.key": %w`, err)} } } - if v, ok := _u.mutation.Value(); ok { - if err := setting.ValueValidator(v); err != nil { - return &ValidationError{Name: "value", err: fmt.Errorf(`ent: validator failed for field "Setting.value": %w`, err)} - } - } return nil } @@ -254,11 +249,6 @@ func (_u *SettingUpdateOne) check() error { return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "Setting.key": %w`, err)} } } - if v, ok := _u.mutation.Value(); ok { - if err := setting.ValueValidator(v); err != nil { - return &ValidationError{Name: "value", err: fmt.Errorf(`ent: validator failed for field "Setting.value": %w`, err)} - } - } return nil } diff --git a/backend/internal/repository/setting_repo_integration_test.go b/backend/internal/repository/setting_repo_integration_test.go index 784124f4..f91c0651 100644 --- a/backend/internal/repository/setting_repo_integration_test.go +++ b/backend/internal/repository/setting_repo_integration_test.go @@ -105,3 +105,59 @@ func (s *SettingRepoSuite) TestSetMultiple_Upsert() { s.Require().NoError(err) s.Require().Equal("new_val", got2) } + +// TestSet_EmptyValue 测试保存空字符串值 +// 这是一个回归测试,确保可选设置(如站点Logo、API端点地址等)可以保存为空字符串 +func (s *SettingRepoSuite) TestSet_EmptyValue() { + // 测试 Set 方法保存空值 + s.Require().NoError(s.repo.Set(s.ctx, "empty_key", ""), "Set with empty value should succeed") + + got, err := s.repo.GetValue(s.ctx, "empty_key") + s.Require().NoError(err, "GetValue for empty value") + s.Require().Equal("", got, "empty value should be preserved") +} + +// TestSetMultiple_WithEmptyValues 测试批量保存包含空字符串的设置 +// 模拟用户保存站点设置时部分字段为空的场景 +func (s *SettingRepoSuite) TestSetMultiple_WithEmptyValues() { + // 模拟保存站点设置,部分字段有值,部分字段为空 + settings := map[string]string{ + "site_name": "AICodex2API", + "site_subtitle": "Subscription to API", + "site_logo": "", // 用户未上传Logo + "api_base_url": "", // 用户未设置API地址 + "contact_info": "", // 用户未设置联系方式 + "doc_url": "", // 用户未设置文档链接 + } + + s.Require().NoError(s.repo.SetMultiple(s.ctx, settings), "SetMultiple with empty values should succeed") + + // 验证所有值都正确保存 + result, err := s.repo.GetMultiple(s.ctx, []string{"site_name", "site_subtitle", "site_logo", "api_base_url", "contact_info", "doc_url"}) + s.Require().NoError(err, "GetMultiple after SetMultiple with empty values") + + s.Require().Equal("AICodex2API", result["site_name"]) + s.Require().Equal("Subscription to API", result["site_subtitle"]) + s.Require().Equal("", result["site_logo"], "empty site_logo should be preserved") + s.Require().Equal("", result["api_base_url"], "empty api_base_url should be preserved") + s.Require().Equal("", result["contact_info"], "empty contact_info should be preserved") + s.Require().Equal("", result["doc_url"], "empty doc_url should be preserved") +} + +// TestSetMultiple_UpdateToEmpty 测试将已有值更新为空字符串 +// 确保用户可以清空之前设置的值 +func (s *SettingRepoSuite) TestSetMultiple_UpdateToEmpty() { + // 先设置非空值 + s.Require().NoError(s.repo.Set(s.ctx, "clearable_key", "initial_value")) + + got, err := s.repo.GetValue(s.ctx, "clearable_key") + s.Require().NoError(err) + s.Require().Equal("initial_value", got) + + // 更新为空值 + s.Require().NoError(s.repo.SetMultiple(s.ctx, map[string]string{"clearable_key": ""}), "Update to empty should succeed") + + got, err = s.repo.GetValue(s.ctx, "clearable_key") + s.Require().NoError(err) + s.Require().Equal("", got, "value should be updated to empty string") +} diff --git a/backend/migrations/015_fix_settings_unique_constraint.sql b/backend/migrations/015_fix_settings_unique_constraint.sql new file mode 100644 index 00000000..60f8fcad --- /dev/null +++ b/backend/migrations/015_fix_settings_unique_constraint.sql @@ -0,0 +1,19 @@ +-- 015_fix_settings_unique_constraint.sql +-- 修复 settings 表 key 字段缺失的唯一约束 +-- 此约束是 ON CONFLICT ("key") DO UPDATE 语句所必需的 + +-- 检查并添加唯一约束(如果不存在) +DO $$ +BEGIN + -- 检查是否已存在唯一约束 + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'settings'::regclass + AND contype = 'u' + AND conname = 'settings_key_key' + ) THEN + -- 添加唯一约束 + ALTER TABLE settings ADD CONSTRAINT settings_key_key UNIQUE (key); + END IF; +END +$$;