fix(subscriptions): reject concurrent duplicate rules

Fixes #66
This commit is contained in:
ShukeBta
2026-08-10 19:30:17 +08:00
parent c32f23a626
commit c0d743d89d
18 changed files with 584 additions and 10 deletions
+57
View File
@@ -97,6 +97,63 @@ func TestEnforceTelegramBindingOneToOneCleansDuplicatesAndAddsIndex(t *testing.T
}
}
func TestEnsureSubscriptionIdentityUniquenessArchivesDuplicatesAndAddsIndex(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Subscription{}); err != nil {
t.Fatal(err)
}
createdAt := time.Now().Add(-time.Hour)
rows := []model.Subscription{
{UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50},
{UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50},
}
for i := range rows {
rows[i].CreatedAt = createdAt.Add(time.Duration(i) * time.Minute)
if err := db.Select("*").Omit("DeletedAt").Create(&rows[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := ensureSubscriptionIdentityUniqueness(db); err != nil {
t.Fatal(err)
}
var active []model.Subscription
if err := db.Where("archived_at IS NULL").Order("created_at asc").Find(&active).Error; err != nil {
t.Fatal(err)
}
if len(active) != 1 || active[0].ID != rows[0].ID {
t.Fatalf("active subscriptions = %#v, want earliest row only", active)
}
var archived model.Subscription
if err := db.First(&archived, "id = ?", rows[1].ID).Error; err != nil {
t.Fatal(err)
}
if archived.ArchivedAt == nil || archived.ArchiveReason != duplicateSubscriptionMigrationReason {
t.Fatalf("duplicate was not archived by migration: %#v", archived)
}
duplicate := rows[0]
duplicate.ID = ""
duplicate.CreatedAt = time.Time{}
duplicate.UpdatedAt = time.Time{}
model.RefreshSubscriptionIdentity(&duplicate)
if err := db.Select("*").Omit("DeletedAt").Create(&duplicate).Error; err == nil {
t.Fatal("expected active identity index to reject a duplicate rule")
}
different := rows[0]
different.ID = ""
different.CreatedAt = time.Time{}
different.UpdatedAt = time.Time{}
different.Resolution = "2160p"
model.RefreshSubscriptionIdentity(&different)
if err := db.Select("*").Omit("DeletedAt").Create(&different).Error; err != nil {
t.Fatalf("different rule should be allowed: %v", err)
}
}
func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
+3
View File
@@ -17,6 +17,9 @@ func AutoMigrate(db *gorm.DB) error {
if err := enforceTelegramBindingOneToOne(db); err != nil {
return err
}
if err := ensureSubscriptionIdentityUniqueness(db); err != nil {
return err
}
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
@@ -0,0 +1,54 @@
package database
import (
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
const duplicateSubscriptionMigrationReason = "迁移合并重复订阅规则"
func ensureSubscriptionIdentityUniqueness(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable(&model.Subscription{}) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
var rows []model.Subscription
if err := tx.Unscoped().Order("created_at asc, id asc").Find(&rows).Error; err != nil {
return err
}
seen := make(map[string]string)
for i := range rows {
row := &rows[i]
key := model.RefreshSubscriptionIdentity(row)
updates := map[string]any{"identity_key": key}
if !row.DeletedAt.Valid && row.ArchivedAt == nil {
activeKey := row.UserID + "\x00" + key
if _, duplicate := seen[activeKey]; duplicate {
archivedAt := row.UpdatedAt
if archivedAt.IsZero() {
archivedAt = row.CreatedAt
}
if archivedAt.IsZero() {
archivedAt = time.Now()
}
updates["enabled"] = false
updates["archived_at"] = &archivedAt
updates["archive_reason"] = duplicateSubscriptionMigrationReason
} else {
seen[activeKey] = row.ID
}
}
if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return err
}
}
return tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_user_identity_active
ON subscriptions(user_id, identity_key)
WHERE deleted_at IS NULL AND archived_at IS NULL AND identity_key <> ''
`).Error
})
}