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:
@@ -0,0 +1,379 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
)
|
||||
|
||||
type favoriteOwner struct {
|
||||
tenantID int64
|
||||
userID int64
|
||||
}
|
||||
|
||||
type memoryMediaSupplyFavoriteStore struct {
|
||||
mu sync.Mutex
|
||||
states map[favoriteOwner]mediaSupplyFavoriteState
|
||||
}
|
||||
|
||||
func newMemoryMediaSupplyFavoriteStore() *memoryMediaSupplyFavoriteStore {
|
||||
return &memoryMediaSupplyFavoriteStore{states: make(map[favoriteOwner]mediaSupplyFavoriteState)}
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) List(
|
||||
_ context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, ok := s.states[favoriteOwner{tenantID: tenantID, userID: userID}]
|
||||
if !ok {
|
||||
return mediaSupplyFavoriteState{}, nil
|
||||
}
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) CreateGroup(
|
||||
_ context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
groupID string,
|
||||
name string,
|
||||
resourceID int64,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
owner := favoriteOwner{tenantID: tenantID, userID: userID}
|
||||
state := ensureMemoryFavoriteState(s.states[owner])
|
||||
for _, group := range state.Groups {
|
||||
if group.Name == name {
|
||||
if resourceID > 0 {
|
||||
return s.putResourceLocked(owner, state, group.ID, resourceID)
|
||||
}
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
}
|
||||
state.Groups = append(state.Groups, MediaSupplyFavoriteGroup{
|
||||
ID: groupID,
|
||||
Name: name,
|
||||
ResourceIDs: []int64{},
|
||||
UpdatedAt: favoriteTestTime(),
|
||||
})
|
||||
if resourceID > 0 {
|
||||
for index, group := range state.Groups {
|
||||
if group.ID == groupID {
|
||||
group.ResourceIDs = append(group.ResourceIDs, resourceID)
|
||||
group.UpdatedAt = favoriteTestTime()
|
||||
}
|
||||
state.Groups[index] = group
|
||||
}
|
||||
}
|
||||
state.Revision++
|
||||
s.states[owner] = state
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) DeleteGroup(
|
||||
_ context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
groupID string,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
owner := favoriteOwner{tenantID: tenantID, userID: userID}
|
||||
state := ensureMemoryFavoriteState(s.states[owner])
|
||||
next := state.Groups[:0]
|
||||
changed := false
|
||||
for _, group := range state.Groups {
|
||||
if group.ID != groupID {
|
||||
next = append(next, group)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
state.Groups = next
|
||||
if changed {
|
||||
state.Revision++
|
||||
}
|
||||
s.states[owner] = state
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) PutResource(
|
||||
_ context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
groupID string,
|
||||
resourceID int64,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
owner := favoriteOwner{tenantID: tenantID, userID: userID}
|
||||
state := ensureMemoryFavoriteState(s.states[owner])
|
||||
return s.putResourceLocked(owner, state, groupID, resourceID)
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) putResourceLocked(
|
||||
owner favoriteOwner,
|
||||
state mediaSupplyFavoriteState,
|
||||
groupID string,
|
||||
resourceID int64,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
groupExists := false
|
||||
resourceIDs := make(map[int64]struct{})
|
||||
for _, group := range state.Groups {
|
||||
if group.ID == groupID {
|
||||
groupExists = true
|
||||
}
|
||||
for _, id := range group.ResourceIDs {
|
||||
resourceIDs[id] = struct{}{}
|
||||
if id == resourceID {
|
||||
if group.ID == groupID {
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !groupExists {
|
||||
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteGroupMissing
|
||||
}
|
||||
_, resourceExists := resourceIDs[resourceID]
|
||||
if !resourceExists && len(resourceIDs) >= mediaSupplyFavoriteResourceLimit {
|
||||
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteResourceLimit
|
||||
}
|
||||
for index, group := range state.Groups {
|
||||
if group.ID == groupID {
|
||||
group.ResourceIDs = append(group.ResourceIDs, resourceID)
|
||||
group.UpdatedAt = favoriteTestTime()
|
||||
}
|
||||
state.Groups[index] = group
|
||||
}
|
||||
state.Revision++
|
||||
s.states[owner] = state
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) DeleteResourceFromGroup(
|
||||
_ context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
groupID string,
|
||||
resourceID int64,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
owner := favoriteOwner{tenantID: tenantID, userID: userID}
|
||||
state := ensureMemoryFavoriteState(s.states[owner])
|
||||
for index, group := range state.Groups {
|
||||
if group.ID != groupID {
|
||||
continue
|
||||
}
|
||||
next := removeFavoriteResourceID(group.ResourceIDs, resourceID)
|
||||
if len(next) != len(group.ResourceIDs) {
|
||||
group.ResourceIDs = next
|
||||
group.UpdatedAt = favoriteTestTime()
|
||||
state.Groups[index] = group
|
||||
state.Revision++
|
||||
s.states[owner] = state
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
s.states[owner] = state
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteGroupMissing
|
||||
}
|
||||
|
||||
func (s *memoryMediaSupplyFavoriteStore) DeleteResource(
|
||||
_ context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
resourceID int64,
|
||||
) (mediaSupplyFavoriteState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
owner := favoriteOwner{tenantID: tenantID, userID: userID}
|
||||
state := ensureMemoryFavoriteState(s.states[owner])
|
||||
changed := false
|
||||
for index, group := range state.Groups {
|
||||
if len(group.ResourceIDs) != len(removeFavoriteResourceID(group.ResourceIDs, resourceID)) {
|
||||
changed = true
|
||||
}
|
||||
group.ResourceIDs = removeFavoriteResourceID(group.ResourceIDs, resourceID)
|
||||
state.Groups[index] = group
|
||||
}
|
||||
if changed {
|
||||
state.Revision++
|
||||
}
|
||||
s.states[owner] = state
|
||||
return cloneFavoriteState(state), nil
|
||||
}
|
||||
|
||||
func TestMediaSupplyFavoriteGroupsPersistAcrossServiceInstances(t *testing.T) {
|
||||
store := newMemoryMediaSupplyFavoriteStore()
|
||||
ctx := auth.WithActor(context.Background(), auth.Actor{TenantID: 11, UserID: 101})
|
||||
writer := &MediaSupplyService{favoriteStore: store}
|
||||
|
||||
initial, err := writer.ListFavoriteGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, initial.Initialized)
|
||||
require.Len(t, initial.Groups, 1)
|
||||
require.Equal(t, "default", initial.Groups[0].ID)
|
||||
require.Equal(t, "默认分组", initial.Groups[0].Name)
|
||||
require.Empty(t, initial.Groups[0].ResourceIDs)
|
||||
require.False(t, initial.Groups[0].UpdatedAt.IsZero())
|
||||
|
||||
withDefaultFavorite, err := writer.PutFavoriteResource(ctx, "default", 101)
|
||||
require.NoError(t, err)
|
||||
require.True(t, withDefaultFavorite.Initialized)
|
||||
require.Equal(t, []int64{101}, withDefaultFavorite.Groups[0].ResourceIDs)
|
||||
|
||||
resourceID := int64(202)
|
||||
withGroup, err := writer.CreateFavoriteGroup(ctx, CreateMediaSupplyFavoriteGroupRequest{
|
||||
Name: "重点媒体",
|
||||
ResourceID: &resourceID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, withGroup.Groups, 2)
|
||||
groupID := withGroup.Groups[1].ID
|
||||
require.NotEmpty(t, groupID)
|
||||
require.NotEqual(t, "default", groupID)
|
||||
require.Equal(t, []int64{202}, withGroup.Groups[1].ResourceIDs)
|
||||
|
||||
idempotentCreate, err := writer.CreateFavoriteGroup(ctx, CreateMediaSupplyFavoriteGroupRequest{
|
||||
Name: "重点媒体",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, withGroup, idempotentCreate)
|
||||
|
||||
multiGroup, err := writer.PutFavoriteResource(ctx, groupID, 101)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{101}, multiGroup.Groups[0].ResourceIDs)
|
||||
require.Equal(t, []int64{202, 101}, multiGroup.Groups[1].ResourceIDs)
|
||||
|
||||
idempotentPut, err := writer.PutFavoriteResource(ctx, groupID, 101)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, multiGroup, idempotentPut)
|
||||
|
||||
reader := &MediaSupplyService{favoriteStore: store}
|
||||
readBack, err := reader.ListFavoriteGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, multiGroup, readBack)
|
||||
|
||||
removedFromGroup, err := writer.DeleteFavoriteResourceFromGroup(ctx, groupID, 101)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{101}, removedFromGroup.Groups[0].ResourceIDs)
|
||||
require.Equal(t, []int64{202}, removedFromGroup.Groups[1].ResourceIDs)
|
||||
|
||||
restored, err := writer.PutFavoriteResource(ctx, groupID, 101)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{101}, restored.Groups[0].ResourceIDs)
|
||||
require.Equal(t, []int64{202, 101}, restored.Groups[1].ResourceIDs)
|
||||
|
||||
removed, err := writer.DeleteFavoriteResource(ctx, 101)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, removed.Groups[0].ResourceIDs)
|
||||
require.Equal(t, []int64{202}, removed.Groups[1].ResourceIDs)
|
||||
idempotentDelete, err := writer.DeleteFavoriteResource(ctx, 101)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, removed, idempotentDelete)
|
||||
|
||||
deletedGroup, err := writer.DeleteFavoriteGroup(ctx, groupID)
|
||||
require.NoError(t, err)
|
||||
idempotentGroupDelete, err := writer.DeleteFavoriteGroup(ctx, groupID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, deletedGroup, idempotentGroupDelete)
|
||||
|
||||
otherUserCtx := auth.WithActor(context.Background(), auth.Actor{TenantID: 11, UserID: 102})
|
||||
otherUser, err := reader.ListFavoriteGroups(otherUserCtx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, otherUser.Initialized)
|
||||
require.Empty(t, otherUser.Groups[0].ResourceIDs)
|
||||
}
|
||||
|
||||
func TestMediaSupplyFavoriteResourceLimit(t *testing.T) {
|
||||
store := newMemoryMediaSupplyFavoriteStore()
|
||||
owner := favoriteOwner{tenantID: 11, userID: 101}
|
||||
resourceIDs := make([]int64, mediaSupplyFavoriteResourceLimit)
|
||||
for index := range resourceIDs {
|
||||
resourceIDs[index] = int64(index + 1)
|
||||
}
|
||||
store.states[owner] = mediaSupplyFavoriteState{
|
||||
Groups: []MediaSupplyFavoriteGroup{
|
||||
{
|
||||
ID: "default",
|
||||
Name: "默认分组",
|
||||
ResourceIDs: resourceIDs,
|
||||
UpdatedAt: favoriteTestTime(),
|
||||
},
|
||||
{
|
||||
ID: "secondary",
|
||||
Name: "第二分组",
|
||||
ResourceIDs: []int64{},
|
||||
UpdatedAt: favoriteTestTime(),
|
||||
},
|
||||
},
|
||||
Initialized: true,
|
||||
Revision: 1,
|
||||
}
|
||||
service := &MediaSupplyService{favoriteStore: store}
|
||||
ctx := auth.WithActor(context.Background(), auth.Actor{TenantID: owner.tenantID, UserID: owner.userID})
|
||||
|
||||
multiGroup, err := service.PutFavoriteResource(ctx, "secondary", 1)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, multiGroup.Groups[0].ResourceIDs, int64(1))
|
||||
require.Equal(t, []int64{1}, multiGroup.Groups[1].ResourceIDs)
|
||||
|
||||
_, err = service.PutFavoriteResource(ctx, "default", 501)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func ensureMemoryFavoriteState(state mediaSupplyFavoriteState) mediaSupplyFavoriteState {
|
||||
if state.Initialized {
|
||||
return state
|
||||
}
|
||||
return mediaSupplyFavoriteState{
|
||||
Groups: []MediaSupplyFavoriteGroup{{
|
||||
ID: "default",
|
||||
Name: "默认分组",
|
||||
ResourceIDs: []int64{},
|
||||
UpdatedAt: favoriteTestTime(),
|
||||
}},
|
||||
Initialized: true,
|
||||
Revision: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFavoriteState(state mediaSupplyFavoriteState) mediaSupplyFavoriteState {
|
||||
state.Groups = cloneFavoriteGroups(state.Groups)
|
||||
return state
|
||||
}
|
||||
|
||||
func cloneFavoriteGroups(groups []MediaSupplyFavoriteGroup) []MediaSupplyFavoriteGroup {
|
||||
cloned := make([]MediaSupplyFavoriteGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
group.ResourceIDs = append([]int64(nil), group.ResourceIDs...)
|
||||
cloned = append(cloned, group)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func removeFavoriteResourceID(ids []int64, resourceID int64) []int64 {
|
||||
next := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != resourceID {
|
||||
next = append(next, id)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func favoriteTestTime() time.Time {
|
||||
return time.Date(2026, time.July, 10, 10, 30, 0, 0, time.UTC)
|
||||
}
|
||||
Reference in New Issue
Block a user