From 3fc08745a6a41dc8e77a526e4585d7b6cf045f22 Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:37:53 +0800 Subject: [PATCH] fix(model-plaza): include active subscription groups --- .../internal/handler/model_plaza_handler.go | 4 +- .../handler/model_plaza_handler_test.go | 14 +++ .../service/api_key_group_visibility_test.go | 100 ++++++++++++++++++ backend/internal/service/api_key_service.go | 13 ++- 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 backend/internal/service/api_key_group_visibility_test.go diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 8ac8c6ad48..a4d8d502bb 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -15,7 +15,7 @@ import ( // 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则 // 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同): // - 匿名:仅非专属分组(订阅型照常展示); -// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性); +// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组; // 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。 type ModelPlazaHandler struct { plazaService *service.ModelPlazaService @@ -160,7 +160,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { } // filterPlazaVisibleGroups 按登录态裁剪分组可见性。 -// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。 +// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。 // restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会 // 在广场看到自己实际绑定不了的分组。 func filterPlazaVisibleGroups( diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index d57e5e1017..2de6cbdbe5 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -244,3 +244,17 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { weekdaysTP := weekdaysModel["time_pricing"].(map[string]any) require.Equal(t, true, weekdaysTP["weekdays_only"]) } + +func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) { + groups := []service.PlazaGroup{ + {ID: 42, IsExclusive: true, SubscriptionType: "subscription"}, + {ID: 43, IsExclusive: true, SubscriptionType: "subscription"}, + {ID: 44, IsExclusive: true, SubscriptionType: "standard"}, + } + require.Empty(t, filterPlazaVisibleGroups(groups, nil, false)) + for _, restricted := range []bool{false, true} { + visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted) + require.Len(t, visible, 1) + require.Equal(t, int64(42), visible[0].ID) + } +} diff --git a/backend/internal/service/api_key_group_visibility_test.go b/backend/internal/service/api_key_group_visibility_test.go new file mode 100644 index 0000000000..77efcdd1e8 --- /dev/null +++ b/backend/internal/service/api_key_group_visibility_test.go @@ -0,0 +1,100 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type visibilityUserRepo struct { + UserRepository + user *User + err error +} + +func (r *visibilityUserRepo) GetByID(context.Context, int64) (*User, error) { return r.user, r.err } + +type visibilitySubRepo struct { + UserSubscriptionRepository + subscriptions []UserSubscription + err error + calls int +} + +func (r *visibilitySubRepo) ListActiveByUserID(_ context.Context, userID int64) ([]UserSubscription, error) { + r.calls++ + // Match the repository's active-status and expiry predicates. + active := make([]UserSubscription, 0) + for _, sub := range r.subscriptions { + if sub.UserID == userID && sub.IsActive() { + active = append(active, sub) + } + } + return active, r.err +} + +type visibilityGroupRepo struct { + GroupRepository + groups []Group +} + +func (r *visibilityGroupRepo) ListActive(context.Context) ([]Group, error) { return r.groups, nil } + +func TestGetUserGroupVisibilityIncludesActiveSubscriptions(t *testing.T) { + for _, restricted := range []bool{false, true} { + t.Run(map[bool]string{false: "unrestricted", true: "restricted"}[restricted], func(t *testing.T) { + now := time.Now() + subs := &visibilitySubRepo{subscriptions: []UserSubscription{ + {UserID: 1, GroupID: 42, Status: SubscriptionStatusActive, ExpiresAt: now.Add(time.Hour)}, + {UserID: 1, GroupID: 43, Status: SubscriptionStatusActive, ExpiresAt: now.Add(-time.Hour)}, + {UserID: 1, GroupID: 44, Status: "expired", ExpiresAt: now.Add(time.Hour)}, + {UserID: 2, GroupID: 45, Status: SubscriptionStatusActive, ExpiresAt: now.Add(time.Hour)}, + }} + svc := &APIKeyService{ + userRepo: &visibilityUserRepo{user: &User{ID: 1, AllowedGroups: []int64{7}, RestrictPublicGroups: restricted}}, + userSubRepo: subs, + groupRepo: &visibilityGroupRepo{groups: []Group{{ID: 42, IsExclusive: true, SubscriptionType: "subscription"}}}, + } + available, err := svc.GetAvailableGroups(context.Background(), 1) + require.NoError(t, err) + require.Len(t, available, 1) + visible, restrict, err := svc.GetUserGroupVisibility(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, restricted, restrict) + require.Equal(t, map[int64]struct{}{7: {}, 42: {}}, visible) + require.Contains(t, visible, available[0].ID, "a subscribed group that can be bound must be visible") + }) + } +} + +func TestGetUserGroupVisibilityEmptyAndErrors(t *testing.T) { + failure := errors.New("repository unavailable") + for _, tc := range []struct { + name string + userErr, subErr error + }{ + {name: "empty"}, {name: "user failure", userErr: failure}, {name: "subscription failure", subErr: failure}, + } { + t.Run(tc.name, func(t *testing.T) { + subs := &visibilitySubRepo{err: tc.subErr} + svc := &APIKeyService{userRepo: &visibilityUserRepo{user: &User{ID: 1}, err: tc.userErr}, userSubRepo: subs} + got, _, err := svc.GetUserGroupVisibility(context.Background(), 1) + if tc.userErr != nil || tc.subErr != nil { + require.ErrorIs(t, err, failure) + require.Nil(t, got, "repository failures must not become anonymous visibility") + } else { + require.NoError(t, err) + require.NotNil(t, got, "an empty logged-in user must not become anonymous") + require.Empty(t, got) + } + if tc.userErr != nil { + require.Zero(t, subs.calls) + } + }) + } +} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index f90ce0b000..5288f4ff8b 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -1071,11 +1071,11 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword return keys, nil } -// GetUserGroupVisibility 返回 user_allowed_groups 授权给该用户的分组 ID 集合, +// GetUserGroupVisibility 返回 user_allowed_groups 授权及有效订阅的分组 ID 集合, // 以及该用户是否开启了公开分组限制。开启时公开分组的可见性也要落在该集合内。 // -// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性, -// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。 +// 与 GetAvailableGroups 的区别:这里保留普通授权分组的「橱窗」语义,不检查 +// 分组是否活跃;有效订阅也授予对应分组的可见性。返回值恒非 nil。 func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64) (map[int64]struct{}, bool, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -1085,6 +1085,13 @@ func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64 for _, id := range user.AllowedGroups { allowed[id] = struct{}{} } + subscriptions, err := s.userSubRepo.ListActiveByUserID(ctx, userID) + if err != nil { + return nil, false, fmt.Errorf("list active subscriptions: %w", err) + } + for _, sub := range subscriptions { + allowed[sub.GroupID] = struct{}{} + } return allowed, user.RestrictPublicGroups, nil }