67a6cbcd9e
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>
380 lines
11 KiB
Go
380 lines
11 KiB
Go
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)
|
|
}
|