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:
1 parent
b960ec1980
commit
9fc006546c
32 files changed
+1797
-37
No files matched your search
@@ -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
@@ -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(", ")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user