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:
2026-07-10 20:10:50 +08:00
parent 0bf4b73ebc
commit 67a6cbcd9e
15 changed files with 2378 additions and 518 deletions
@@ -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) {