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) }