Merge pull request #6303 from akihitohyh/feat/per-user-public-group-access

feat(admin): 支持为单个用户限制可访问的公开分组
This commit is contained in:
Wesley Liddick
2026-08-28 13:19:11 +08:00
committed by GitHub
29 changed files with 507 additions and 93 deletions
+1
View File
@@ -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
View File
@@ -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
+9 -5
View File
@@ -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()
+5
View File
@@ -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
View File
@@ -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(", ")
+10
View File
@@ -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()
+15
View File
@@ -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))
+65
View File
@@ -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) {
+34
View File
@@ -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)
}
+45 -41
View File
@@ -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]*ratenil 表示删除该分组的专属倍率
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)
+5 -4
View File
@@ -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,
}
}
+3
View File
@@ -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 -10
View File
@@ -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,
+4
View File
@@ -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).
+12 -9
View File
@@ -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]*ratenil 表示删除该分组的专属倍率
GroupRates map[int64]*float64
+9 -1
View File
@@ -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,
+5 -4
View File
@@ -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 获取用户的专属分组倍率配置
+12 -6
View File
@@ -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))
})
}
}
+2
View File
@@ -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;
@@ -95,11 +95,23 @@
<!-- 公开分组区域 -->
<div v-if="publicGroups.length > 0">
<div class="mb-3 flex items-center gap-2">
<div class="mb-3 flex flex-wrap items-center gap-2">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.publicGroups') }}</h4>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
{{ restrictPublicGroups ? t('admin.users.publicGroupsRestricted') : t('admin.users.publicGroups') }}
</h4>
<span class="text-xs text-gray-400">({{ publicGroupConfigs.length }})</span>
<label class="ml-auto flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
:checked="restrictPublicGroups"
@change="toggleRestrictPublicGroups"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
{{ t('admin.users.restrictPublicGroups') }}
</label>
</div>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.users.restrictPublicGroupsHint') }}</p>
<div class="grid gap-3">
<div
v-for="config in publicGroupConfigs"
@@ -107,9 +119,19 @@
class="relative overflow-hidden rounded-xl border-2 border-green-200 bg-green-50/50 p-4 dark:border-green-800/50 dark:bg-green-900/10"
>
<div class="flex items-center gap-4">
<!-- 复选框禁用状态 -->
<!-- 未开启限制时公开分组恒可用此处仅作展示开启后才是真实开关 -->
<div class="flex-shrink-0">
<div class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600">
<input
v-if="restrictPublicGroups"
type="checkbox"
:checked="config.isSelected"
@change="togglePublicGroup(config.groupId)"
class="h-5 w-5 cursor-pointer rounded-md border-2 border-green-400 text-green-600 focus:ring-green-500 dark:border-green-600"
/>
<div
v-else
class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600"
>
<svg class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
@@ -207,6 +229,7 @@ const groupConfigs = ref<GroupRateConfig[]>([])
const originalGroupRates = ref<Record<number, number>>({}) //
const loading = ref(false)
const submitting = ref(false)
const restrictPublicGroups = ref(false)
//
const exclusiveGroups = computed(() => groups.value.filter((g) => g.is_exclusive))
@@ -234,6 +257,7 @@ const load = async () => {
//
const userAllowedGroups = props.user?.allowed_groups || []
const userGroupRates = props.user?.group_rates || {}
restrictPublicGroups.value = props.user?.restrict_public_groups ?? false
//
originalGroupRates.value = { ...userGroupRates }
@@ -246,8 +270,9 @@ const load = async () => {
defaultRate: g.rate_multiplier,
customRate: userGroupRates[g.id] ?? null,
// allowed_groups
//
isSelected: g.is_exclusive ? userAllowedGroups.includes(g.id) : true,
// allowed_groups
isSelected:
g.is_exclusive || restrictPublicGroups.value ? userAllowedGroups.includes(g.id) : true,
}))
} catch (error) {
console.error('Failed to load groups:', error)
@@ -263,6 +288,23 @@ const toggleExclusiveGroup = (groupId: number) => {
}
}
const togglePublicGroup = (groupId: number) => {
const config = groupConfigs.value.find((c) => c.groupId === groupId)
if (config && !config.isExclusive) {
config.isSelected = !config.isSelected
}
}
// ""
const toggleRestrictPublicGroups = () => {
restrictPublicGroups.value = !restrictPublicGroups.value
if (!restrictPublicGroups.value) {
for (const config of groupConfigs.value) {
if (!config.isExclusive) config.isSelected = true
}
}
}
const updateCustomRate = (groupId: number, value: string) => {
const config = groupConfigs.value.find((c) => c.groupId === groupId)
if (config) {
@@ -280,8 +322,11 @@ const handleSave = async () => {
submitting.value = true
try {
// allowed_groups
const allowedGroups = groupConfigs.value.filter((c) => c.isExclusive && c.isSelected).map((c) => c.groupId)
// allowed_groups
// ""
const allowedGroups = groupConfigs.value
.filter((c) => c.isSelected && (c.isExclusive || restrictPublicGroups.value))
.map((c) => c.groupId)
// group_rates
// - :
@@ -301,6 +346,7 @@ const handleSave = async () => {
await adminAPI.users.update(props.user.id, {
allowed_groups: allowedGroups,
restrict_public_groups: restrictPublicGroups.value,
group_rates: Object.keys(groupRates).length > 0 ? groupRates : undefined,
})
@@ -612,6 +612,9 @@ export default {
groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)',
exclusiveGroups: 'Exclusive Groups',
publicGroups: 'Public Groups (Default Available)',
restrictPublicGroups: 'Restrict accessible public groups',
restrictPublicGroupsHint: 'When on, this user may only use the public groups checked below. When off, every public group stays available.',
publicGroupsRestricted: 'Public Groups (Restricted)',
defaultRate: 'Default Rate',
customRate: 'Custom Rate',
useDefaultRate: 'Use Default',
@@ -616,6 +616,9 @@ export default {
groupConfigHint: '为用户 {email} 配置专属分组倍率(覆盖分组默认倍率)',
exclusiveGroups: '专属分组',
publicGroups: '公开分组(默认可用)',
restrictPublicGroups: '限制可访问的公开分组',
restrictPublicGroupsHint: '开启后,该用户仅能使用下方勾选的公开分组;关闭则可使用全部公开分组。',
publicGroupsRestricted: '公开分组(已限制)',
defaultRate: '默认倍率',
customRate: '专属倍率',
useDefaultRate: '使用默认',
+4
View File
@@ -107,6 +107,9 @@ export interface AdminUser extends User {
last_used_at?: string | null
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
group_rates?: Record<number, number>
// 为 true 时该用户仅可使用 allowed_groups 中列出的公开分组。
// 管理侧权限开关,普通用户接口不返回。
restrict_public_groups?: boolean
// 当前并发数(仅管理员列表接口返回)
current_concurrency?: number
}
@@ -1973,6 +1976,7 @@ export interface UpdateUserRequest {
rpm_limit?: number
status?: 'active' | 'disabled'
allowed_groups?: number[] | null
restrict_public_groups?: boolean
// 用户专属分组倍率配置 (group_id -> rate_multiplier | null)
// null 表示删除该分组的专属倍率
group_rates?: Record<number, number | null>