diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index f667c3ffbf..270eecd87a 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1801,6 +1801,7 @@ var ( {Name: "signup_source", Type: field.TypeString, Default: "email"}, {Name: "last_login_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "last_active_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "restrict_public_groups", Type: field.TypeBool, Default: false}, {Name: "balance_notify_enabled", Type: field.TypeBool, Default: true}, {Name: "balance_notify_threshold_type", Type: field.TypeString, Default: "fixed"}, {Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index f777a0a5fb..54d284ad97 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -48422,6 +48422,7 @@ type UserMutation struct { signup_source *string last_login_at *time.Time last_active_at *time.Time + restrict_public_groups *bool balance_notify_enabled *bool balance_notify_threshold_type *string balance_notify_threshold *float64 @@ -49347,6 +49348,42 @@ func (m *UserMutation) ResetLastActiveAt() { delete(m.clearedFields, user.FieldLastActiveAt) } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (m *UserMutation) SetRestrictPublicGroups(b bool) { + m.restrict_public_groups = &b +} + +// RestrictPublicGroups returns the value of the "restrict_public_groups" field in the mutation. +func (m *UserMutation) RestrictPublicGroups() (r bool, exists bool) { + v := m.restrict_public_groups + if v == nil { + return + } + return *v, true +} + +// OldRestrictPublicGroups returns the old "restrict_public_groups" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldRestrictPublicGroups(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldRestrictPublicGroups is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldRestrictPublicGroups requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldRestrictPublicGroups: %w", err) + } + return oldValue.RestrictPublicGroups, nil +} + +// ResetRestrictPublicGroups resets all changes to the "restrict_public_groups" field. +func (m *UserMutation) ResetRestrictPublicGroups() { + m.restrict_public_groups = nil +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (m *UserMutation) SetBalanceNotifyEnabled(b bool) { m.balance_notify_enabled = &b @@ -50373,7 +50410,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 24) + fields := make([]string, 0, 25) if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -50428,6 +50465,9 @@ func (m *UserMutation) Fields() []string { if m.last_active_at != nil { fields = append(fields, user.FieldLastActiveAt) } + if m.restrict_public_groups != nil { + fields = append(fields, user.FieldRestrictPublicGroups) + } if m.balance_notify_enabled != nil { fields = append(fields, user.FieldBalanceNotifyEnabled) } @@ -50490,6 +50530,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.LastLoginAt() case user.FieldLastActiveAt: return m.LastActiveAt() + case user.FieldRestrictPublicGroups: + return m.RestrictPublicGroups() case user.FieldBalanceNotifyEnabled: return m.BalanceNotifyEnabled() case user.FieldBalanceNotifyThresholdType: @@ -50547,6 +50589,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldLastLoginAt(ctx) case user.FieldLastActiveAt: return m.OldLastActiveAt(ctx) + case user.FieldRestrictPublicGroups: + return m.OldRestrictPublicGroups(ctx) case user.FieldBalanceNotifyEnabled: return m.OldBalanceNotifyEnabled(ctx) case user.FieldBalanceNotifyThresholdType: @@ -50694,6 +50738,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetLastActiveAt(v) return nil + case user.FieldRestrictPublicGroups: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetRestrictPublicGroups(v) + return nil case user.FieldBalanceNotifyEnabled: v, ok := value.(bool) if !ok { @@ -50953,6 +51004,9 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldLastActiveAt: m.ResetLastActiveAt() return nil + case user.FieldRestrictPublicGroups: + m.ResetRestrictPublicGroups() + return nil case user.FieldBalanceNotifyEnabled: m.ResetBalanceNotifyEnabled() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 03a6894e0d..088b876b96 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -2217,24 +2217,28 @@ func init() { user.DefaultSignupSource = userDescSignupSource.Default.(string) // user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save. user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error) + // userDescRestrictPublicGroups is the schema descriptor for restrict_public_groups field. + userDescRestrictPublicGroups := userFields[15].Descriptor() + // user.DefaultRestrictPublicGroups holds the default value on creation for the restrict_public_groups field. + user.DefaultRestrictPublicGroups = userDescRestrictPublicGroups.Default.(bool) // userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field. - userDescBalanceNotifyEnabled := userFields[15].Descriptor() + userDescBalanceNotifyEnabled := userFields[16].Descriptor() // user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field. user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool) // userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field. - userDescBalanceNotifyThresholdType := userFields[16].Descriptor() + userDescBalanceNotifyThresholdType := userFields[17].Descriptor() // user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field. user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string) // userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field. - userDescBalanceNotifyExtraEmails := userFields[18].Descriptor() + userDescBalanceNotifyExtraEmails := userFields[19].Descriptor() // user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field. user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string) // userDescTotalRecharged is the schema descriptor for total_recharged field. - userDescTotalRecharged := userFields[19].Descriptor() + userDescTotalRecharged := userFields[20].Descriptor() // user.DefaultTotalRecharged holds the default value on creation for the total_recharged field. user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64) // userDescRpmLimit is the schema descriptor for rpm_limit field. - userDescRpmLimit := userFields[20].Descriptor() + userDescRpmLimit := userFields[21].Descriptor() // user.DefaultRpmLimit holds the default value on creation for the rpm_limit field. user.DefaultRpmLimit = userDescRpmLimit.Default.(int) userallowedgroupFields := schema.UserAllowedGroup{}.Fields() diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index baa7efbbd9..8337f3fd77 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -96,6 +96,11 @@ func (User) Fields() []ent.Field { Nillable(). SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + // 公开分组访问限制:为 false 时用户可绑定任意非专属分组(默认行为), + // 为 true 时仅可绑定 user_allowed_groups 中列出的公开分组。 + field.Bool("restrict_public_groups"). + Default(false), + // 余额不足通知 field.Bool("balance_notify_enabled"). Default(true), diff --git a/backend/ent/user.go b/backend/ent/user.go index 299a8d627f..a7f92208c9 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -53,6 +53,8 @@ type User struct { LastLoginAt *time.Time `json:"last_login_at,omitempty"` // LastActiveAt holds the value of the "last_active_at" field. LastActiveAt *time.Time `json:"last_active_at,omitempty"` + // RestrictPublicGroups holds the value of the "restrict_public_groups" field. + RestrictPublicGroups bool `json:"restrict_public_groups,omitempty"` // BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field. BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"` // BalanceNotifyThresholdType holds the value of the "balance_notify_threshold_type" field. @@ -237,7 +239,7 @@ func (*User) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled: + case user.FieldTotpEnabled, user.FieldRestrictPublicGroups, user.FieldBalanceNotifyEnabled: values[i] = new(sql.NullBool) case user.FieldBalance, user.FieldFrozenBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged: values[i] = new(sql.NullFloat64) @@ -381,6 +383,12 @@ func (_m *User) assignValues(columns []string, values []any) error { _m.LastActiveAt = new(time.Time) *_m.LastActiveAt = value.Time } + case user.FieldRestrictPublicGroups: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field restrict_public_groups", values[i]) + } else if value.Valid { + _m.RestrictPublicGroups = value.Bool + } case user.FieldBalanceNotifyEnabled: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i]) @@ -588,6 +596,9 @@ func (_m *User) String() string { builder.WriteString(v.Format(time.ANSIC)) } builder.WriteString(", ") + builder.WriteString("restrict_public_groups=") + builder.WriteString(fmt.Sprintf("%v", _m.RestrictPublicGroups)) + builder.WriteString(", ") builder.WriteString("balance_notify_enabled=") builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled)) builder.WriteString(", ") diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index ae1a84494d..0dac53f407 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -51,6 +51,8 @@ const ( FieldLastLoginAt = "last_login_at" // FieldLastActiveAt holds the string denoting the last_active_at field in the database. FieldLastActiveAt = "last_active_at" + // FieldRestrictPublicGroups holds the string denoting the restrict_public_groups field in the database. + FieldRestrictPublicGroups = "restrict_public_groups" // FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database. FieldBalanceNotifyEnabled = "balance_notify_enabled" // FieldBalanceNotifyThresholdType holds the string denoting the balance_notify_threshold_type field in the database. @@ -212,6 +214,7 @@ var Columns = []string{ FieldSignupSource, FieldLastLoginAt, FieldLastActiveAt, + FieldRestrictPublicGroups, FieldBalanceNotifyEnabled, FieldBalanceNotifyThresholdType, FieldBalanceNotifyThreshold, @@ -280,6 +283,8 @@ var ( DefaultSignupSource string // SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save. SignupSourceValidator func(string) error + // DefaultRestrictPublicGroups holds the default value on creation for the "restrict_public_groups" field. + DefaultRestrictPublicGroups bool // DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field. DefaultBalanceNotifyEnabled bool // DefaultBalanceNotifyThresholdType holds the default value on creation for the "balance_notify_threshold_type" field. @@ -390,6 +395,11 @@ func ByLastActiveAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldLastActiveAt, opts...).ToFunc() } +// ByRestrictPublicGroups orders the results by the restrict_public_groups field. +func ByRestrictPublicGroups(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldRestrictPublicGroups, opts...).ToFunc() +} + // ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field. func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc() diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index c2a71f6172..30c78e2144 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -145,6 +145,11 @@ func LastActiveAt(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldLastActiveAt, v)) } +// RestrictPublicGroups applies equality check predicate on the "restrict_public_groups" field. It's identical to RestrictPublicGroupsEQ. +func RestrictPublicGroups(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v)) +} + // BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ. func BalanceNotifyEnabled(v bool) predicate.User { return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v)) @@ -1115,6 +1120,16 @@ func LastActiveAtNotNil() predicate.User { return predicate.User(sql.FieldNotNull(FieldLastActiveAt)) } +// RestrictPublicGroupsEQ applies the EQ predicate on the "restrict_public_groups" field. +func RestrictPublicGroupsEQ(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v)) +} + +// RestrictPublicGroupsNEQ applies the NEQ predicate on the "restrict_public_groups" field. +func RestrictPublicGroupsNEQ(v bool) predicate.User { + return predicate.User(sql.FieldNEQ(FieldRestrictPublicGroups, v)) +} + // BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field. func BalanceNotifyEnabledEQ(v bool) predicate.User { return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v)) diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index b5bdf986a0..9d1c32f368 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -270,6 +270,20 @@ func (_c *UserCreate) SetNillableLastActiveAt(v *time.Time) *UserCreate { return _c } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (_c *UserCreate) SetRestrictPublicGroups(v bool) *UserCreate { + _c.mutation.SetRestrictPublicGroups(v) + return _c +} + +// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil. +func (_c *UserCreate) SetNillableRestrictPublicGroups(v *bool) *UserCreate { + if v != nil { + _c.SetRestrictPublicGroups(*v) + } + return _c +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate { _c.mutation.SetBalanceNotifyEnabled(v) @@ -636,6 +650,10 @@ func (_c *UserCreate) defaults() error { v := user.DefaultSignupSource _c.mutation.SetSignupSource(v) } + if _, ok := _c.mutation.RestrictPublicGroups(); !ok { + v := user.DefaultRestrictPublicGroups + _c.mutation.SetRestrictPublicGroups(v) + } if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok { v := user.DefaultBalanceNotifyEnabled _c.mutation.SetBalanceNotifyEnabled(v) @@ -730,6 +748,9 @@ func (_c *UserCreate) check() error { return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)} } } + if _, ok := _c.mutation.RestrictPublicGroups(); !ok { + return &ValidationError{Name: "restrict_public_groups", err: errors.New(`ent: missing required field "User.restrict_public_groups"`)} + } if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok { return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)} } @@ -844,6 +865,10 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldLastActiveAt, field.TypeTime, value) _node.LastActiveAt = &value } + if value, ok := _c.mutation.RestrictPublicGroups(); ok { + _spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value) + _node.RestrictPublicGroups = value + } if value, ok := _c.mutation.BalanceNotifyEnabled(); ok { _spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value) _node.BalanceNotifyEnabled = value @@ -1384,6 +1409,18 @@ func (u *UserUpsert) ClearLastActiveAt() *UserUpsert { return u } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (u *UserUpsert) SetRestrictPublicGroups(v bool) *UserUpsert { + u.Set(user.FieldRestrictPublicGroups, v) + return u +} + +// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create. +func (u *UserUpsert) UpdateRestrictPublicGroups() *UserUpsert { + u.SetExcluded(user.FieldRestrictPublicGroups) + return u +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert { u.Set(user.FieldBalanceNotifyEnabled, v) @@ -1819,6 +1856,20 @@ func (u *UserUpsertOne) ClearLastActiveAt() *UserUpsertOne { }) } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (u *UserUpsertOne) SetRestrictPublicGroups(v bool) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetRestrictPublicGroups(v) + }) +} + +// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateRestrictPublicGroups() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateRestrictPublicGroups() + }) +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne { return u.Update(func(s *UserUpsert) { @@ -2436,6 +2487,20 @@ func (u *UserUpsertBulk) ClearLastActiveAt() *UserUpsertBulk { }) } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (u *UserUpsertBulk) SetRestrictPublicGroups(v bool) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetRestrictPublicGroups(v) + }) +} + +// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateRestrictPublicGroups() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateRestrictPublicGroups() + }) +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk { return u.Update(func(s *UserUpsert) { diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index 6df9b320da..16cc771ce5 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -321,6 +321,20 @@ func (_u *UserUpdate) ClearLastActiveAt() *UserUpdate { return _u } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (_u *UserUpdate) SetRestrictPublicGroups(v bool) *UserUpdate { + _u.mutation.SetRestrictPublicGroups(v) + return _u +} + +// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil. +func (_u *UserUpdate) SetNillableRestrictPublicGroups(v *bool) *UserUpdate { + if v != nil { + _u.SetRestrictPublicGroups(*v) + } + return _u +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate { _u.mutation.SetBalanceNotifyEnabled(v) @@ -1069,6 +1083,9 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.LastActiveAtCleared() { _spec.ClearField(user.FieldLastActiveAt, field.TypeTime) } + if value, ok := _u.mutation.RestrictPublicGroups(); ok { + _spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value) + } if value, ok := _u.mutation.BalanceNotifyEnabled(); ok { _spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value) } @@ -1997,6 +2014,20 @@ func (_u *UserUpdateOne) ClearLastActiveAt() *UserUpdateOne { return _u } +// SetRestrictPublicGroups sets the "restrict_public_groups" field. +func (_u *UserUpdateOne) SetRestrictPublicGroups(v bool) *UserUpdateOne { + _u.mutation.SetRestrictPublicGroups(v) + return _u +} + +// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableRestrictPublicGroups(v *bool) *UserUpdateOne { + if v != nil { + _u.SetRestrictPublicGroups(*v) + } + return _u +} + // SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne { _u.mutation.SetBalanceNotifyEnabled(v) @@ -2775,6 +2806,9 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { if _u.mutation.LastActiveAtCleared() { _spec.ClearField(user.FieldLastActiveAt, field.TypeTime) } + if value, ok := _u.mutation.RestrictPublicGroups(); ok { + _spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value) + } if value, ok := _u.mutation.BalanceNotifyEnabled(); ok { _spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value) } diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 651dfa714f..24e4540f36 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -59,30 +59,32 @@ func NewUserHandler( // CreateUserRequest represents admin create user request type CreateUserRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` - Username string `json:"username"` - Notes string `json:"notes"` - Role string `json:"role" binding:"omitempty,oneof=admin user"` - Balance *float64 `json:"balance"` - Concurrency int `json:"concurrency"` - RPMLimit int `json:"rpm_limit"` - AllowedGroups []int64 `json:"allowed_groups"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + Username string `json:"username"` + Notes string `json:"notes"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` + Balance *float64 `json:"balance"` + Concurrency int `json:"concurrency"` + RPMLimit int `json:"rpm_limit"` + AllowedGroups []int64 `json:"allowed_groups"` + RestrictPublicGroups bool `json:"restrict_public_groups"` } // UpdateUserRequest represents admin update user request // 使用指针类型来区分"未提供"和"设置为0" type UpdateUserRequest struct { - Email string `json:"email" binding:"omitempty,email"` - Password string `json:"password" binding:"omitempty,min=6"` - Username *string `json:"username"` - Notes *string `json:"notes"` - Role string `json:"role" binding:"omitempty,oneof=admin user"` - Balance *float64 `json:"balance"` - Concurrency *int `json:"concurrency"` - RPMLimit *int `json:"rpm_limit"` - Status string `json:"status" binding:"omitempty,oneof=active disabled"` - AllowedGroups *[]int64 `json:"allowed_groups"` + Email string `json:"email" binding:"omitempty,email"` + Password string `json:"password" binding:"omitempty,min=6"` + Username *string `json:"username"` + Notes *string `json:"notes"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` + Balance *float64 `json:"balance"` + Concurrency *int `json:"concurrency"` + RPMLimit *int `json:"rpm_limit"` + Status string `json:"status" binding:"omitempty,oneof=active disabled"` + AllowedGroups *[]int64 `json:"allowed_groups"` + RestrictPublicGroups *bool `json:"restrict_public_groups"` // GroupRates 用户专属分组倍率配置 // map[groupID]*rate,nil 表示删除该分组的专属倍率 GroupRates map[int64]*float64 `json:"group_rates"` @@ -284,16 +286,17 @@ func (h *UserHandler) Create(c *gin.Context) { } user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{ - Email: req.Email, - Password: req.Password, - Username: req.Username, - Notes: req.Notes, - Role: req.Role, - Balance: req.Balance, - Concurrency: req.Concurrency, - RPMLimit: req.RPMLimit, - AllowedGroups: req.AllowedGroups, - ActorAdminID: getAdminIDFromContext(c), + Email: req.Email, + Password: req.Password, + Username: req.Username, + Notes: req.Notes, + Role: req.Role, + Balance: req.Balance, + Concurrency: req.Concurrency, + RPMLimit: req.RPMLimit, + AllowedGroups: req.AllowedGroups, + RestrictPublicGroups: req.RestrictPublicGroups, + ActorAdminID: getAdminIDFromContext(c), }) if err != nil { response.ErrorFrom(c, err) @@ -342,18 +345,19 @@ func (h *UserHandler) Update(c *gin.Context) { // 使用指针类型直接传递,nil 表示未提供该字段 user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{ - Email: req.Email, - Password: req.Password, - Username: req.Username, - Notes: req.Notes, - Role: req.Role, - Balance: req.Balance, - Concurrency: req.Concurrency, - RPMLimit: req.RPMLimit, - Status: req.Status, - AllowedGroups: req.AllowedGroups, - GroupRates: req.GroupRates, - ActorAdminID: getAdminIDFromContext(c), + Email: req.Email, + Password: req.Password, + Username: req.Username, + Notes: req.Notes, + Role: req.Role, + Balance: req.Balance, + Concurrency: req.Concurrency, + RPMLimit: req.RPMLimit, + Status: req.Status, + AllowedGroups: req.AllowedGroups, + RestrictPublicGroups: req.RestrictPublicGroups, + GroupRates: req.GroupRates, + ActorAdminID: getAdminIDFromContext(c), }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d5a9403a23..bb11bea132 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -68,10 +68,11 @@ func UserFromServiceAdmin(u *service.User) *AdminUser { return nil } return &AdminUser{ - User: *base, - Notes: u.Notes, - LastUsedAt: u.LastUsedAt, - GroupRates: u.GroupRates, + User: *base, + Notes: u.Notes, + LastUsedAt: u.LastUsedAt, + GroupRates: u.GroupRates, + RestrictPublicGroups: u.RestrictPublicGroups, } } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 2cd623f7d6..cc960104e4 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -48,6 +48,9 @@ type AdminUser struct { // GroupRates 用户专属分组倍率配置 // map[groupID]rateMultiplier GroupRates map[int64]float64 `json:"group_rates,omitempty"` + // RestrictPublicGroups 为 true 时,该用户仅可使用 allowed_groups 中列出的 + // 公开分组。这是管理侧的权限开关,不下发给用户自身的接口。 + RestrictPublicGroups bool `json:"restrict_public_groups"` } type APIKey struct { diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 829ef94af4..8ac8c6ad48 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -15,7 +15,8 @@ import ( // 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则 // 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同): // - 匿名:仅非专属分组(订阅型照常展示); -// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。 +// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性); +// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。 type ModelPlazaHandler struct { plazaService *service.ModelPlazaService apiKeyService *service.APIKeyService @@ -127,11 +128,12 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { return } - // allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。 - var allowedExclusive map[int64]struct{} + // allowedGroups == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。 + var allowedGroups map[int64]struct{} + var restrictPublicGroups bool var userRates map[int64]float64 if authed { - allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID) + allowedGroups, restrictPublicGroups, err = h.apiKeyService.GetUserGroupVisibility(c.Request.Context(), subject.UserID) if err != nil { // 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。 response.ErrorFrom(c, err) @@ -145,7 +147,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { } } - visible := filterPlazaVisibleGroups(groups, allowedExclusive) + visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups) out := make([]modelPlazaGroup, 0, len(visible)) for i := range visible { @@ -158,18 +160,21 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { } // filterPlazaVisibleGroups 按登录态裁剪分组可见性。 -// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。 +// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。 +// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会 +// 在广场看到自己实际绑定不了的分组。 func filterPlazaVisibleGroups( groups []service.PlazaGroup, - allowedExclusive map[int64]struct{}, + allowedGroups map[int64]struct{}, + restrictPublicGroups bool, ) []service.PlazaGroup { visible := make([]service.PlazaGroup, 0, len(groups)) for _, g := range groups { - if g.IsExclusive { - if allowedExclusive == nil { + if g.IsExclusive || (restrictPublicGroups && allowedGroups != nil) { + if allowedGroups == nil { continue } - if _, ok := allowedExclusive[g.ID]; !ok { + if _, ok := allowedGroups[g.ID]; !ok { continue } } diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index ab37a6db24..d57e5e1017 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -25,7 +25,7 @@ func plazaGroups() []service.PlazaGroup { func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) { // 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。 - visible := filterPlazaVisibleGroups(plazaGroups(), nil) + visible := filterPlazaVisibleGroups(plazaGroups(), nil, false) require.Len(t, visible, 2) ids := []int64{visible[0].ID, visible[1].ID} require.ElementsMatch(t, []int64{1, 3}, ids) @@ -34,7 +34,7 @@ func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) { func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) { // 登录:非专属 + 授权的专属;未授权的专属仍不可见。 allowed := map[int64]struct{}{2: {}} - visible := filterPlazaVisibleGroups(plazaGroups(), allowed) + visible := filterPlazaVisibleGroups(plazaGroups(), allowed, false) require.Len(t, visible, 3) ids := make([]int64, 0, len(visible)) for _, g := range visible { @@ -46,10 +46,33 @@ func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) { func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) { // 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属, // 但语义区分要保持——空集合不能被当作 nil 匿名分支。 - visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{}) + visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{}, false) require.Len(t, visible, 2) } +func TestFilterPlazaVisibleGroups_RestrictedUserSeesOnlyGrantedPublic(t *testing.T) { + // 开启公开分组限制后,公开分组也必须落在授权集合内,否则用户会在广场 + // 看到自己实际绑定不了的分组。 + allowed := map[int64]struct{}{1: {}, 2: {}} + visible := filterPlazaVisibleGroups(plazaGroups(), allowed, true) + ids := make([]int64, 0, len(visible)) + for _, g := range visible { + ids = append(ids, g.ID) + } + // 3 是未授权的公开分组,受限后不可见;4 是未授权的专属分组,一贯不可见。 + require.ElementsMatch(t, []int64{1, 2}, ids) +} + +func TestFilterPlazaVisibleGroups_RestrictionDoesNotAffectAnonymous(t *testing.T) { + // 匿名没有用户记录,限制标志无从谈起,可见性必须与未受限时一致。 + visible := filterPlazaVisibleGroups(plazaGroups(), nil, true) + ids := make([]int64, 0, len(visible)) + for _, g := range visible { + ids = append(ids, g.ID) + } + require.ElementsMatch(t, []int64{1, 3}, ids) +} + func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) { gin.SetMode(gin.TestMode) h := &ModelPlazaHandler{} // settingService == nil → fail-closed diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 388562faeb..6e517f4524 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -155,6 +155,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se user.FieldBalance, user.FieldConcurrency, user.FieldBalanceNotifyEnabled, + user.FieldRestrictPublicGroups, user.FieldBalanceNotifyThresholdType, user.FieldBalanceNotifyThreshold, user.FieldBalanceNotifyExtraEmails, @@ -931,6 +932,7 @@ func userEntityToService(u *dbent.User) *service.User { TotpEnabled: u.TotpEnabled, TotpEnabledAt: u.TotpEnabledAt, BalanceNotifyEnabled: u.BalanceNotifyEnabled, + RestrictPublicGroups: u.RestrictPublicGroups, BalanceNotifyThresholdType: u.BalanceNotifyThresholdType, BalanceNotifyThreshold: u.BalanceNotifyThreshold, TotalRecharged: u.TotalRecharged, diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 3d02d6fbff..a0d7006616 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -153,6 +153,7 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard SetNillableLastLoginAt(userIn.LastLoginAt). SetNillableLastActiveAt(userIn.LastActiveAt). SetRpmLimit(userIn.RPMLimit). + SetRestrictPublicGroups(userIn.RestrictPublicGroups). Save(txCtx) if err != nil { return translatePersistenceError(err, nil, service.ErrEmailExists) @@ -316,6 +317,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User, field if fields.Status { updateOp = updateOp.SetStatus(userIn.Status) } + if fields.RestrictPublicGroups { + updateOp = updateOp.SetRestrictPublicGroups(userIn.RestrictPublicGroups) + } if fields.BalanceNotifySettings { updateOp = updateOp. SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled). diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index be56680764..e48eea51c1 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -139,15 +139,16 @@ type AdminService interface { // CreateUserInput represents input for creating a new user via admin operations. type CreateUserInput struct { - Email string - Password string - Username string - Notes string - Role string // 空字符串表示使用默认角色(user);合法值 admin/user - Balance *float64 - Concurrency int - RPMLimit int - AllowedGroups []int64 + Email string + Password string + Username string + Notes string + Role string // 空字符串表示使用默认角色(user);合法值 admin/user + Balance *float64 + Concurrency int + RPMLimit int + AllowedGroups []int64 + RestrictPublicGroups bool // ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。 ActorAdminID int64 } @@ -163,6 +164,8 @@ type UpdateUserInput struct { RPMLimit *int // 使用指针区分"未提供"和"设置为0" Status string AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组" + // RestrictPublicGroups 指针区分"未提供"和"显式开关"。 + RestrictPublicGroups *bool // GroupRates 用户专属分组倍率配置 // map[groupID]*rate,nil 表示删除该分组的专属倍率 GroupRates map[int64]*float64 diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go index 8d36df5adc..12f71f96a2 100644 --- a/backend/internal/service/admin_user.go +++ b/backend/internal/service/admin_user.go @@ -141,6 +141,8 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu RPMLimit: input.RPMLimit, Status: StatusActive, AllowedGroups: input.AllowedGroups, + + RestrictPublicGroups: input.RestrictPublicGroups, } if err := user.SetPassword(input.Password); err != nil { return nil, err @@ -279,6 +281,12 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda fields.AllowedGroups = true } + oldRestrictPublicGroups := user.RestrictPublicGroups + if input.RestrictPublicGroups != nil { + user.RestrictPublicGroups = *input.RestrictPublicGroups + fields.RestrictPublicGroups = true + } + if err := s.userRepo.Update(ctx, user, fields); err != nil { return nil, err } @@ -299,7 +307,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda if s.authCacheInvalidator != nil { // RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联, // allowed_groups 参与 API Key 专属分组授权判断;不失效缓存会让修改在一个 L2 TTL 内失去效果。 - if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) { + if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || user.RestrictPublicGroups != oldRestrictPublicGroups || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) { s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID) } } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 30a41eece6..71e0318dc0 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -41,6 +41,7 @@ type APIKeyAuthUserSnapshot struct { Email string `json:"email"` Username string `json:"username"` BalanceNotifyEnabled bool `json:"balance_notify_enabled"` + RestrictPublicGroups bool `json:"restrict_public_groups"` BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"` BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 7cb37baf48..3c95141a47 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -360,6 +360,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) Email: apiKey.User.Email, Username: apiKey.User.Username, BalanceNotifyEnabled: apiKey.User.BalanceNotifyEnabled, + RestrictPublicGroups: apiKey.User.RestrictPublicGroups, BalanceNotifyThresholdType: apiKey.User.BalanceNotifyThresholdType, BalanceNotifyThreshold: apiKey.User.BalanceNotifyThreshold, BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails, @@ -464,6 +465,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho Email: snapshot.User.Email, Username: snapshot.User.Username, BalanceNotifyEnabled: snapshot.User.BalanceNotifyEnabled, + RestrictPublicGroups: snapshot.User.RestrictPublicGroups, BalanceNotifyThresholdType: snapshot.User.BalanceNotifyThresholdType, BalanceNotifyThreshold: snapshot.User.BalanceNotifyThreshold, BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails, diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 8ce62a688f..f90ce0b000 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -1071,20 +1071,21 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword return keys, nil } -// GetUserAllowedGroupIDSet 返回 user_allowed_groups 授权给该用户的专属分组 ID 集合。 +// GetUserGroupVisibility 返回 user_allowed_groups 授权给该用户的分组 ID 集合, +// 以及该用户是否开启了公开分组限制。开启时公开分组的可见性也要落在该集合内。 // // 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性, // 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。 -func (s *APIKeyService) GetUserAllowedGroupIDSet(ctx context.Context, userID int64) (map[int64]struct{}, error) { +func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64) (map[int64]struct{}, bool, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { - return nil, fmt.Errorf("get user: %w", err) + return nil, false, fmt.Errorf("get user: %w", err) } allowed := make(map[int64]struct{}, len(user.AllowedGroups)) for _, id := range user.AllowedGroups { allowed[id] = struct{}{} } - return allowed, nil + return allowed, user.RestrictPublicGroups, nil } // GetUserGroupRates 获取用户的专属分组倍率配置 diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 22a21a4634..7f19550fa8 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -23,7 +23,11 @@ type User struct { Concurrency int Status string AllowedGroups []int64 - TokenVersion int64 // Incremented on password change to invalidate existing tokens + // RestrictPublicGroups narrows the public groups this user may bind to the + // ones listed in AllowedGroups. False keeps the default, where every public + // group is bindable. + RestrictPublicGroups bool + TokenVersion int64 // Incremented on password change to invalidate existing tokens // TokenVersionResolved indicates TokenVersion already contains the fingerprint-derived // value expected in JWT claims and refresh-token state. TokenVersionResolved bool @@ -74,14 +78,16 @@ func (u *User) IsActive() bool { // CanBindGroup checks whether a user can bind to a given group. // For standard groups: -// - Public groups (non-exclusive): all users can bind -// - Exclusive groups: only users with the group in AllowedGroups can bind +// - Public groups (non-exclusive): bindable by every user, unless the user has +// RestrictPublicGroups set, in which case the group must be in AllowedGroups +// - Exclusive groups: only users with the group in AllowedGroups can bind func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool { - // 公开分组(非专属):所有用户都可以绑定 - if !isExclusive { + // 公开分组(非专属):默认所有用户都可以绑定;仅当该用户开启了公开分组 + // 限制时,才需要落在 AllowedGroups 中。 + if !isExclusive && !u.RestrictPublicGroups { return true } - // 专属分组:需要在 AllowedGroups 中 + // 专属分组,以及受限用户的公开分组:需要在 AllowedGroups 中 for _, id := range u.AllowedGroups { if id == groupID { return true diff --git a/backend/internal/service/user_can_bind_group_test.go b/backend/internal/service/user_can_bind_group_test.go new file mode 100644 index 0000000000..ea6082b0e2 --- /dev/null +++ b/backend/internal/service/user_can_bind_group_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserCanBindGroup(t *testing.T) { + const ( + publicGroupA int64 = 10 + publicGroupB int64 = 11 + exclusiveGroupA int64 = 20 + ) + + for _, tc := range []struct { + name string + user User + groupID int64 + isExclusive bool + want bool + }{ + { + name: "public group is bindable by default", + user: User{}, + groupID: publicGroupA, + want: true, + }, + { + name: "exclusive group needs an explicit grant", + user: User{}, + groupID: exclusiveGroupA, + isExclusive: true, + want: false, + }, + { + name: "granted exclusive group is bindable", + user: User{AllowedGroups: []int64{exclusiveGroupA}}, + groupID: exclusiveGroupA, + isExclusive: true, + want: true, + }, + { + name: "an unrestricted user keeps every public group even with a grant list", + user: User{AllowedGroups: []int64{exclusiveGroupA}}, + groupID: publicGroupA, + want: true, + }, + { + name: "a restricted user keeps the public groups listed for them", + user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA}}, + groupID: publicGroupA, + want: true, + }, + { + name: "a restricted user loses the public groups not listed for them", + user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA}}, + groupID: publicGroupB, + want: false, + }, + { + name: "a restricted user with no list keeps no public group", + user: User{RestrictPublicGroups: true}, + groupID: publicGroupA, + want: false, + }, + { + name: "restricting public groups does not widen exclusive access", + user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA}}, + groupID: exclusiveGroupA, + isExclusive: true, + want: false, + }, + { + name: "a restricted user can still hold both kinds of grant", + user: User{RestrictPublicGroups: true, AllowedGroups: []int64{publicGroupA, exclusiveGroupA}}, + groupID: exclusiveGroupA, + isExclusive: true, + want: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + user := tc.user + require.Equal(t, tc.want, user.CanBindGroup(tc.groupID, tc.isExclusive)) + }) + } +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 2bfab8d586..ee9a7f257f 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -113,6 +113,8 @@ type UserUpdateFields struct { BalanceNotifyExtraEmails bool // AllowedGroups 为 true 时才同步 user_allowed_groups 关联表。 AllowedGroups bool + // RestrictPublicGroups 覆盖 restrict_public_groups 列。 + RestrictPublicGroups bool } // BalanceChange 记录一次余额变更前后的值。 diff --git a/backend/migrations/231_user_restrict_public_groups.sql b/backend/migrations/231_user_restrict_public_groups.sql new file mode 100644 index 0000000000..21ae4139fd --- /dev/null +++ b/backend/migrations/231_user_restrict_public_groups.sql @@ -0,0 +1,7 @@ +-- Per-user access control for public (non-exclusive) groups. +-- +-- Public groups have always been bindable by every user. When this flag is +-- enabled for a user, the public groups they may bind are narrowed to the ones +-- listed in user_allowed_groups, which until now only carried exclusive groups. +-- The default keeps every existing user unrestricted. +ALTER TABLE users ADD COLUMN IF NOT EXISTS restrict_public_groups BOOLEAN NOT NULL DEFAULT false; diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index ac822d4132..6360680e7e 100644 --- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue +++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue @@ -95,11 +95,23 @@
{{ t('admin.users.restrictPublicGroupsHint') }}