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
+91 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
@@ -326,24 +327,107 @@ func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, actor *Actor,
}
func (s *MediaSupplyService) SetResourceVisibility(ctx context.Context, actor *Actor, resourceID int64, input SetMediaSupplyVisibilityInput) error {
tag, err := s.pool.Exec(ctx, `
UPDATE supplier_media_resources
SET customer_visible = $3, updated_at = NOW()
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
`, resourceID, opsMediaSupplySupplierMeijiequan, input.CustomerVisible)
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if tag.RowsAffected() == 0 {
defer tx.Rollback(ctx)
if !input.CustomerVisible {
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
WHERE favorite.tenant_id = favorite_owner.tenant_id
AND favorite.user_id = favorite_owner.user_id
AND favorite.resource_id = $1
)
ORDER BY favorite_owner.tenant_id, favorite_owner.user_id
FOR UPDATE OF favorite_owner
`, resourceID)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
for ownerRows.Next() {
var tenantID, userID int64
if err := ownerRows.Scan(&tenantID, &userID); err != nil {
ownerRows.Close()
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
}
if err := ownerRows.Err(); err != nil {
ownerRows.Close()
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
ownerRows.Close()
}
var lockedResourceID int64
if err := tx.QueryRow(ctx, `
SELECT id
FROM supplier_media_resources
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
FOR UPDATE
`, resourceID, opsMediaSupplySupplierMeijiequan).Scan(&lockedResourceID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
}
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
var updated, removedFavorites int
err = tx.QueryRow(ctx, `
WITH updated_resource AS (
UPDATE supplier_media_resources
SET customer_visible = $3, updated_at = NOW()
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
RETURNING id
), deleted_favorites AS (
DELETE FROM media_supply_favorite_resources favorite
USING updated_resource resource
WHERE $3 = FALSE AND favorite.resource_id = 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 updated_resource),
(SELECT COUNT(*) FROM deleted_favorites)
`, resourceID, opsMediaSupplySupplierMeijiequan, input.CustomerVisible).Scan(&updated, &removedFavorites)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if updated == 0 {
return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if s.audits != nil && actor != nil {
action := "media_supply.resource_show"
if !input.CustomerVisible {
action = "media_supply.resource_hide"
}
_ = s.audits.Append(ctx, actor.audit(action, "supplier_media_resource", resourceID, map[string]any{
"customer_visible": input.CustomerVisible,
"customer_visible": input.CustomerVisible,
"removed_favorite_count": removedFavorites,
}))
}
return nil