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 {
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

View File

@@ -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{

View File

@@ -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{})

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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{})