diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 6f9b9c6d24..3a173cbab5 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -173,10 +173,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { leaderLockCache := repository.NewLeaderLockCache(redisClient) dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) + adminGroupRepository := repository.NewAdminGroupRepository(client, db) adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) + adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) diff --git a/backend/ent/group.go b/backend/ent/group.go index 088da069d6..e6ffdec453 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -43,6 +43,8 @@ type Group struct { IsExclusive bool `json:"is_exclusive,omitempty"` // Status holds the value of the "status" field. Status string `json:"status,omitempty"` + // 内部幂等恢复标识,不对 API 暴露 + DuplicateOperationID *string `json:"duplicate_operation_id,omitempty"` // Platform holds the value of the "platform" field. Platform string `json:"platform,omitempty"` // SubscriptionType holds the value of the "subscription_type" field. @@ -229,7 +231,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) - case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: + case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: values[i] = new(sql.NullString) case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -328,6 +330,13 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Status = value.String } + case group.FieldDuplicateOperationID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field duplicate_operation_id", values[i]) + } else if value.Valid { + _m.DuplicateOperationID = new(string) + *_m.DuplicateOperationID = value.String + } case group.FieldPlatform: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field platform", values[i]) @@ -680,6 +689,11 @@ func (_m *Group) String() string { builder.WriteString("status=") builder.WriteString(_m.Status) builder.WriteString(", ") + if v := _m.DuplicateOperationID; v != nil { + builder.WriteString("duplicate_operation_id=") + builder.WriteString(*v) + } + builder.WriteString(", ") builder.WriteString("platform=") builder.WriteString(_m.Platform) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 61d7a21d67..efe2eaa066 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -40,6 +40,8 @@ const ( FieldIsExclusive = "is_exclusive" // FieldStatus holds the string denoting the status field in the database. FieldStatus = "status" + // FieldDuplicateOperationID holds the string denoting the duplicate_operation_id field in the database. + FieldDuplicateOperationID = "duplicate_operation_id" // FieldPlatform holds the string denoting the platform field in the database. FieldPlatform = "platform" // FieldSubscriptionType holds the string denoting the subscription_type field in the database. @@ -199,6 +201,7 @@ var Columns = []string{ FieldPeakRateMultiplier, FieldIsExclusive, FieldStatus, + FieldDuplicateOperationID, FieldPlatform, FieldSubscriptionType, FieldDailyLimitUsd, @@ -292,6 +295,8 @@ var ( DefaultStatus string // StatusValidator is a validator for the "status" field. It is called by the builders before save. StatusValidator func(string) error + // DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save. + DuplicateOperationIDValidator func(string) error // DefaultPlatform holds the default value on creation for the "platform" field. DefaultPlatform string // PlatformValidator is a validator for the "platform" field. It is called by the builders before save. @@ -414,6 +419,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldStatus, opts...).ToFunc() } +// ByDuplicateOperationID orders the results by the duplicate_operation_id field. +func ByDuplicateOperationID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDuplicateOperationID, opts...).ToFunc() +} + // ByPlatform orders the results by the platform field. func ByPlatform(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldPlatform, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index b9a52a2eb6..441326dba5 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -115,6 +115,11 @@ func Status(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldStatus, v)) } +// DuplicateOperationID applies equality check predicate on the "duplicate_operation_id" field. It's identical to DuplicateOperationIDEQ. +func DuplicateOperationID(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v)) +} + // Platform applies equality check predicate on the "platform" field. It's identical to PlatformEQ. func Platform(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldPlatform, v)) @@ -840,6 +845,81 @@ func StatusContainsFold(v string) predicate.Group { return predicate.Group(sql.FieldContainsFold(FieldStatus, v)) } +// DuplicateOperationIDEQ applies the EQ predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDNEQ applies the NEQ predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDIn applies the In predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldDuplicateOperationID, vs...)) +} + +// DuplicateOperationIDNotIn applies the NotIn predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldDuplicateOperationID, vs...)) +} + +// DuplicateOperationIDGT applies the GT predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDGTE applies the GTE predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDLT applies the LT predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDLTE applies the LTE predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDContains applies the Contains predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDHasPrefix applies the HasPrefix predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDHasSuffix applies the HasSuffix predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDIsNil applies the IsNil predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldDuplicateOperationID)) +} + +// DuplicateOperationIDNotNil applies the NotNil predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldDuplicateOperationID)) +} + +// DuplicateOperationIDEqualFold applies the EqualFold predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDContainsFold applies the ContainsFold predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldDuplicateOperationID, v)) +} + // PlatformEQ applies the EQ predicate on the "platform" field. func PlatformEQ(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldPlatform, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 53fd733c1f..207b54fee0 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -189,6 +189,20 @@ func (_c *GroupCreate) SetNillableStatus(v *string) *GroupCreate { return _c } +// SetDuplicateOperationID sets the "duplicate_operation_id" field. +func (_c *GroupCreate) SetDuplicateOperationID(v string) *GroupCreate { + _c.mutation.SetDuplicateOperationID(v) + return _c +} + +// SetNillableDuplicateOperationID sets the "duplicate_operation_id" field if the given value is not nil. +func (_c *GroupCreate) SetNillableDuplicateOperationID(v *string) *GroupCreate { + if v != nil { + _c.SetDuplicateOperationID(*v) + } + return _c +} + // SetPlatform sets the "platform" field. func (_c *GroupCreate) SetPlatform(v string) *GroupCreate { _c.mutation.SetPlatform(v) @@ -993,6 +1007,11 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)} } } + if v, ok := _c.mutation.DuplicateOperationID(); ok { + if err := group.DuplicateOperationIDValidator(v); err != nil { + return &ValidationError{Name: "duplicate_operation_id", err: fmt.Errorf(`ent: validator failed for field "Group.duplicate_operation_id": %w`, err)} + } + } if _, ok := _c.mutation.Platform(); !ok { return &ValidationError{Name: "platform", err: errors.New(`ent: missing required field "Group.platform"`)} } @@ -1152,6 +1171,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldStatus, field.TypeString, value) _node.Status = value } + if value, ok := _c.mutation.DuplicateOperationID(); ok { + _spec.SetField(group.FieldDuplicateOperationID, field.TypeString, value) + _node.DuplicateOperationID = &value + } if value, ok := _c.mutation.Platform(); ok { _spec.SetField(group.FieldPlatform, field.TypeString, value) _node.Platform = value @@ -2246,6 +2269,9 @@ func (u *GroupUpsertOne) UpdateNewValues() *GroupUpsertOne { if _, exists := u.create.mutation.CreatedAt(); exists { s.SetIgnore(group.FieldCreatedAt) } + if _, exists := u.create.mutation.DuplicateOperationID(); exists { + s.SetIgnore(group.FieldDuplicateOperationID) + } })) return u } @@ -3366,6 +3392,9 @@ func (u *GroupUpsertBulk) UpdateNewValues() *GroupUpsertBulk { if _, exists := b.mutation.CreatedAt(); exists { s.SetIgnore(group.FieldCreatedAt) } + if _, exists := b.mutation.DuplicateOperationID(); exists { + s.SetIgnore(group.FieldDuplicateOperationID) + } } })) return u diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 1e767a139b..234e4fe96c 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1273,6 +1273,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Status(); ok { _spec.SetField(group.FieldStatus, field.TypeString, value) } + if _u.mutation.DuplicateOperationIDCleared() { + _spec.ClearField(group.FieldDuplicateOperationID, field.TypeString) + } if value, ok := _u.mutation.Platform(); ok { _spec.SetField(group.FieldPlatform, field.TypeString, value) } @@ -3063,6 +3066,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.Status(); ok { _spec.SetField(group.FieldStatus, field.TypeString, value) } + if _u.mutation.DuplicateOperationIDCleared() { + _spec.ClearField(group.FieldDuplicateOperationID, field.TypeString) + } if value, ok := _u.mutation.Platform(); ok { _spec.SetField(group.FieldPlatform, field.TypeString, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 994780cd42..c09f1f26ab 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -845,6 +845,7 @@ var ( {Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "is_exclusive", Type: field.TypeBool, Default: false}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, + {Name: "duplicate_operation_id", Type: field.TypeString, Nullable: true, Size: 64}, {Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"}, {Name: "subscription_type", Type: field.TypeString, Size: 20, Default: "standard"}, {Name: "daily_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, @@ -896,12 +897,12 @@ var ( { Name: "group_platform", Unique: false, - Columns: []*schema.Column{GroupsColumns[13]}, + Columns: []*schema.Column{GroupsColumns[14]}, }, { Name: "group_subscription_type", Unique: false, - Columns: []*schema.Column{GroupsColumns[14]}, + Columns: []*schema.Column{GroupsColumns[15]}, }, { Name: "group_is_exclusive", @@ -916,7 +917,15 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[41]}, + Columns: []*schema.Column{GroupsColumns[42]}, + }, + { + Name: "idx_groups_duplicate_operation_id_active", + Unique: true, + Columns: []*schema.Column{GroupsColumns[13]}, + Annotation: &entsql.IndexAnnotation{ + Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL", + }, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 46b97462eb..9e746e19da 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -20808,6 +20808,7 @@ type GroupMutation struct { addpeak_rate_multiplier *float64 is_exclusive *bool status *string + duplicate_operation_id *string platform *string subscription_type *string daily_limit_usd *float64 @@ -21484,6 +21485,55 @@ func (m *GroupMutation) ResetStatus() { m.status = nil } +// SetDuplicateOperationID sets the "duplicate_operation_id" field. +func (m *GroupMutation) SetDuplicateOperationID(s string) { + m.duplicate_operation_id = &s +} + +// DuplicateOperationID returns the value of the "duplicate_operation_id" field in the mutation. +func (m *GroupMutation) DuplicateOperationID() (r string, exists bool) { + v := m.duplicate_operation_id + if v == nil { + return + } + return *v, true +} + +// OldDuplicateOperationID returns the old "duplicate_operation_id" field's value of the Group entity. +// If the Group 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 *GroupMutation) OldDuplicateOperationID(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDuplicateOperationID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDuplicateOperationID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDuplicateOperationID: %w", err) + } + return oldValue.DuplicateOperationID, nil +} + +// ClearDuplicateOperationID clears the value of the "duplicate_operation_id" field. +func (m *GroupMutation) ClearDuplicateOperationID() { + m.duplicate_operation_id = nil + m.clearedFields[group.FieldDuplicateOperationID] = struct{}{} +} + +// DuplicateOperationIDCleared returns if the "duplicate_operation_id" field was cleared in this mutation. +func (m *GroupMutation) DuplicateOperationIDCleared() bool { + _, ok := m.clearedFields[group.FieldDuplicateOperationID] + return ok +} + +// ResetDuplicateOperationID resets all changes to the "duplicate_operation_id" field. +func (m *GroupMutation) ResetDuplicateOperationID() { + m.duplicate_operation_id = nil + delete(m.clearedFields, group.FieldDuplicateOperationID) +} + // SetPlatform sets the "platform" field. func (m *GroupMutation) SetPlatform(s string) { m.platform = &s @@ -23714,7 +23764,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 48) + fields := make([]string, 0, 49) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -23751,6 +23801,9 @@ func (m *GroupMutation) Fields() []string { if m.status != nil { fields = append(fields, group.FieldStatus) } + if m.duplicate_operation_id != nil { + fields = append(fields, group.FieldDuplicateOperationID) + } if m.platform != nil { fields = append(fields, group.FieldPlatform) } @@ -23891,6 +23944,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.IsExclusive() case group.FieldStatus: return m.Status() + case group.FieldDuplicateOperationID: + return m.DuplicateOperationID() case group.FieldPlatform: return m.Platform() case group.FieldSubscriptionType: @@ -23996,6 +24051,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldIsExclusive(ctx) case group.FieldStatus: return m.OldStatus(ctx) + case group.FieldDuplicateOperationID: + return m.OldDuplicateOperationID(ctx) case group.FieldPlatform: return m.OldPlatform(ctx) case group.FieldSubscriptionType: @@ -24161,6 +24218,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetStatus(v) return nil + case group.FieldDuplicateOperationID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDuplicateOperationID(v) + return nil case group.FieldPlatform: v, ok := value.(string) if !ok { @@ -24704,6 +24768,9 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldDescription) { fields = append(fields, group.FieldDescription) } + if m.FieldCleared(group.FieldDuplicateOperationID) { + fields = append(fields, group.FieldDuplicateOperationID) + } if m.FieldCleared(group.FieldDailyLimitUsd) { fields = append(fields, group.FieldDailyLimitUsd) } @@ -24763,6 +24830,9 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldDescription: m.ClearDescription() return nil + case group.FieldDuplicateOperationID: + m.ClearDuplicateOperationID() + return nil case group.FieldDailyLimitUsd: m.ClearDailyLimitUsd() return nil @@ -24846,6 +24916,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldStatus: m.ResetStatus() return nil + case group.FieldDuplicateOperationID: + m.ResetDuplicateOperationID() + return nil case group.FieldPlatform: m.ResetPlatform() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index f13e14c519..42dbff5dae 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -995,102 +995,106 @@ func init() { group.DefaultStatus = groupDescStatus.Default.(string) // group.StatusValidator is a validator for the "status" field. It is called by the builders before save. group.StatusValidator = groupDescStatus.Validators[0].(func(string) error) + // groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field. + groupDescDuplicateOperationID := groupFields[9].Descriptor() + // group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save. + group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error) // groupDescPlatform is the schema descriptor for platform field. - groupDescPlatform := groupFields[9].Descriptor() + groupDescPlatform := groupFields[10].Descriptor() // group.DefaultPlatform holds the default value on creation for the platform field. group.DefaultPlatform = groupDescPlatform.Default.(string) // group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save. group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error) // groupDescSubscriptionType is the schema descriptor for subscription_type field. - groupDescSubscriptionType := groupFields[10].Descriptor() + groupDescSubscriptionType := groupFields[11].Descriptor() // group.DefaultSubscriptionType holds the default value on creation for the subscription_type field. group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string) // group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save. group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error) // groupDescDefaultValidityDays is the schema descriptor for default_validity_days field. - groupDescDefaultValidityDays := groupFields[14].Descriptor() + groupDescDefaultValidityDays := groupFields[15].Descriptor() // group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field. group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int) // groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field. - groupDescAllowImageGeneration := groupFields[15].Descriptor() + groupDescAllowImageGeneration := groupFields[16].Descriptor() // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field. group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool) // groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field. - groupDescAllowBatchImageGeneration := groupFields[16].Descriptor() + groupDescAllowBatchImageGeneration := groupFields[17].Descriptor() // group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field. group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool) // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field. - groupDescImageRateIndependent := groupFields[17].Descriptor() + groupDescImageRateIndependent := groupFields[18].Descriptor() // group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field. group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool) // groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field. - groupDescImageRateMultiplier := groupFields[18].Descriptor() + groupDescImageRateMultiplier := groupFields[19].Descriptor() // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field. group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64) // groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field. - groupDescBatchImageDiscountMultiplier := groupFields[22].Descriptor() + groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor() // group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field. group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64) // groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field. - groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor() + groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor() // group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field. group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64) // groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field. - groupDescVideoRateIndependent := groupFields[24].Descriptor() + groupDescVideoRateIndependent := groupFields[25].Descriptor() // group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field. group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool) // groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field. - groupDescVideoRateMultiplier := groupFields[25].Descriptor() + groupDescVideoRateMultiplier := groupFields[26].Descriptor() // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[30].Descriptor() + groupDescClaudeCodeOnly := groupFields[31].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[34].Descriptor() + groupDescModelRoutingEnabled := groupFields[35].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[35].Descriptor() + groupDescMcpXMLInject := groupFields[36].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[36].Descriptor() + groupDescSupportedModelScopes := groupFields[37].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[37].Descriptor() + groupDescSortOrder := groupFields[38].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[38].Descriptor() + groupDescAllowMessagesDispatch := groupFields[39].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[39].Descriptor() + groupDescRequireOauthOnly := groupFields[40].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[40].Descriptor() + groupDescRequirePrivacySet := groupFields[41].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[41].Descriptor() + groupDescDefaultMappedModel := groupFields[42].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[43].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[43].Descriptor() + groupDescModelsListConfig := groupFields[44].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[44].Descriptor() + groupDescRpmLimit := groupFields[45].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 70093a3cfa..3252112523 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -66,6 +66,12 @@ func (Group) Fields() []ent.Field { field.String("status"). MaxLen(20). Default(domain.StatusActive), + field.String("duplicate_operation_id"). + MaxLen(64). + Optional(). + Nillable(). + Immutable(). + Comment("内部幂等恢复标识,不对 API 暴露"), // Subscription-related fields (added by migration 003) field.String("platform"). @@ -244,5 +250,9 @@ func (Group) Indexes() []ent.Index { index.Fields("is_exclusive"), index.Fields("deleted_at"), index.Fields("sort_order"), + index.Fields("duplicate_operation_id"). + Unique(). + StorageKey("idx_groups_duplicate_operation_id_active"). + Annotations(entsql.IndexWhere("duplicate_operation_id IS NOT NULL AND deleted_at IS NULL")), } } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index d93ee7111c..cccd28a524 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -293,6 +293,15 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat return &group, nil } +func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"} + return &group, nil +} + +func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + return nil, nil +} + func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) { group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive} return &group, nil diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 02a4446846..0f44d55311 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -2,12 +2,15 @@ package admin import ( "bytes" + "context" "encoding/json" "fmt" + "log/slog" "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" @@ -361,6 +364,53 @@ func (h *GroupHandler) Create(c *gin.Context) { response.Success(c, dto.GroupFromServiceAdmin(group)) } +// Duplicate handles creating an inactive group copy with the source account bindings. +// POST /api/v1/admin/groups/:id/duplicate +func (h *GroupHandler) Duplicate(c *gin.Context) { + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || groupID <= 0 { + response.BadRequest(c, "Invalid group ID") + return + } + actorScope := adminActorScope(c) + + result, err := executeAdminIdempotent( + c, + "admin.groups.duplicate", + struct { + GroupID int64 `json:"group_id"` + }{GroupID: groupID}, + service.DefaultWriteIdempotencyTTL(), + func(ctx context.Context) (any, error) { + group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key")) + if execErr != nil { + return nil, execErr + } + return dto.GroupFromServiceAdmin(group), nil + }, + ) + if err != nil { + reason := infraerrors.Reason(err) + if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) { + recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key")) + if recoverErr != nil { + slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr) + } else if recovered != nil { + c.Header("X-Idempotency-Recovered", "true") + response.Success(c, dto.GroupFromServiceAdmin(recovered)) + return + } + } + response.ErrorFrom(c, err) + return + } + + if result != nil && result.Replayed { + c.Header("X-Idempotency-Replayed", "true") + } + response.Success(c, result.Data) +} + // Update handles updating a group // PUT /api/v1/admin/groups/:id func (h *GroupHandler) Update(c *gin.Context) { diff --git a/backend/internal/handler/admin/group_handler_duplicate_test.go b/backend/internal/handler/admin/group_handler_duplicate_test.go new file mode 100644 index 0000000000..a77b93582e --- /dev/null +++ b/backend/internal/handler/admin/group_handler_duplicate_test.go @@ -0,0 +1,163 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type duplicateGroupAdminServiceStub struct { + service.AdminService + group *service.Group + calls int + recoverCalls int + groupID int64 + actorScope string + operationKey string + recoverScope string + recoverKey string + created bool +} + +func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) { + s.calls++ + s.groupID = groupID + s.actorScope = actorScope + s.operationKey = operationKey + s.created = true + return s.group, nil +} + +func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) { + s.recoverCalls++ + s.recoverScope = actorScope + s.recoverKey = operationKey + if !s.created { + return nil, nil + } + return s.group, nil +} + +func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine { + t.Helper() + previousCoordinator := service.DefaultIdempotencyCoordinator() + service.SetDefaultIdempotencyCoordinator(nil) + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77}) + c.Next() + }) + handler := NewGroupHandler(svc, nil, nil) + router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate) + return router +} + +func duplicateGroupHandlerFixture() *service.Group { + return &service.Group{ + ID: 43, + Name: "primary (Copy)", + Platform: service.PlatformAnthropic, + Status: "inactive", + RateMultiplier: 1, + AccountCount: 3, + ActiveAccountCount: 2, + DuplicateOperationID: "internal-operation-must-not-leak", + ModelRouting: map[string][]int64{"claude-*": {7}}, + } +} + +func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()} + router := setupDuplicateGroupRouter(t, svc) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil) + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, svc.calls) + require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`) + require.Contains(t, recorder.Body.String(), `"status":"inactive"`) + require.Contains(t, recorder.Body.String(), `"account_count":3`) + require.NotContains(t, recorder.Body.String(), "duplicate_operation_id") + require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak") +} + +func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) { + for _, id := range []string{"not-a-number", "0", "-1"} { + t.Run(id, func(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{} + router := setupDuplicateGroupRouter(t, svc) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil) + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, svc.calls) + }) + } +} + +func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()} + router := setupDuplicateGroupRouter(t, svc) + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig())) + + call := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-group-42") + router.ServeHTTP(recorder, request) + return recorder + } + + first := call() + second := call() + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) + require.Equal(t, 1, svc.calls) + require.Equal(t, int64(42), svc.groupID) + require.Equal(t, "admin:77", svc.actorScope) + require.Equal(t, "duplicate-group-42", svc.operationKey) + require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed")) +} + +func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()} + router := setupDuplicateGroupRouter(t, svc) + repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true} + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig())) + + call := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery") + router.ServeHTTP(recorder, request) + return recorder + } + + first := call() + second := call() + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) + require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered")) + require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered")) + require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect") + require.Equal(t, 2, svc.recoverCalls) + require.Equal(t, "admin:77", svc.recoverScope) + require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey) +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 4c0edf72b4..ece9aae775 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -928,6 +928,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { IsExclusive: g.IsExclusive, Status: g.Status, Hydrated: true, + DuplicateOperationID: derefString(g.DuplicateOperationID), SubscriptionType: g.SubscriptionType, DailyLimitUSD: g.DailyLimitUsd, WeeklyLimitUSD: g.WeeklyLimitUsd, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 47efb72dd8..c749ec47b5 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -32,12 +32,31 @@ func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupReposi return newGroupRepositoryWithSQL(client, sqlDB) } +// NewAdminGroupRepository exposes the atomic group-duplication capability as +// an explicit dependency of the admin service. +func NewAdminGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.AdminGroupRepository { + return newGroupRepositoryWithSQL(client, sqlDB) +} + func newGroupRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *groupRepository { return &groupRepository{client: client, sql: sqlq} } func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) error { - builder := r.client.Group.Create(). + if err := createGroupRecord(ctx, r.client, groupIn); err != nil { + return err + } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil { + logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err) + } + return nil +} + +func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *service.Group) error { + if groupIn == nil { + return errors.New("group is nil") + } + builder := client.Group.Create(). SetName(groupIn.Name). SetDescription(groupIn.Description). SetPlatform(groupIn.Platform). @@ -81,6 +100,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetPeakStart(groupIn.PeakStart). SetPeakEnd(groupIn.PeakEnd). SetPeakRateMultiplier(groupIn.PeakRateMultiplier) + if groupIn.DuplicateOperationID != "" { + builder = builder.SetDuplicateOperationID(groupIn.DuplicateOperationID) + } // 设置模型路由配置 if groupIn.ModelRouting != nil { @@ -91,15 +113,87 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes) created, err := builder.Save(ctx) + if err != nil { + return translatePersistenceError(err, nil, service.ErrGroupExists) + } + groupIn.ID = created.ID + groupIn.CreatedAt = created.CreatedAt + groupIn.UpdatedAt = created.UpdatedAt + return nil +} + +func (r *groupRepository) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.Group, error) { + operationID = strings.TrimSpace(operationID) + if operationID == "" { + return nil, nil + } + row, err := r.client.Group.Query(). + Where(group.DuplicateOperationIDEQ(operationID)). + Order(dbent.Asc(group.FieldID)). + First(ctx) + if dbent.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find group duplicate operation: %w", err) + } + return groupEntityToService(row), nil +} + +// CreateFromSource atomically persists a copied group, clones the source +// account bindings with their exact priorities, and writes its scheduler event. +func (r *groupRepository) CreateFromSource(ctx context.Context, groupIn *service.Group, sourceGroupID int64) error { + if groupIn == nil { + return errors.New("group is nil") + } + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + + var txClient *dbent.Client if err == nil { - groupIn.ID = created.ID - groupIn.CreatedAt = created.CreatedAt - groupIn.UpdatedAt = created.UpdatedAt - if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil { - logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err) + defer func() { _ = tx.Rollback() }() + txClient = tx.Client() + } else { + // Reuse a caller-owned transaction when this repository is already transactional. + txClient = r.client + } + + if err := createGroupRecord(ctx, txClient, groupIn); err != nil { + return err + } + result, err := txClient.ExecContext( + ctx, + `INSERT INTO account_groups (account_id, group_id, priority, created_at) + SELECT ag.account_id, $2, ag.priority, NOW() + FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1 + AND a.deleted_at IS NULL + AND (NOT $3 OR a.type <> $4) + ON CONFLICT (account_id, group_id) DO NOTHING`, + sourceGroupID, + groupIn.ID, + groupIn.RequireOAuthOnly, + service.AccountTypeAPIKey, + ) + if err != nil { + return err + } + if count, countErr := result.RowsAffected(); countErr == nil { + groupIn.AccountCount = count + } + if err := enqueueSchedulerOutbox(ctx, txClient, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil { + return err + } + + if tx != nil { + if err := tx.Commit(); err != nil { + return err } } - return translatePersistenceError(err, nil, service.ErrGroupExists) + return nil } func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group, error) { diff --git a/backend/internal/repository/group_repo_duplicate_integration_test.go b/backend/internal/repository/group_repo_duplicate_integration_test.go new file mode 100644 index 0000000000..4748410d7a --- /dev/null +++ b/backend/internal/repository/group_repo_duplicate_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestCreateGroupFromSourceRollsBackWhenOutboxInsertFails(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := newGroupRepositoryWithSQL(client, integrationDB) + suffix := time.Now().UnixNano() + operationID := strings.Repeat("b", 64) + + source, err := client.Group.Create(). + SetName(fmt.Sprintf("duplicate-rollback-source-%d", suffix)). + SetPlatform(service.PlatformAnthropic). + Save(ctx) + require.NoError(t, err) + account, err := client.Account.Create(). + SetName(fmt.Sprintf("duplicate-rollback-account-%d", suffix)). + SetPlatform(service.PlatformAnthropic). + SetType(service.AccountTypeOAuth). + Save(ctx) + require.NoError(t, err) + _, err = client.AccountGroup.Create(). + SetAccountID(account.ID). + SetGroupID(source.ID). + SetPriority(29). + Save(ctx) + require.NoError(t, err) + + functionName := fmt.Sprintf("fail_group_duplicate_outbox_%d", suffix) + triggerName := fmt.Sprintf("fail_group_duplicate_outbox_trigger_%d", suffix) + _, err = integrationDB.ExecContext(ctx, fmt.Sprintf(` + CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.group_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM groups + WHERE id = NEW.group_id AND duplicate_operation_id = '%s' + ) THEN + RAISE EXCEPTION 'forced duplicate outbox failure'; + END IF; + RETURN NEW; + END; + $$`, functionName, operationID)) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, fmt.Sprintf( + "CREATE TRIGGER %s BEFORE INSERT ON scheduler_outbox FOR EACH ROW EXECUTE FUNCTION %s()", + triggerName, + functionName, + )) + require.NoError(t, err) + + duplicateName := fmt.Sprintf("duplicate-rollback-copy-%d", suffix) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON scheduler_outbox", triggerName)) + _, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP FUNCTION IF EXISTS %s()", functionName)) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM scheduler_outbox WHERE group_id = $1", source.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE account_id = $1", account.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", account.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE name IN ($1, $2)", source.Name, duplicateName) + }) + + duplicate := &service.Group{ + Name: duplicateName, + Platform: source.Platform, + RateMultiplier: 1, + Status: "inactive", + SubscriptionType: service.SubscriptionTypeStandard, + DuplicateOperationID: operationID, + } + err = repo.CreateFromSource(ctx, duplicate, source.ID) + require.ErrorContains(t, err, "forced duplicate outbox failure") + + var groupCount, bindingCount, outboxCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM groups WHERE name = $1", duplicateName).Scan(&groupCount)) + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", duplicate.ID).Scan(&bindingCount)) + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1", duplicate.ID).Scan(&outboxCount)) + require.Zero(t, groupCount) + require.Zero(t, bindingCount) + require.Zero(t, outboxCount) +} diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 68183b2b7b..8c14504776 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "strings" "testing" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -67,6 +68,91 @@ func (s *GroupRepoSuite) TestCreate() { s.Require().Equal("test-create", got.Name) } +func (s *GroupRepoSuite) TestCreateFromSourcePreservesPriorityAndFiltersIneligibleAccounts() { + source := &service.Group{ + Name: "duplicate-source", + Platform: service.PlatformOpenAI, + RateMultiplier: 1, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + RequireOAuthOnly: true, + } + s.Require().NoError(s.repo.Create(s.ctx, source)) + + insertAccount := func(name, accountType string, deleted bool) int64 { + var id int64 + deletedAt := any(nil) + if deleted { + deletedAt = "2026-07-16T00:00:00Z" + } + s.Require().NoError(scanSingleRow( + s.ctx, + s.tx, + "INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, $4) RETURNING id", + []any{name, service.PlatformOpenAI, accountType, deletedAt}, + &id, + )) + return id + } + oauthID := insertAccount("duplicate-oauth", service.AccountTypeOAuth, false) + apiKeyID := insertAccount("duplicate-apikey", service.AccountTypeAPIKey, false) + deletedID := insertAccount("duplicate-deleted", service.AccountTypeOAuth, true) + for _, binding := range []struct { + accountID int64 + priority int + }{{oauthID, 37}, {apiKeyID, 8}, {deletedID, 3}} { + _, err := s.tx.ExecContext( + s.ctx, + "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", + binding.accountID, + source.ID, + binding.priority, + ) + s.Require().NoError(err) + } + + duplicate := &service.Group{ + Name: "duplicate-source (Copy)", + Platform: source.Platform, + RateMultiplier: source.RateMultiplier, + Status: "inactive", + SubscriptionType: source.SubscriptionType, + RequireOAuthOnly: true, + DuplicateOperationID: strings.Repeat("a", 64), + } + s.Require().NoError(s.repo.CreateFromSource(s.ctx, duplicate, source.ID)) + s.Require().EqualValues(1, duplicate.AccountCount) + + rows, err := s.tx.QueryContext( + s.ctx, + "SELECT account_id, priority FROM account_groups WHERE group_id = $1 ORDER BY account_id", + duplicate.ID, + ) + s.Require().NoError(err) + defer func() { _ = rows.Close() }() + s.Require().True(rows.Next()) + var copiedAccountID int64 + var copiedPriority int + s.Require().NoError(rows.Scan(&copiedAccountID, &copiedPriority)) + s.Require().Equal(oauthID, copiedAccountID) + s.Require().Equal(37, copiedPriority) + s.Require().False(rows.Next(), "API-key and soft-deleted accounts must not be copied") + + recovered, err := s.repo.FindByDuplicateOperationID(s.ctx, duplicate.DuplicateOperationID) + s.Require().NoError(err) + s.Require().Equal(duplicate.ID, recovered.ID) + + var outboxCount int + s.Require().NoError(scanSingleRow( + s.ctx, + s.tx, + "SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1", + []any{duplicate.ID}, + &outboxCount, + )) + s.Require().Equal(1, outboxCount) +} + func (s *GroupRepoSuite) TestGetByID_NotFound() { _, err := s.repo.GetByID(s.ctx, 999999) s.Require().Error(err, "expected error for non-existent ID") diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 1bcb8e6a24..581831912a 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet( NewUserRepository, NewAPIKeyRepository, NewGroupRepository, + NewAdminGroupRepository, NewAccountRepository, NewAdminAccountRepository, NewScheduledTestPlanRepository, // 定时测试计划仓储 diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 3fb1cbff8c..9c3c499c7f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1731,6 +1731,14 @@ func (stubGroupRepo) UpdateSortOrders(ctx context.Context, updates []service.Gro return nil } +func (stubGroupRepo) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.Group, error) { + return nil, nil +} + +func (stubGroupRepo) CreateFromSource(ctx context.Context, group *service.Group, sourceGroupID int64) error { + return errors.New("not implemented") +} + type stubAccountRepo struct { bulkUpdateIDs []int64 } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 844f75eae0..5ce192a9e5 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -296,6 +296,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates) groups.GET("/:id", h.Admin.Group.GetByID) groups.POST("", h.Admin.Group.Create) + groups.POST("/:id/duplicate", h.Admin.Group.Duplicate) groups.PUT("/:id", h.Admin.Group.Update) groups.DELETE("/:id", h.Admin.Group.Delete) groups.GET("/:id/stats", h.Admin.Group.GetStats) diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go new file mode 100644 index 0000000000..ef025e3c86 --- /dev/null +++ b/backend/internal/service/admin_group_duplicate.go @@ -0,0 +1,205 @@ +package service + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + maxGroupNameRunes = 100 + duplicateGroupInactiveStatus = "inactive" +) + +func duplicateGroupOperationID(sourceID int64, actorScope, operationKey string) string { + operationKey = strings.TrimSpace(operationKey) + if operationKey == "" { + return "" + } + actorScope = strings.TrimSpace(actorScope) + if actorScope == "" { + actorScope = "admin:0" + } + payload := "admin.groups.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey + digest := sha256.Sum256([]byte(payload)) + return fmt.Sprintf("%x", digest) +} + +func duplicateGroupName(sourceName string, copyNumber int) string { + if copyNumber < 1 { + copyNumber = 1 + } + suffix := " (Copy)" + if copyNumber > 1 { + suffix = fmt.Sprintf(" (Copy %d)", copyNumber) + } + baseRunes := []rune(strings.TrimSpace(sourceName)) + maxBaseRunes := maxGroupNameRunes - len([]rune(suffix)) + if maxBaseRunes < 0 { + maxBaseRunes = 0 + } + if len(baseRunes) > maxBaseRunes { + baseRunes = baseRunes[:maxBaseRunes] + } + return string(baseRunes) + suffix +} + +func cloneGroupValuePointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneGroupModelRouting(value map[string][]int64) map[string][]int64 { + if value == nil { + return nil + } + cloned := make(map[string][]int64, len(value)) + for model, accountIDs := range value { + cloned[model] = append([]int64(nil), accountIDs...) + } + return cloned +} + +func cloneGroupMessagesDispatchModelConfig(value OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig { + cloned := value + if value.ExactModelMappings != nil { + cloned.ExactModelMappings = make(map[string]string, len(value.ExactModelMappings)) + for requestedModel, mappedModel := range value.ExactModelMappings { + cloned.ExactModelMappings[requestedModel] = mappedModel + } + } + return cloned +} + +func cloneGroupForDuplicate(source *Group, operationID string) *Group { + return &Group{ + Name: duplicateGroupName(source.Name, 1), + Description: source.Description, + Platform: source.Platform, + RateMultiplier: source.RateMultiplier, + PeakRateEnabled: source.PeakRateEnabled, + PeakStart: source.PeakStart, + PeakEnd: source.PeakEnd, + PeakRateMultiplier: source.PeakRateMultiplier, + IsExclusive: source.IsExclusive, + Status: duplicateGroupInactiveStatus, + DuplicateOperationID: operationID, + SubscriptionType: source.SubscriptionType, + DailyLimitUSD: cloneGroupValuePointer(source.DailyLimitUSD), + WeeklyLimitUSD: cloneGroupValuePointer(source.WeeklyLimitUSD), + MonthlyLimitUSD: cloneGroupValuePointer(source.MonthlyLimitUSD), + DefaultValidityDays: source.DefaultValidityDays, + AllowImageGeneration: source.AllowImageGeneration, + AllowBatchImageGeneration: source.AllowBatchImageGeneration, + ImageRateIndependent: source.ImageRateIndependent, + ImageRateMultiplier: source.ImageRateMultiplier, + ImagePrice1K: cloneGroupValuePointer(source.ImagePrice1K), + ImagePrice2K: cloneGroupValuePointer(source.ImagePrice2K), + ImagePrice4K: cloneGroupValuePointer(source.ImagePrice4K), + BatchImageDiscountMultiplier: source.BatchImageDiscountMultiplier, + BatchImageHoldMultiplier: source.BatchImageHoldMultiplier, + VideoRateIndependent: source.VideoRateIndependent, + VideoRateMultiplier: source.VideoRateMultiplier, + VideoPrice480P: cloneGroupValuePointer(source.VideoPrice480P), + VideoPrice720P: cloneGroupValuePointer(source.VideoPrice720P), + VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P), + WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall), + ClaudeCodeOnly: source.ClaudeCodeOnly, + FallbackGroupID: cloneGroupValuePointer(source.FallbackGroupID), + FallbackGroupIDOnInvalidRequest: cloneGroupValuePointer(source.FallbackGroupIDOnInvalidRequest), + ModelRouting: cloneGroupModelRouting(source.ModelRouting), + ModelRoutingEnabled: source.ModelRoutingEnabled, + MCPXMLInject: source.MCPXMLInject, + SupportedModelScopes: append([]string(nil), source.SupportedModelScopes...), + SortOrder: source.SortOrder, + AllowMessagesDispatch: source.AllowMessagesDispatch, + RequireOAuthOnly: source.RequireOAuthOnly, + RequirePrivacySet: source.RequirePrivacySet, + DefaultMappedModel: source.DefaultMappedModel, + MessagesDispatchModelConfig: cloneGroupMessagesDispatchModelConfig(source.MessagesDispatchModelConfig), + ModelsListConfig: GroupModelsListConfig{ + Enabled: source.ModelsListConfig.Enabled, + Models: append([]string(nil), source.ModelsListConfig.Models...), + }, + RPMLimit: source.RPMLimit, + } +} + +// RecoverDuplicateGroup performs a read-only lookup for a copy that was already +// committed for the same actor, source group, and idempotency key. +func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + operationID := duplicateGroupOperationID(id, actorScope, operationKey) + if operationID == "" { + return nil, nil + } + if s.groupDuplicateRepo == nil { + return nil, errors.New("group duplicate repository is not configured") + } + group, err := s.groupDuplicateRepo.FindByDuplicateOperationID(ctx, operationID) + if err != nil { + return nil, fmt.Errorf("find duplicate group operation: %w", err) + } + if group == nil { + return nil, nil + } + hydrated, err := s.groupRepo.GetByID(ctx, group.ID) + if err != nil { + return nil, fmt.Errorf("load recovered duplicate group: %w", err) + } + return hydrated, nil +} + +// DuplicateGroup creates an inactive copy of a group's configuration and exact +// account priorities. The repository commits the group, bindings, and outbox +// event atomically so a failed binding never leaves an orphan group. +func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + + source, err := s.groupRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if s.groupDuplicateRepo == nil { + return nil, errors.New("group duplicate repository is not configured") + } + + duplicate := cloneGroupForDuplicate(source, duplicateGroupOperationID(id, actorScope, operationKey)) + for copyNumber := 1; ; copyNumber++ { + duplicate.Name = duplicateGroupName(source.Name, copyNumber) + duplicate.ID = 0 + duplicate.CreatedAt = time.Time{} + duplicate.UpdatedAt = time.Time{} + if err := s.groupDuplicateRepo.CreateFromSource(ctx, duplicate, source.ID); err == nil { + hydrated, loadErr := s.groupRepo.GetByID(ctx, duplicate.ID) + if loadErr != nil { + return nil, fmt.Errorf("load duplicate group: %w", loadErr) + } + return hydrated, nil + } else if !errors.Is(err, ErrGroupExists) { + return nil, fmt.Errorf("create duplicate group: %w", err) + } + + // A unique conflict can be either the generated name or the operation ID. + // Recover first; if no operation row exists, advance to the next name. + recovered, recoverErr := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey) + if recoverErr != nil { + return nil, recoverErr + } + if recovered != nil { + return recovered, nil + } + } +} diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go new file mode 100644 index 0000000000..1d47cd699f --- /dev/null +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -0,0 +1,279 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/stretchr/testify/require" +) + +type duplicateGroupRepoStub struct { + GroupRepository + nextID int64 + groups map[int64]*Group + names map[string]struct{} + byOperation map[string]int64 + sourceBindings map[int64][]AccountGroup + createdBindings map[int64][]AccountGroup + createdFromSources []int64 + atomicCreateErr error +} + +func newDuplicateGroupRepoStub(source *Group) *duplicateGroupRepoStub { + repo := &duplicateGroupRepoStub{ + nextID: 100, + groups: make(map[int64]*Group), + names: make(map[string]struct{}), + byOperation: make(map[string]int64), + sourceBindings: make(map[int64][]AccountGroup), + createdBindings: make(map[int64][]AccountGroup), + } + if source != nil { + repo.groups[source.ID] = source + repo.names[source.Name] = struct{}{} + } + return repo +} + +func cloneGroupForDuplicateTest(group *Group) *Group { + if group == nil { + return nil + } + cloned := *group + cloned.DailyLimitUSD = cloneGroupValuePointer(group.DailyLimitUSD) + cloned.WeeklyLimitUSD = cloneGroupValuePointer(group.WeeklyLimitUSD) + cloned.MonthlyLimitUSD = cloneGroupValuePointer(group.MonthlyLimitUSD) + cloned.ImagePrice1K = cloneGroupValuePointer(group.ImagePrice1K) + cloned.ImagePrice2K = cloneGroupValuePointer(group.ImagePrice2K) + cloned.ImagePrice4K = cloneGroupValuePointer(group.ImagePrice4K) + cloned.VideoPrice480P = cloneGroupValuePointer(group.VideoPrice480P) + cloned.VideoPrice720P = cloneGroupValuePointer(group.VideoPrice720P) + cloned.VideoPrice1080P = cloneGroupValuePointer(group.VideoPrice1080P) + cloned.WebSearchPricePerCall = cloneGroupValuePointer(group.WebSearchPricePerCall) + cloned.FallbackGroupID = cloneGroupValuePointer(group.FallbackGroupID) + cloned.FallbackGroupIDOnInvalidRequest = cloneGroupValuePointer(group.FallbackGroupIDOnInvalidRequest) + cloned.ModelRouting = cloneGroupModelRouting(group.ModelRouting) + cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...) + cloned.MessagesDispatchModelConfig = cloneGroupMessagesDispatchModelConfig(group.MessagesDispatchModelConfig) + cloned.ModelsListConfig.Models = append([]string(nil), group.ModelsListConfig.Models...) + cloned.AccountGroups = append([]AccountGroup(nil), group.AccountGroups...) + return &cloned +} + +func (r *duplicateGroupRepoStub) GetByID(_ context.Context, id int64) (*Group, error) { + group := r.groups[id] + if group == nil { + return nil, ErrGroupNotFound + } + cloned := cloneGroupForDuplicateTest(group) + cloned.Hydrated = true + return cloned, nil +} + +func (r *duplicateGroupRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*Group, error) { + id := r.byOperation[operationID] + if id == 0 { + return nil, nil + } + return cloneGroupForDuplicateTest(r.groups[id]), nil +} + +func (r *duplicateGroupRepoStub) CreateFromSource(_ context.Context, group *Group, sourceGroupID int64) error { + if r.atomicCreateErr != nil { + return r.atomicCreateErr + } + if group.DuplicateOperationID != "" { + if _, exists := r.byOperation[group.DuplicateOperationID]; exists { + return ErrGroupExists + } + } + if _, exists := r.names[group.Name]; exists { + return ErrGroupExists + } + r.nextID++ + group.ID = r.nextID + group.CreatedAt = time.Now().UTC() + group.UpdatedAt = group.CreatedAt + bindings := append([]AccountGroup(nil), r.sourceBindings[sourceGroupID]...) + for i := range bindings { + bindings[i].GroupID = group.ID + } + group.AccountCount = int64(len(bindings)) + group.ActiveAccountCount = int64(len(bindings)) + r.createdBindings[group.ID] = bindings + r.createdFromSources = append(r.createdFromSources, sourceGroupID) + r.names[group.Name] = struct{}{} + r.groups[group.ID] = cloneGroupForDuplicateTest(group) + if group.DuplicateOperationID != "" { + r.byOperation[group.DuplicateOperationID] = group.ID + } + return nil +} + +func groupDuplicateTestPointer[T any](value T) *T { return &value } + +func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) { + createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC) + source := &Group{ + ID: 41, + Name: "高级订阅", + Description: "configuration", + Platform: PlatformOpenAI, + RateMultiplier: 1.75, + PeakRateEnabled: true, + PeakStart: "09:00", + PeakEnd: "18:00", + PeakRateMultiplier: 1.2, + IsExclusive: true, + Status: StatusActive, + Hydrated: true, + SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: groupDuplicateTestPointer(11.0), + WeeklyLimitUSD: groupDuplicateTestPointer(22.0), + MonthlyLimitUSD: groupDuplicateTestPointer(33.0), + DefaultValidityDays: 91, + AllowImageGeneration: true, + AllowBatchImageGeneration: true, + ImageRateIndependent: true, + ImageRateMultiplier: 1.4, + ImagePrice1K: groupDuplicateTestPointer(0.01), + ImagePrice2K: groupDuplicateTestPointer(0.02), + ImagePrice4K: groupDuplicateTestPointer(0.04), + BatchImageDiscountMultiplier: 0.4, + BatchImageHoldMultiplier: 0.7, + VideoRateIndependent: true, + VideoRateMultiplier: 2.1, + VideoPrice480P: groupDuplicateTestPointer(0.1), + VideoPrice720P: groupDuplicateTestPointer(0.2), + VideoPrice1080P: groupDuplicateTestPointer(0.3), + WebSearchPricePerCall: groupDuplicateTestPointer(0.005), + ClaudeCodeOnly: true, + FallbackGroupID: groupDuplicateTestPointer(int64(7)), + FallbackGroupIDOnInvalidRequest: groupDuplicateTestPointer(int64(8)), + ModelRouting: map[string][]int64{"gpt-*": {13, 17}}, + ModelRoutingEnabled: true, + MCPXMLInject: true, + SupportedModelScopes: []string{"claude", "gemini_text"}, + SortOrder: 9, + AllowMessagesDispatch: true, + RequireOAuthOnly: true, + RequirePrivacySet: true, + DefaultMappedModel: "gpt-5.4", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: "gpt-5.4", + SonnetMappedModel: "gpt-5.3", + HaikuMappedModel: "gpt-5-mini", + ExactModelMappings: map[string]string{"claude-special": "gpt-special"}, + }, + ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, + RPMLimit: 99, + CreatedAt: createdAt, + UpdatedAt: createdAt, + AccountCount: 12, + ActiveAccountCount: 8, + RateLimitedAccountCount: 2, + DuplicateOperationID: "old-operation-must-not-copy", + AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}}, + } + repo := newDuplicateGroupRepoStub(source) + repo.sourceBindings[source.ID] = []AccountGroup{ + {AccountID: 13, GroupID: source.ID, Priority: 37}, + {AccountID: 17, GroupID: source.ID, Priority: 8}, + } + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + + duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:7", "stable-key") + + require.NoError(t, err) + require.NotEqual(t, source.ID, duplicate.ID) + require.Equal(t, "高级订阅 (Copy)", duplicate.Name) + require.Equal(t, duplicateGroupInactiveStatus, duplicate.Status) + require.True(t, duplicate.Hydrated, "the duplicate response is reloaded with derived counts") + require.Equal(t, source.Description, duplicate.Description) + require.Equal(t, source.Platform, duplicate.Platform) + require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier) + require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier) + require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays) + require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K) + require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall) + require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID) + require.Equal(t, source.ModelRouting, duplicate.ModelRouting) + require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) + require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) + require.Equal(t, source.RPMLimit, duplicate.RPMLimit) + require.EqualValues(t, 2, duplicate.AccountCount) + require.EqualValues(t, 2, duplicate.ActiveAccountCount) + require.NotEmpty(t, duplicate.DuplicateOperationID) + require.Equal(t, []int64{source.ID}, repo.createdFromSources) + require.Equal(t, []AccountGroup{ + {AccountID: 13, GroupID: duplicate.ID, Priority: 37}, + {AccountID: 17, GroupID: duplicate.ID, Priority: 8}, + }, repo.createdBindings[duplicate.ID]) + + duplicate.ModelRouting["gpt-*"][0] = 999 + duplicate.SupportedModelScopes[0] = "changed" + duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed" + duplicate.ModelsListConfig.Models[0] = "changed" + *duplicate.DailyLimitUSD = 999 + require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0]) + require.Equal(t, "claude", source.SupportedModelScopes[0]) + require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"]) + require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0]) + require.Equal(t, 11.0, *source.DailyLimitUSD) +} + +func TestDuplicateGroupRecoversSameOperationAndScopesByAdmin(t *testing.T) { + source := &Group{ID: 9, Name: "team", Platform: PlatformAnthropic, Status: StatusActive} + repo := newDuplicateGroupRepoStub(source) + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + ctx := context.Background() + + first, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key") + require.NoError(t, err) + retry, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key") + require.NoError(t, err) + recovered, err := svc.RecoverDuplicateGroup(ctx, source.ID, "admin:7", "same-key") + require.NoError(t, err) + otherAdmin, err := svc.DuplicateGroup(ctx, source.ID, "admin:8", "same-key") + require.NoError(t, err) + + require.Equal(t, first.ID, retry.ID) + require.Equal(t, first.ID, recovered.ID) + require.NotEqual(t, first.ID, otherAdmin.ID) + require.Equal(t, "team (Copy 2)", otherAdmin.Name) +} + +func TestDuplicateGroupAdvancesNameAndTruncatesUnicodeByRunes(t *testing.T) { + source := &Group{ID: 12, Name: "team", Platform: PlatformAnthropic, Status: StatusActive} + repo := newDuplicateGroupRepoStub(source) + repo.names["team (Copy)"] = struct{}{} + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + + duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "") + require.NoError(t, err) + require.Equal(t, "team (Copy 2)", duplicate.Name) + + unicodeName := duplicateGroupName(strings.Repeat("组", 100), 23) + require.Equal(t, maxGroupNameRunes, utf8.RuneCountInString(unicodeName)) + require.True(t, strings.HasSuffix(unicodeName, " (Copy 23)")) +} + +func TestDuplicateGroupAtomicCreateFailureReturnsNoCopy(t *testing.T) { + source := &Group{ID: 15, Name: "team", Platform: PlatformAnthropic, Status: StatusActive} + repo := newDuplicateGroupRepoStub(source) + repo.atomicCreateErr = errors.New("binding insert failed") + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + + duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "key") + + require.ErrorContains(t, err, "binding insert failed") + require.Nil(t, duplicate) + require.Len(t, repo.groups, 1) + require.Empty(t, repo.byOperation) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index a09bd14fb7..0a8278a176 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -39,6 +39,12 @@ type AdminService interface { GetGroup(ctx context.Context, id int64) (*Group, error) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) + // DuplicateGroup creates an inactive independent copy of a group's configuration + // and account bindings while preserving each binding's priority. + DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) + // RecoverDuplicateGroup returns a previously committed copy for an ambiguous retry. + // It never creates a group. + RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) DeleteGroup(ctx context.Context, id int64) error GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) @@ -582,6 +588,7 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST type adminServiceImpl struct { userRepo UserRepository groupRepo GroupRepository + groupDuplicateRepo GroupDuplicateRepository accountRepo AccountRepository accountDuplicateRepo AccountDuplicateRepository proxyRepo ProxyRepository @@ -613,7 +620,7 @@ type userGroupRateBatchReader interface { // NewAdminService creates a new AdminService func NewAdminService( userRepo UserRepository, - groupRepo GroupRepository, + groupRepo AdminGroupRepository, accountRepo AdminAccountRepository, proxyRepo ProxyRepository, apiKeyRepo APIKeyRepository, @@ -635,6 +642,7 @@ func NewAdminService( return &adminServiceImpl{ userRepo: userRepo, groupRepo: groupRepo, + groupDuplicateRepo: groupRepo, accountRepo: accountRepo, accountDuplicateRepo: accountRepo, proxyRepo: proxyRepo, diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 346eef7b95..58265b6687 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -28,6 +28,9 @@ type Group struct { IsExclusive bool Status string Hydrated bool // indicates the group was loaded from a trusted repository source + // DuplicateOperationID is internal persistence metadata used only to recover + // an already committed one-click copy. It must never be mapped to API DTOs. + DuplicateOperationID string SubscriptionType string DailyLimitUSD *float64 diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 93078aa632..a78b5975be 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -37,6 +37,22 @@ type GroupRepository interface { UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error } +type GroupDuplicateRepository interface { + // FindByDuplicateOperationID performs the read-only recovery lookup used + // after an ambiguous idempotency-store failure. + FindByDuplicateOperationID(ctx context.Context, operationID string) (*Group, error) + // CreateFromSource atomically persists the group, copies the source group's + // exact account priorities, and writes the scheduler outbox event. + CreateFromSource(ctx context.Context, group *Group, sourceGroupID int64) error +} + +// AdminGroupRepository makes the group-duplication write capability an explicit +// admin-service dependency without widening gateway-only group test doubles. +type AdminGroupRepository interface { + GroupRepository + GroupDuplicateRepository +} + // GroupSortOrderUpdate 分组排序更新 type GroupSortOrderUpdate struct { ID int64 `json:"id"` diff --git a/backend/migrations/181_group_duplicate_operation_id.sql b/backend/migrations/181_group_duplicate_operation_id.sql new file mode 100644 index 0000000000..b20ef2fb38 --- /dev/null +++ b/backend/migrations/181_group_duplicate_operation_id.sql @@ -0,0 +1,8 @@ +-- Persist an internal operation identity for safely recovering an already +-- committed group duplicate when the idempotency response write is ambiguous. +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS duplicate_operation_id VARCHAR(64); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_duplicate_operation_id_active + ON groups (duplicate_operation_id) + WHERE duplicate_operation_id IS NOT NULL AND deleted_at IS NULL; diff --git a/frontend/src/api/__tests__/admin.groups.duplicate.spec.ts b/frontend/src/api/__tests__/admin.groups.duplicate.spec.ts new file mode 100644 index 0000000000..cfe6ab65fe --- /dev/null +++ b/frontend/src/api/__tests__/admin.groups.duplicate.spec.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { post } = vi.hoisted(() => ({ + post: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { post } +})) + +import { duplicate } from '@/api/admin/groups' + +describe('admin group duplicate API', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + post.mockReset() + post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } }) + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends a stable idempotency key with the duplicate request', async () => { + const group = await duplicate(42) + + expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, { + headers: { + 'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111' + } + }) + expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' }) + expect(sessionStorage.length).toBe(0) + }) + + it('reuses the operation key after an ambiguous failed request', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(99)).rejects.toThrow('network timeout') + + post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } }) + await duplicate(99) + + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers) + expect(sessionStorage.length).toBe(0) + }) + + it('reuses the operation key after a page reload', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(77)).rejects.toThrow('network timeout') + const firstHeaders = post.mock.calls[0][2].headers + + vi.resetModules() + post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } }) + const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups') + await duplicateAfterReload(77) + + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[1][2].headers).toEqual(firstHeaders) + expect(sessionStorage.length).toBe(0) + }) + + it('does not reuse an operation key across administrators for the same group', async () => { + post.mockRejectedValueOnce(new Error('first admin timeout')) + await expect(duplicate(55)).rejects.toThrow('first admin timeout') + const firstAdminHeaders = post.mock.calls[0][2].headers + + localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) + vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce( + '22222222-2222-4222-8222-222222222222' + ) + post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } }) + await duplicate(55) + + expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders) + expect(post.mock.calls[1][2].headers).toEqual({ + 'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222' + }) + expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe( + firstAdminHeaders['Idempotency-Key'] + ) + expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull() + }) + + it('does not persist or reuse keys when the current user cannot be parsed', async () => { + localStorage.setItem('auth_user', '{invalid json') + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(66)).rejects.toThrow('network timeout') + const firstHeaders = post.mock.calls[0][2].headers + + vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce( + '33333333-3333-4333-8333-333333333333' + ) + post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } }) + await duplicate(66) + + expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders) + expect(sessionStorage.length).toBe(0) + }) +}) diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 31a1b57650..3d2d121fb0 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise return data } +/** + * Duplicate a group on the server so configuration that is not present in the + * list response is preserved. Keep the operation key after ambiguous failures + * so a retry replays the original operation instead of creating another group. + */ +const duplicateOperationKeys = new Map() + +interface DuplicateOperationScope { + adminID: string + key: string +} + +function getCurrentAdminID(): string | null { + try { + const rawUser = globalThis.localStorage?.getItem('auth_user') + if (!rawUser) return null + + const user: unknown = JSON.parse(rawUser) + if (typeof user !== 'object' || user === null) return null + + const id = (user as { id?: unknown }).id + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null + return String(id) + } catch { + return null + } +} + +function duplicateOperationScope(id: number): DuplicateOperationScope | null { + const adminID = getCurrentAdminID() + if (!adminID) return null + + return { + adminID, + key: `sub2api:admin:group-duplicate:${adminID}:${id}` + } +} + +function getStoredDuplicateOperationKey(storageKey: string): string | null { + try { + return globalThis.sessionStorage?.getItem(storageKey) ?? null + } catch { + return null + } +} + +function storeDuplicateOperationKey(storageKey: string, key: string | null): void { + try { + if (key) globalThis.sessionStorage?.setItem(storageKey, key) + else globalThis.sessionStorage?.removeItem(storageKey) + } catch { + // In-memory retry protection still works when browser storage is unavailable. + } +} + +export async function duplicate(id: number): Promise { + const scope = duplicateOperationScope(id) + let idempotencyKey = scope + ? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key) + : null + if (!idempotencyKey) { + const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}` + } + if (scope) { + duplicateOperationKeys.set(scope.key, idempotencyKey) + storeDuplicateOperationKey(scope.key, idempotencyKey) + } + + const { data } = await apiClient.post(`/admin/groups/${id}/duplicate`, undefined, { + headers: { 'Idempotency-Key': idempotencyKey } + }) + + if (scope) { + duplicateOperationKeys.delete(scope.key) + storeDuplicateOperationKey(scope.key, null) + } + return data +} + /** * Update group * @param id - Group ID @@ -337,6 +417,7 @@ export const groupsAPI = { getById, getModelsListCandidates, create, + duplicate, update, delete: deleteGroup, toggleStatus, diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 51449dc883..a093cc3802 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -743,6 +743,10 @@ export default { createGroup: 'Create Group', editGroup: 'Edit Group', deleteGroup: 'Delete Group', + duplicate: 'Duplicate', + duplicating: 'Duplicating', + duplicateSuccess: 'Group duplicated as "{name}" and disabled. Review its configuration before enabling it.', + duplicateFailed: 'Failed to duplicate group', sortOrder: 'Sort', columnSettings: 'Column Settings', sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index e1e41bbeb7..8056e22d8f 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -740,6 +740,10 @@ export default { createGroup: '创建分组', editGroup: '编辑分组', deleteGroup: '删除分组', + duplicate: '复制', + duplicating: '复制中', + duplicateSuccess: '分组已复制为「{name}」,已默认停用,请确认配置后再启用', + duplicateFailed: '复制分组失败', sortOrder: '排序', columnSettings: '列设置', sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示', diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 81310c43af..635488479b 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -366,6 +366,26 @@ {{ t("common.edit") }} +