Make repeated group setup safer

Admins often recreate groups with the same pricing, routing, and account membership. A server-side duplicate creates an inactive copy for review, preserves eligible account priorities, and recovers ambiguous retries without creating extra groups.

Constraint: Group has no neutral JSON metadata field for durable operation recovery
Constraint: Model routing references account IDs, so copied configuration requires matching bindings
Rejected: Rebuild from the list response | it omits configuration and account priority details
Rejected: Store operation identity in business configuration | it would pollute real group settings
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep duplicated groups inactive until an administrator reviews the copied configuration
Tested: Go unit and full tests, go vet, integration-tag compile, frontend Vitest, lint, typecheck, production build, and Playwright duplicate flow
Not-tested: PostgreSQL container integration locally because Docker is unavailable; CI will execute the database-backed suite
This commit is contained in:
yan9651688 committed 2026-07-16 18:18:28 +08:00
1 parent b960ec1980
commit 9fc006546c
32 files changed
+1797 -37

No files matched your search

+2 -1
View File
@@ -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)
+15 -1
View File
@@ -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(", ")
+10
View File
@@ -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()
+80
View File
@@ -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))
+29
View File
@@ -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
+6
View File
@@ -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)
}
+12 -3
View File
@@ -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",
},
},
},
}
+74 -1
View File
@@ -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
+27 -23
View File
@@ -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()
+10
View File
@@ -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")),
}
}
@@ -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
@@ -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) {
@@ -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)
}
@@ -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,
+101 -7
View File
@@ -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) {
@@ -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)
}
@@ -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")
+1
View File
@@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet(
NewUserRepository,
NewAPIKeyRepository,
NewGroupRepository,
NewAdminGroupRepository,
NewAccountRepository,
NewAdminAccountRepository,
NewScheduledTestPlanRepository, // 定时测试计划仓储
@@ -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
}
+1
View File
@@ -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)
@@ -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
}
}
}
@@ -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)
}
+9 -1
View File
@@ -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,
+3
View File
@@ -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
+16
View File
@@ -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"`
@@ -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;
@@ -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)
})
})
+81
View File
@@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise<AdminGroup>
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<string, string>()
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<AdminGroup> {
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<AdminGroup>(`/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,
@@ -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',
@@ -740,6 +740,10 @@ export default {
createGroup: '创建分组',
editGroup: '编辑分组',
deleteGroup: '删除分组',
duplicate: '复制',
duplicating: '复制中',
duplicateSuccess: '分组已复制为「{name}」,已默认停用,请确认配置后再启用',
duplicateFailed: '复制分组失败',
sortOrder: '排序',
columnSettings: '列设置',
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
+41
View File
@@ -366,6 +366,26 @@
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t("common.edit") }}</span>
</button>
<button
data-testid="group-duplicate"
:title="
duplicatingGroupIds.has(row.id)
? t('admin.groups.duplicating')
: t('admin.groups.duplicate')
"
:disabled="duplicatingGroupIds.has(row.id)"
@click="handleDuplicate(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="copy" size="sm" />
<span class="text-xs">
{{
duplicatingGroupIds.has(row.id)
? t("admin.groups.duplicating")
: t("admin.groups.duplicate")
}}
</span>
</button>
<button
@click="handleRateMultipliers(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
@@ -3583,6 +3603,7 @@ import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesMo
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { extractApiErrorMessage } from "@/utils/apiError";
import { useKeyedDebouncedSearch } from "@/composables/useKeyedDebouncedSearch";
import { getPersistedPageSize } from "@/composables/usePersistedPageSize";
import {
@@ -3967,6 +3988,7 @@ const submitting = ref(false);
const sortSubmitting = ref(false);
const editingGroup = ref<AdminGroup | null>(null);
const deletingGroup = ref<AdminGroup | null>(null);
const duplicatingGroupIds = reactive(new Set<number>());
const showRateMultipliersModal = ref(false);
const rateMultipliersGroup = ref<AdminGroup | null>(null);
const showRPMOverridesModal = ref(false);
@@ -5126,6 +5148,25 @@ const handleRPMOverrides = (group: AdminGroup) => {
showRPMOverridesModal.value = true;
};
const handleDuplicate = async (group: AdminGroup) => {
if (duplicatingGroupIds.has(group.id)) return;
duplicatingGroupIds.add(group.id);
try {
const duplicate = await adminAPI.groups.duplicate(group.id);
appStore.showSuccess(
t("admin.groups.duplicateSuccess", { name: duplicate.name }),
);
await loadGroups();
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.groups.duplicateFailed")),
);
} finally {
duplicatingGroupIds.delete(group.id);
}
};
const handleDelete = (group: AdminGroup) => {
deletingGroup.value = group;
showDeleteDialog.value = true;
@@ -0,0 +1,268 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AdminGroup } from '@/types'
import GroupsView from '@/views/admin/GroupsView.vue'
const {
listGroups,
duplicateGroup,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
showSuccess,
showError
} = vi.hoisted(() => ({
listGroups: vi.fn(),
duplicateGroup: vi.fn(),
getModelsListCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
groups: {
list: listGroups,
duplicate: duplicateGroup,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
updateSortOrder: vi.fn()
},
accounts: {
list: vi.fn(),
getById: vi.fn()
}
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess, showError })
}))
vi.mock('@/stores/onboarding', () => ({
useOnboardingStore: () => ({
isCurrentStep: vi.fn(() => false),
nextStep: vi.fn()
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
const sourceGroup: AdminGroup = {
id: 42,
name: 'Primary',
description: null,
platform: 'openai',
rate_multiplier: 1,
rpm_limit: 0,
is_exclusive: false,
status: 'active',
subscription_type: 'standard',
daily_limit_usd: null,
weekly_limit_usd: null,
monthly_limit_usd: null,
allow_image_generation: false,
allow_batch_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
batch_image_discount_multiplier: 0.5,
batch_image_hold_multiplier: 0.6,
image_price_1k: null,
image_price_2k: null,
image_price_4k: null,
video_rate_independent: false,
video_rate_multiplier: 1,
video_price_480p: null,
video_price_720p: null,
video_price_1080p: null,
web_search_price_per_call: null,
peak_rate_enabled: false,
peak_start: '',
peak_end: '',
peak_rate_multiplier: 1,
claude_code_only: false,
fallback_group_id: null,
fallback_group_id_on_invalid_request: null,
allow_messages_dispatch: false,
default_mapped_model: '',
messages_dispatch_model_config: undefined,
require_oauth_only: false,
require_privacy_set: false,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
model_routing: null,
model_routing_enabled: false,
mcp_xml_inject: true,
supported_model_scopes: [],
account_count: 1,
active_account_count: 1,
rate_limited_account_count: 0,
models_list_config: undefined,
sort_order: 10
}
const AppLayoutStub = defineComponent({
template: '<main><slot /></main>'
})
const TablePageLayoutStub = defineComponent({
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>'
})
const DataTableStub = defineComponent({
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
},
template: '<div><div v-for="row in data" :key="row.id"><slot name="cell-actions" :row="row" /></div></div>'
})
function mountView() {
return mount(GroupsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
Pagination: true,
BaseDialog: true,
ConfirmDialog: true,
EmptyState: true,
Select: true,
PlatformIcon: true,
Icon: true,
GroupCapacityBadge: true,
GroupRateMultipliersModal: true,
GroupRPMOverridesModal: true,
VueDraggable: true
}
}
})
}
describe('GroupsView duplicate action', () => {
beforeEach(() => {
localStorage.clear()
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const fn of [
listGroups,
duplicateGroup,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
showSuccess,
showError
]) {
fn.mockReset()
}
listGroups.mockResolvedValue({
items: [sourceGroup],
total: 1,
page: 1,
page_size: 20,
pages: 1
})
duplicateGroup.mockResolvedValue({
...sourceGroup,
id: 43,
name: 'Primary (Copy)',
status: 'inactive'
})
getModelsListCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('duplicates the selected group, reports success, and refreshes the list', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
await flushPromises()
expect(duplicateGroup).toHaveBeenCalledTimes(1)
expect(duplicateGroup).toHaveBeenCalledWith(42)
expect(showSuccess).toHaveBeenCalledWith('admin.groups.duplicateSuccess')
expect(listGroups).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('ignores repeated clicks while the duplicate request is in flight', async () => {
let resolveDuplicate!: (value: AdminGroup) => void
duplicateGroup.mockImplementationOnce(
() => new Promise<AdminGroup>((resolve) => { resolveDuplicate = resolve })
)
const wrapper = mountView()
await flushPromises()
const button = wrapper.get('[data-testid="group-duplicate"]')
void button.trigger('click')
void button.trigger('click')
await wrapper.vm.$nextTick()
expect(duplicateGroup).toHaveBeenCalledTimes(1)
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('title')).toBe('admin.groups.duplicating')
resolveDuplicate({ ...sourceGroup, id: 43, name: 'Primary (Copy)', status: 'inactive' })
await flushPromises()
expect(wrapper.get('[data-testid="group-duplicate"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('shows the API error and restores the action when duplication fails', async () => {
duplicateGroup.mockRejectedValueOnce(new Error('duplicate failed'))
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledWith('duplicate failed')
expect(wrapper.get('[data-testid="group-duplicate"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('does not report a successful duplicate as failed when the refresh fails', async () => {
listGroups
.mockResolvedValueOnce({
items: [sourceGroup],
total: 1,
page: 1,
page_size: 20,
pages: 1
})
.mockRejectedValueOnce(new Error('refresh failed'))
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
await flushPromises()
expect(showSuccess).toHaveBeenCalledWith('admin.groups.duplicateSuccess')
expect(showError).toHaveBeenCalledWith('admin.groups.failedToLoad')
expect(showError).not.toHaveBeenCalledWith('admin.groups.duplicateFailed')
wrapper.unmount()
})
})