feat: associate dataset sources with owners

This commit is contained in:
2026-07-23 21:44:47 +08:00
parent 50d8246915
commit 55ff320cec
7 changed files with 112 additions and 14 deletions

View File

@@ -43,6 +43,9 @@ func InitDataset(database *gorm.DB) error {
} }
return database.Transaction(func(tx *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 { if err := prepareDatasetItems(tx); err != nil {
return err 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 { func prepareDatasetItems(tx *gorm.DB) error {
if tx.Migrator().HasIndex(&models.SaDatasetItem{}, "idx_sa_dataset_item_source_external") { if tx.Migrator().HasIndex(&models.SaDatasetItem{}, "idx_sa_dataset_item_source_external") {
return nil return nil
@@ -85,7 +111,7 @@ func prepareDatasetItems(tx *gorm.DB) error {
func upsertDefaultDatasetSource(tx *gorm.DB, root models.SaUser, definition defaultDatasetSource) error { func upsertDefaultDatasetSource(tx *gorm.DB, root models.SaUser, definition defaultDatasetSource) error {
var source models.SaDatasetSource 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 { if err == nil {
return tx.Model(&source).Updates(map[string]any{ return tx.Model(&source).Updates(map[string]any{
"url": definition.URL, "icon_url": definition.IconURL, "kind": "rss", "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...) 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 { if err == nil {
return tx.Model(&source).Updates(map[string]any{ return tx.Model(&source).Updates(map[string]any{
"seed_key": definition.Key, "url": definition.URL, "seed_key": definition.Key, "url": definition.URL,
@@ -109,7 +135,7 @@ func upsertDefaultDatasetSource(tx *gorm.DB, root models.SaUser, definition defa
seedKey := definition.Key seedKey := definition.Key
return tx.Create(&models.SaDatasetSource{ 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, Kind: "rss", URL: definition.URL, IconURL: definition.IconURL,
Description: "默认资讯订阅", Enabled: true, Description: "默认资讯订阅", Enabled: true,
}).Error }).Error

View File

@@ -31,6 +31,8 @@ func TestInitDatasetCreatesDefaultSourcesWhenTableIsEmpty(t *testing.T) {
require.Equal(t, expected.Key, *sources[index].SeedKey) require.Equal(t, expected.Key, *sources[index].SeedKey)
require.Equal(t, "rss", sources[index].Kind) require.Equal(t, "rss", sources[index].Kind)
require.True(t, sources[index].Enabled) 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.ID, sources[index].CreatedBy)
require.Equal(t, root.Identity, sources[index].CreatedByIdentity) require.Equal(t, root.Identity, sources[index].CreatedByIdentity)
} }
@@ -60,6 +62,30 @@ func TestInitDatasetAddsMissingDefaultsWithoutChangingCustomSources(t *testing.T
require.Nil(t, preserved.SeedKey) 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) { func TestInitDatasetRejectsMissingRootInsteadOfAssigningDefaultsToAnotherUser(t *testing.T) {
database := newDatasetInitTestDatabase(t) database := newDatasetInitTestDatabase(t)
user := models.SaUser{ user := models.SaUser{

View File

@@ -40,6 +40,8 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) {
require.Empty(t, source.IconURL) require.Empty(t, source.IconURL)
var storedSource models.SaDatasetSource var storedSource models.SaDatasetSource
require.NoError(t, database.Where("identity = ?", source.ID).First(&storedSource).Error) 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.ID, storedSource.CreatedBy)
require.Equal(t, owner.Identity, storedSource.CreatedByIdentity) require.Equal(t, owner.Identity, storedSource.CreatedByIdentity)
@@ -102,6 +104,25 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) {
require.Zero(t, cronCount) 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 { func newDatasetTestDatabase(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})

View File

@@ -66,7 +66,7 @@ func newServiceWithFetcher(database *gorm.DB, fetcher FeedFetcher) *Service {
func (s *Service) ListSources(userID uint) ([]SourceRecord, error) { func (s *Service) ListSources(userID uint) ([]SourceRecord, error) {
var sources []models.SaDatasetSource 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 return nil, err
} }
counts := make(map[uint]int64) counts := make(map[uint]int64)
@@ -103,7 +103,7 @@ func (s *Service) CreateSource(userID uint, input SourceInput) (*models.SaDatase
return nil, err return nil, err
} }
source := &models.SaDatasetSource{ 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, URL: normalized.URL, IconURL: normalized.IconURL,
Description: normalized.Description, Enabled: normalized.Enabled, Description: normalized.Description, Enabled: normalized.Enabled,
} }
@@ -116,7 +116,7 @@ func (s *Service) UpdateSource(userID uint, identity string, input SourceInput)
return nil, err return nil, err
} }
var source models.SaDatasetSource 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 return nil, err
} }
if err := s.db.Model(&source).Updates(map[string]any{ 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 { func (s *Service) DeleteSource(userID uint, identity string) error {
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
var source models.SaDatasetSource 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 return err
} }
if err := tx.Where("source_id = ? AND created_by = ?", source.ID, userID).Delete(&models.SaDatasetCron{}).Error; err != nil { 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) { func (s *Service) SyncAllSources(ctx context.Context) ([]models.SaDatasetCron, error) {
var userIDs []uint var userIDs []uint
if err := s.db.Model(&models.SaDatasetSource{}). if err := s.db.Model(&models.SaDatasetSource{}).
Distinct("created_by"). Distinct("owner_id").
Where("enabled = ? AND kind = ?", true, "rss"). Where("enabled = ? AND kind = ?", true, "rss").
Order("created_by asc"). Order("owner_id asc").
Pluck("created_by", &userIDs).Error; err != nil { Pluck("owner_id", &userIDs).Error; err != nil {
return nil, err 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) { func (s *Service) syncSources(ctx context.Context, userID uint) ([]models.SaDatasetCron, error) {
result := make([]models.SaDatasetCron, 0) result := make([]models.SaDatasetCron, 0)
var sources []models.SaDatasetSource 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 { Order("id asc").Find(&sources).Error; err != nil {
return nil, err 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) { func (s *Service) findOwnedSource(userID uint, identity string) (*models.SaDatasetSource, error) {
var source models.SaDatasetSource 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 return &source, err
} }

View File

@@ -5,9 +5,11 @@ import "time"
type SaDatasetSource struct { type SaDatasetSource struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"` 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"` 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"` Name string `gorm:"size:160;not null"`
Kind string `gorm:"size:32;not null"` Kind string `gorm:"size:32;not null"`
URL string `gorm:"size:2048"` URL string `gorm:"size:2048"`

View File

@@ -22,6 +22,15 @@ func (m *SaDatasetSource) BeforeCreate(tx *gorm.DB) error {
if err := ensureIdentity(&m.Identity); err != nil { if err := ensureIdentity(&m.Identity); err != nil {
return err 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) return resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity)
} }

View File

@@ -40,6 +40,20 @@ func TestBeforeCreateCopiesParentIdentitiesFromNumericIDs(t *testing.T) {
require.Equal(t, item.Identity, *task.SourceInboxItemIdentity) 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 { func newIdentityTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})