feat(media-supply): persist favorites with multi-group membership
Move media favorites from client state to PostgreSQL with server-side search and pagination. - Store favorites keyed by (tenant, user, group, resource) so a resource can belong to multiple groups; the 500-resource cap counts distinct resources, not memberships. - Add favorite-group CRUD and group/global removal routes, with silent cleanup of delisted or invisible resources on read. - Serve favorite resource details server-side, 10 per page, with name/ID search; favorites-page removal is group-scoped, resources-page removal is global. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ type MediaSupplyService struct {
|
||||
cfg config.Provider
|
||||
client *MeijiequanClient
|
||||
objectStorage objectstorage.Client
|
||||
favoriteStore mediaSupplyFavoriteStore
|
||||
}
|
||||
|
||||
func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *zap.Logger, cfg config.Provider) *MediaSupplyService {
|
||||
@@ -37,11 +38,12 @@ func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *za
|
||||
}
|
||||
client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan)
|
||||
return &MediaSupplyService{
|
||||
pool: pool,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
pool: pool,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
favoriteStore: newPostgresMediaSupplyFavoriteStore(pool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,6 +999,7 @@ type mediaResourceSyncStats struct {
|
||||
Synced int
|
||||
RemovedPriceOverride int
|
||||
RemovedStale int
|
||||
RemovedFavorite int
|
||||
}
|
||||
|
||||
func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) {
|
||||
@@ -1128,6 +1131,7 @@ func (s *MediaSupplyService) RunMediaResourceSyncOnce(ctx context.Context, model
|
||||
"synced": stats.Synced,
|
||||
"removed_price_overrides": stats.RemovedPriceOverride,
|
||||
"removed_stale_resources": stats.RemovedStale,
|
||||
"removed_stale_favorites": stats.RemovedFavorite,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1176,17 +1180,18 @@ func (s *MediaSupplyService) syncMediaResourceModel(ctx context.Context, modelID
|
||||
if !completed {
|
||||
return total, fmt.Errorf("meijiequan media resource sync incomplete: reached max pages %d", maxPages)
|
||||
}
|
||||
removed, err := s.markMissingSyncedResourcesDeleted(ctx, mediaSupplySupplierMeijiequan, modelID, syncedResourceIDs)
|
||||
removed, removedFavorites, err := s.markMissingSyncedResourcesDeleted(ctx, mediaSupplySupplierMeijiequan, modelID, syncedResourceIDs)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total.RemovedStale = removed
|
||||
total.RemovedFavorite = removedFavorites
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Context, supplier string, modelID int, syncedResourceIDs map[string]struct{}) (int, error) {
|
||||
func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Context, supplier string, modelID int, syncedResourceIDs map[string]struct{}) (int, int, error) {
|
||||
if s == nil || s.pool == nil || strings.TrimSpace(supplier) == "" || modelID <= 0 {
|
||||
return 0, nil
|
||||
return 0, 0, nil
|
||||
}
|
||||
ids := make([]string, 0, len(syncedResourceIDs))
|
||||
for id := range syncedResourceIDs {
|
||||
@@ -1194,18 +1199,115 @@ func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Conte
|
||||
ids = append(ids, trimmed)
|
||||
}
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE supplier_media_resources
|
||||
SET deleted_at = NOW(), updated_at = NOW()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
ownerRows, err := tx.Query(ctx, `
|
||||
SELECT favorite_owner.tenant_id, favorite_owner.user_id
|
||||
FROM media_supply_favorite_owners favorite_owner
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM media_supply_favorite_resources favorite
|
||||
JOIN supplier_media_resources resource ON resource.id = favorite.resource_id
|
||||
WHERE favorite.tenant_id = favorite_owner.tenant_id
|
||||
AND favorite.user_id = favorite_owner.user_id
|
||||
AND resource.supplier = $1
|
||||
AND resource.model_id = $2
|
||||
AND resource.deleted_at IS NULL
|
||||
AND NOT (resource.supplier_resource_id = ANY($3::text[]))
|
||||
)
|
||||
ORDER BY favorite_owner.tenant_id, favorite_owner.user_id
|
||||
FOR UPDATE OF favorite_owner
|
||||
`, supplier, modelID, ids)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
for ownerRows.Next() {
|
||||
var tenantID, userID int64
|
||||
if err := ownerRows.Scan(&tenantID, &userID); err != nil {
|
||||
ownerRows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
if err := ownerRows.Err(); err != nil {
|
||||
ownerRows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
ownerRows.Close()
|
||||
resourceRows, err := tx.Query(ctx, `
|
||||
SELECT id
|
||||
FROM supplier_media_resources
|
||||
WHERE supplier = $1
|
||||
AND model_id = $2
|
||||
AND deleted_at IS NULL
|
||||
AND NOT (supplier_resource_id = ANY($3::text[]))
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
`, supplier, modelID, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
for resourceRows.Next() {
|
||||
var resourceID int64
|
||||
if err := resourceRows.Scan(&resourceID); err != nil {
|
||||
resourceRows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
if err := resourceRows.Err(); err != nil {
|
||||
resourceRows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
resourceRows.Close()
|
||||
|
||||
var removed, removedFavorites int
|
||||
err = tx.QueryRow(ctx, `
|
||||
WITH removed_resources AS (
|
||||
UPDATE supplier_media_resources
|
||||
SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE supplier = $1
|
||||
AND model_id = $2
|
||||
AND deleted_at IS NULL
|
||||
AND NOT (supplier_resource_id = ANY($3::text[]))
|
||||
RETURNING id
|
||||
), deleted_favorites AS (
|
||||
DELETE FROM media_supply_favorite_resources favorite
|
||||
USING removed_resources removed_resource
|
||||
WHERE favorite.resource_id = removed_resource.id
|
||||
RETURNING favorite.tenant_id, favorite.user_id, favorite.group_key
|
||||
), touched_groups AS (
|
||||
UPDATE media_supply_favorite_groups favorite_group
|
||||
SET updated_at = NOW()
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM deleted_favorites deleted
|
||||
WHERE deleted.tenant_id = favorite_group.tenant_id
|
||||
AND deleted.user_id = favorite_group.user_id
|
||||
AND deleted.group_key = favorite_group.group_key
|
||||
)
|
||||
RETURNING favorite_group.tenant_id, favorite_group.user_id
|
||||
), touched_owners AS (
|
||||
UPDATE media_supply_favorite_owners favorite_owner
|
||||
SET revision = revision + 1, updated_at = NOW()
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM deleted_favorites deleted
|
||||
WHERE deleted.tenant_id = favorite_owner.tenant_id
|
||||
AND deleted.user_id = favorite_owner.user_id
|
||||
)
|
||||
RETURNING favorite_owner.tenant_id
|
||||
)
|
||||
SELECT (SELECT COUNT(*) FROM removed_resources),
|
||||
(SELECT COUNT(*) FROM deleted_favorites)
|
||||
`, supplier, modelID, ids).Scan(&removed, &removedFavorites)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return removed, removedFavorites, nil
|
||||
}
|
||||
|
||||
func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) {
|
||||
|
||||
Reference in New Issue
Block a user