From 59269dc1c1a397fa974428ea85610241f3ec1afa Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Wed, 31 Dec 2025 16:37:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=95=B0=E6=8D=AE=E5=B1=82):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BD=AF=E5=88=A0=E9=99=A4=E4=B8=8E=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E5=86=B2=E7=AA=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:软删除的记录仍占用唯一约束位置,导致删后无法重建同名/同邮箱/同订阅 修复方案:使用 PostgreSQL 部分唯一索引(WHERE deleted_at IS NULL) - User.email: 移除字段级 Unique(),改用部分唯一索引 - Group.name: 移除字段级 Unique(),改用部分唯一索引 - UserSubscription.(user_id, group_id): 移除组合唯一索引,改用部分唯一索引 - ApiKey.key: 保留普通唯一约束(安全考虑,已删除的 Key 不应重用) 安全性: - 应用层已有 ExistsByXxx 检查,自动过滤软删除记录 - 数据库层部分唯一索引提供最后一道防线 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/ent/migrate/schema.go | 6 +-- backend/ent/schema/group.go | 5 +- backend/ent/schema/user.go | 5 +- backend/ent/schema/user_subscription.go | 4 +- ...016_soft_delete_partial_unique_indexes.sql | 51 +++++++++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/016_soft_delete_partial_unique_indexes.sql diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 848ac74c..c9a1675e 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -204,7 +204,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "name", Type: field.TypeString, Unique: true, Size: 100}, + {Name: "name", Type: field.TypeString, Size: 100}, {Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "is_exclusive", Type: field.TypeBool, Default: false}, @@ -470,7 +470,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "email", Type: field.TypeString, Unique: true, Size: 255}, + {Name: "email", Type: field.TypeString, Size: 255}, {Name: "password_hash", Type: field.TypeString, Size: 255}, {Name: "role", Type: field.TypeString, Size: 20, Default: "user"}, {Name: "balance", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, @@ -605,7 +605,7 @@ var ( }, { Name: "usersubscription_user_id_group_id", - Unique: true, + Unique: false, Columns: []*schema.Column{UserSubscriptionsColumns[16], UserSubscriptionsColumns[15]}, }, { diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 7f3ed167..7a8a5345 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -33,10 +33,11 @@ func (Group) Mixin() []ent.Mixin { func (Group) Fields() []ent.Field { return []ent.Field{ + // 唯一约束通过部分索引实现(WHERE deleted_at IS NULL),支持软删除后重用 + // 见迁移文件 016_soft_delete_partial_unique_indexes.sql field.String("name"). MaxLen(100). - NotEmpty(). - Unique(), + NotEmpty(), field.String("description"). Optional(). Nillable(). diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index ba7f0ce7..c1f742d1 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -33,10 +33,11 @@ func (User) Mixin() []ent.Mixin { func (User) Fields() []ent.Field { return []ent.Field{ + // 唯一约束通过部分索引实现(WHERE deleted_at IS NULL),支持软删除后重用 + // 见迁移文件 016_soft_delete_partial_unique_indexes.sql field.String("email"). MaxLen(255). - NotEmpty(). - Unique(), + NotEmpty(), field.String("password_hash"). MaxLen(255). NotEmpty(), diff --git a/backend/ent/schema/user_subscription.go b/backend/ent/schema/user_subscription.go index 88c4ea8f..b21f4083 100644 --- a/backend/ent/schema/user_subscription.go +++ b/backend/ent/schema/user_subscription.go @@ -109,7 +109,9 @@ func (UserSubscription) Indexes() []ent.Index { index.Fields("status"), index.Fields("expires_at"), index.Fields("assigned_by"), - index.Fields("user_id", "group_id").Unique(), + // 唯一约束通过部分索引实现(WHERE deleted_at IS NULL),支持软删除后重新订阅 + // 见迁移文件 016_soft_delete_partial_unique_indexes.sql + index.Fields("user_id", "group_id"), index.Fields("deleted_at"), } } diff --git a/backend/migrations/016_soft_delete_partial_unique_indexes.sql b/backend/migrations/016_soft_delete_partial_unique_indexes.sql new file mode 100644 index 00000000..b006b775 --- /dev/null +++ b/backend/migrations/016_soft_delete_partial_unique_indexes.sql @@ -0,0 +1,51 @@ +-- 016_soft_delete_partial_unique_indexes.sql +-- 修复软删除 + 唯一约束冲突问题 +-- 将普通唯一约束替换为部分唯一索引(WHERE deleted_at IS NULL) +-- 这样软删除的记录不会占用唯一约束位置,允许删后重建同名/同邮箱/同订阅关系 + +-- ============================================================================ +-- 1. users 表: email 字段 +-- ============================================================================ + +-- 删除旧的唯一约束(可能的命名方式) +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; +DROP INDEX IF EXISTS users_email_key; +DROP INDEX IF EXISTS user_email_key; + +-- 创建部分唯一索引:只对未删除的记录建立唯一约束 +CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_active + ON users(email) + WHERE deleted_at IS NULL; + +-- ============================================================================ +-- 2. groups 表: name 字段 +-- ============================================================================ + +-- 删除旧的唯一约束 +ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_name_key; +DROP INDEX IF EXISTS groups_name_key; +DROP INDEX IF EXISTS group_name_key; + +-- 创建部分唯一索引 +CREATE UNIQUE INDEX IF NOT EXISTS groups_name_unique_active + ON groups(name) + WHERE deleted_at IS NULL; + +-- ============================================================================ +-- 3. user_subscriptions 表: (user_id, group_id) 组合字段 +-- ============================================================================ + +-- 删除旧的唯一约束/索引 +ALTER TABLE user_subscriptions DROP CONSTRAINT IF EXISTS user_subscriptions_user_id_group_id_key; +DROP INDEX IF EXISTS user_subscriptions_user_id_group_id_key; +DROP INDEX IF EXISTS usersubscription_user_id_group_id; + +-- 创建部分唯一索引 +CREATE UNIQUE INDEX IF NOT EXISTS user_subscriptions_user_group_unique_active + ON user_subscriptions(user_id, group_id) + WHERE deleted_at IS NULL; + +-- ============================================================================ +-- 注意: api_keys 表的 key 字段保留普通唯一约束 +-- API Key 即使软删除后也不应该重复使用(安全考虑) +-- ============================================================================