From 55ff320cec7de608c1863695e8376236981fa00d Mon Sep 17 00:00:00 2001 From: yanweidong Date: Thu, 23 Jul 2026 21:44:47 +0800 Subject: [PATCH] feat: associate dataset sources with owners --- backend/internal/initdb/dataset.go | 32 +++++++++++++++++-- backend/internal/initdb/dataset_test.go | 26 +++++++++++++++ .../internal/logic/dataset/handlers_test.go | 21 ++++++++++++ backend/internal/logic/dataset/service.go | 18 +++++------ backend/internal/models/dataset_source.go | 6 ++-- backend/internal/models/identity.go | 9 ++++++ backend/internal/models/identity_test.go | 14 ++++++++ 7 files changed, 112 insertions(+), 14 deletions(-) diff --git a/backend/internal/initdb/dataset.go b/backend/internal/initdb/dataset.go index c3b11c2..e5c8de2 100644 --- a/backend/internal/initdb/dataset.go +++ b/backend/internal/initdb/dataset.go @@ -43,6 +43,9 @@ func InitDataset(database *gorm.DB) error { } return database.Transaction(func(tx *gorm.DB) error { + if err := prepareDatasetSourceOwners(tx); err != nil { + return err + } if err := prepareDatasetItems(tx); err != nil { return err } @@ -55,6 +58,29 @@ func InitDataset(database *gorm.DB) error { }) } +func prepareDatasetSourceOwners(tx *gorm.DB) error { + var sources []models.SaDatasetSource + if err := tx.Where("owner_id = ? OR owner_identity = ?", 0, "").Find(&sources).Error; err != nil { + return err + } + for _, source := range sources { + ownerID := source.OwnerID + if ownerID == 0 { + ownerID = source.CreatedBy + } + var owner models.SaUser + if err := tx.Select("identity").First(&owner, ownerID).Error; err != nil { + return err + } + if err := tx.Model(&source).Updates(map[string]any{ + "owner_id": ownerID, "owner_identity": owner.Identity, + }).Error; err != nil { + return err + } + } + return nil +} + func prepareDatasetItems(tx *gorm.DB) error { if tx.Migrator().HasIndex(&models.SaDatasetItem{}, "idx_sa_dataset_item_source_external") { return nil @@ -85,7 +111,7 @@ func prepareDatasetItems(tx *gorm.DB) error { func upsertDefaultDatasetSource(tx *gorm.DB, root models.SaUser, definition defaultDatasetSource) error { var source models.SaDatasetSource - err := tx.Where("created_by = ? AND seed_key = ?", root.ID, definition.Key).First(&source).Error + err := tx.Where("owner_id = ? AND seed_key = ?", root.ID, definition.Key).First(&source).Error if err == nil { return tx.Model(&source).Updates(map[string]any{ "url": definition.URL, "icon_url": definition.IconURL, "kind": "rss", @@ -96,7 +122,7 @@ func upsertDefaultDatasetSource(tx *gorm.DB, root models.SaUser, definition defa } knownURLs := append([]string{definition.URL}, definition.LegacyURLs...) - err = tx.Where("created_by = ? AND url IN ?", root.ID, knownURLs).First(&source).Error + err = tx.Where("owner_id = ? AND url IN ?", root.ID, knownURLs).First(&source).Error if err == nil { return tx.Model(&source).Updates(map[string]any{ "seed_key": definition.Key, "url": definition.URL, @@ -109,7 +135,7 @@ func upsertDefaultDatasetSource(tx *gorm.DB, root models.SaUser, definition defa seedKey := definition.Key return tx.Create(&models.SaDatasetSource{ - CreatedBy: root.ID, SeedKey: &seedKey, Name: definition.Name, + OwnerID: root.ID, CreatedBy: root.ID, SeedKey: &seedKey, Name: definition.Name, Kind: "rss", URL: definition.URL, IconURL: definition.IconURL, Description: "默认资讯订阅", Enabled: true, }).Error diff --git a/backend/internal/initdb/dataset_test.go b/backend/internal/initdb/dataset_test.go index 428eda1..75e602d 100644 --- a/backend/internal/initdb/dataset_test.go +++ b/backend/internal/initdb/dataset_test.go @@ -31,6 +31,8 @@ func TestInitDatasetCreatesDefaultSourcesWhenTableIsEmpty(t *testing.T) { require.Equal(t, expected.Key, *sources[index].SeedKey) require.Equal(t, "rss", sources[index].Kind) require.True(t, sources[index].Enabled) + require.Equal(t, root.ID, sources[index].OwnerID) + require.Equal(t, root.Identity, sources[index].OwnerIdentity) require.Equal(t, root.ID, sources[index].CreatedBy) require.Equal(t, root.Identity, sources[index].CreatedByIdentity) } @@ -60,6 +62,30 @@ func TestInitDatasetAddsMissingDefaultsWithoutChangingCustomSources(t *testing.T require.Nil(t, preserved.SeedKey) } +func TestInitDatasetBackfillsLegacySourceOwners(t *testing.T) { + database := newDatasetInitTestDatabase(t) + require.NoError(t, InitUser(database)) + other := models.SaUser{ + Email: "other@example.com", DisplayName: "Other", + PasswordHash: "hash", Role: "user", + } + require.NoError(t, database.Create(&other).Error) + source := models.SaDatasetSource{ + CreatedBy: other.ID, Name: "Existing source", Kind: "manual", Enabled: true, + } + require.NoError(t, database.Create(&source).Error) + require.NoError(t, database.Model(&source).Updates(map[string]any{ + "owner_id": 0, "owner_identity": "", + }).Error) + + require.NoError(t, InitDataset(database)) + + require.NoError(t, database.First(&source, source.ID).Error) + require.Equal(t, other.ID, source.OwnerID) + require.Equal(t, other.Identity, source.OwnerIdentity) + require.Equal(t, other.ID, source.CreatedBy) +} + func TestInitDatasetRejectsMissingRootInsteadOfAssigningDefaultsToAnotherUser(t *testing.T) { database := newDatasetInitTestDatabase(t) user := models.SaUser{ diff --git a/backend/internal/logic/dataset/handlers_test.go b/backend/internal/logic/dataset/handlers_test.go index f1d7e3d..278759b 100644 --- a/backend/internal/logic/dataset/handlers_test.go +++ b/backend/internal/logic/dataset/handlers_test.go @@ -40,6 +40,8 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) { require.Empty(t, source.IconURL) var storedSource models.SaDatasetSource require.NoError(t, database.Where("identity = ?", source.ID).First(&storedSource).Error) + require.Equal(t, owner.ID, storedSource.OwnerID) + require.Equal(t, owner.Identity, storedSource.OwnerIdentity) require.Equal(t, owner.ID, storedSource.CreatedBy) require.Equal(t, owner.Identity, storedSource.CreatedByIdentity) @@ -102,6 +104,25 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) { require.Zero(t, cronCount) } +func TestDatasetSourceAuthorizationUsesOwner(t *testing.T) { + database := newDatasetTestDatabase(t) + owner := createDatasetTestUser(t, database, "owner@example.com") + creator := createDatasetTestUser(t, database, "creator@example.com") + source := models.SaDatasetSource{ + OwnerID: owner.ID, CreatedBy: creator.ID, + Name: "Owned feed", Kind: "rss", URL: "https://example.com/feed", Enabled: true, + } + require.NoError(t, database.Create(&source).Error) + + ownerSources, err := NewService(database).ListSources(owner.ID) + require.NoError(t, err) + require.Len(t, ownerSources, 1) + + creatorSources, err := NewService(database).ListSources(creator.ID) + require.NoError(t, err) + require.Empty(t, creatorSources) +} + func newDatasetTestDatabase(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) diff --git a/backend/internal/logic/dataset/service.go b/backend/internal/logic/dataset/service.go index a6169e5..7081a61 100644 --- a/backend/internal/logic/dataset/service.go +++ b/backend/internal/logic/dataset/service.go @@ -66,7 +66,7 @@ func newServiceWithFetcher(database *gorm.DB, fetcher FeedFetcher) *Service { func (s *Service) ListSources(userID uint) ([]SourceRecord, error) { var sources []models.SaDatasetSource - if err := s.db.Where("created_by = ?", userID).Order("created_at asc, id asc").Find(&sources).Error; err != nil { + if err := s.db.Where("owner_id = ?", userID).Order("created_at asc, id asc").Find(&sources).Error; err != nil { return nil, err } counts := make(map[uint]int64) @@ -103,7 +103,7 @@ func (s *Service) CreateSource(userID uint, input SourceInput) (*models.SaDatase return nil, err } source := &models.SaDatasetSource{ - CreatedBy: userID, Name: normalized.Name, Kind: normalized.Kind, + OwnerID: userID, CreatedBy: userID, Name: normalized.Name, Kind: normalized.Kind, URL: normalized.URL, IconURL: normalized.IconURL, Description: normalized.Description, Enabled: normalized.Enabled, } @@ -116,7 +116,7 @@ func (s *Service) UpdateSource(userID uint, identity string, input SourceInput) return nil, err } var source models.SaDatasetSource - if err := s.db.Where("identity = ? AND created_by = ?", identity, userID).First(&source).Error; err != nil { + if err := s.db.Where("identity = ? AND owner_id = ?", identity, userID).First(&source).Error; err != nil { return nil, err } if err := s.db.Model(&source).Updates(map[string]any{ @@ -131,7 +131,7 @@ func (s *Service) UpdateSource(userID uint, identity string, input SourceInput) func (s *Service) DeleteSource(userID uint, identity string) error { return s.db.Transaction(func(tx *gorm.DB) error { var source models.SaDatasetSource - if err := tx.Where("identity = ? AND created_by = ?", identity, userID).First(&source).Error; err != nil { + if err := tx.Where("identity = ? AND owner_id = ?", identity, userID).First(&source).Error; err != nil { return err } if err := tx.Where("source_id = ? AND created_by = ?", source.ID, userID).Delete(&models.SaDatasetCron{}).Error; err != nil { @@ -228,10 +228,10 @@ func (s *Service) SyncSources(ctx context.Context, userID uint) ([]models.SaData func (s *Service) SyncAllSources(ctx context.Context) ([]models.SaDatasetCron, error) { var userIDs []uint if err := s.db.Model(&models.SaDatasetSource{}). - Distinct("created_by"). + Distinct("owner_id"). Where("enabled = ? AND kind = ?", true, "rss"). - Order("created_by asc"). - Pluck("created_by", &userIDs).Error; err != nil { + Order("owner_id asc"). + Pluck("owner_id", &userIDs).Error; err != nil { return nil, err } @@ -253,7 +253,7 @@ func (s *Service) SyncAllSources(ctx context.Context) ([]models.SaDatasetCron, e func (s *Service) syncSources(ctx context.Context, userID uint) ([]models.SaDatasetCron, error) { result := make([]models.SaDatasetCron, 0) var sources []models.SaDatasetSource - if err := s.db.Where("created_by = ? AND enabled = ? AND kind = ?", userID, true, "rss"). + if err := s.db.Where("owner_id = ? AND enabled = ? AND kind = ?", userID, true, "rss"). Order("id asc").Find(&sources).Error; err != nil { return nil, err } @@ -361,7 +361,7 @@ func truncateRunes(value string, limit int) string { func (s *Service) findOwnedSource(userID uint, identity string) (*models.SaDatasetSource, error) { var source models.SaDatasetSource - err := s.db.Where("identity = ? AND created_by = ?", strings.TrimSpace(identity), userID).First(&source).Error + err := s.db.Where("identity = ? AND owner_id = ?", strings.TrimSpace(identity), userID).First(&source).Error return &source, err } diff --git a/backend/internal/models/dataset_source.go b/backend/internal/models/dataset_source.go index a1370f0..2193618 100644 --- a/backend/internal/models/dataset_source.go +++ b/backend/internal/models/dataset_source.go @@ -5,9 +5,11 @@ import "time" type SaDatasetSource struct { ID uint `gorm:"primaryKey"` Identity string `gorm:"type:char(36);uniqueIndex"` - CreatedBy uint `gorm:"index;not null;uniqueIndex:idx_sa_dataset_source_seed,priority:1"` + OwnerID uint `gorm:"index;not null;default:0;uniqueIndex:idx_sa_dataset_source_owner_seed,priority:1"` + OwnerIdentity string `gorm:"type:char(36);index"` + CreatedBy uint `gorm:"index;not null"` CreatedByIdentity string `gorm:"type:char(36);index"` - SeedKey *string `gorm:"size:64;uniqueIndex:idx_sa_dataset_source_seed,priority:2"` + SeedKey *string `gorm:"size:64;uniqueIndex:idx_sa_dataset_source_owner_seed,priority:2"` Name string `gorm:"size:160;not null"` Kind string `gorm:"size:32;not null"` URL string `gorm:"size:2048"` diff --git a/backend/internal/models/identity.go b/backend/internal/models/identity.go index 60c1559..e329bdf 100644 --- a/backend/internal/models/identity.go +++ b/backend/internal/models/identity.go @@ -22,6 +22,15 @@ func (m *SaDatasetSource) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err } + if m.OwnerID == 0 { + m.OwnerID = m.CreatedBy + } + if m.CreatedBy == 0 { + m.CreatedBy = m.OwnerID + } + if err := resolveIdentity(tx, &SaUser{}, m.OwnerID, &m.OwnerIdentity); err != nil { + return err + } return resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity) } diff --git a/backend/internal/models/identity_test.go b/backend/internal/models/identity_test.go index 72dfe60..c1c046f 100644 --- a/backend/internal/models/identity_test.go +++ b/backend/internal/models/identity_test.go @@ -40,6 +40,20 @@ func TestBeforeCreateCopiesParentIdentitiesFromNumericIDs(t *testing.T) { require.Equal(t, item.Identity, *task.SourceInboxItemIdentity) } +func TestDatasetSourceBeforeCreateAssociatesOwner(t *testing.T) { + database := newIdentityTestDB(t) + user := SaUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"} + require.NoError(t, database.Create(&user).Error) + source := SaDatasetSource{OwnerID: user.ID, Name: "Owned feed", Kind: "rss"} + + require.NoError(t, database.Create(&source).Error) + + require.Equal(t, user.ID, source.OwnerID) + require.Equal(t, user.Identity, source.OwnerIdentity) + require.Equal(t, user.ID, source.CreatedBy) + require.Equal(t, user.Identity, source.CreatedByIdentity) +} + func newIdentityTestDB(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})