mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
feat(admin): let admins restrict which public groups a user may access
Public groups have always been bindable by every user: CanBindGroup returned true for any non-exclusive group, and user_allowed_groups only ever carried the exclusive groups an admin had granted. Admins had no way to hand a single user a subset of the public groups short of converting a group to exclusive, which changes it for everyone already using it. A user now carries restrict_public_groups. While it is false, which is the default and what every existing row migrates to, nothing changes: every public group stays bindable. Once an admin turns it on for a user, that user's public groups are narrowed to the ones listed in user_allowed_groups, the same table that already gates exclusive groups. The flag is an administrative control, so it rides on the admin user DTO only and leaves the shape of the end-user endpoints alone. The model plaza filter honours the flag as well, so a restricted user is not shown groups they would be refused when binding a key. Anonymous visitors have no user record and keep the previous view. Enforcement rides on the existing CanBindGroup choke point, so it covers key creation, key updates, and per-request authorization together. An API key bound to a group that is later withdrawn stops working at request time rather than lingering as a key that can be listed but not used. The admin dialog gains a toggle over the public group list. Turning it off re-checks every public group, so an admin cannot save a list that reads as restrictive while the restriction itself is disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
efb46db0a9
commit
b56c61eccd
@@ -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)"}},
|
||||
|
||||
+55
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
+12
-1
@@ -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(", ")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 获取用户的专属分组倍率配置
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,8 @@ type UserUpdateFields struct {
|
||||
BalanceNotifyExtraEmails bool
|
||||
// AllowedGroups 为 true 时才同步 user_allowed_groups 关联表。
|
||||
AllowedGroups bool
|
||||
// RestrictPublicGroups 覆盖 restrict_public_groups 列。
|
||||
RestrictPublicGroups bool
|
||||
}
|
||||
|
||||
// BalanceChange 记录一次余额变更前后的值。
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user