Files
sub2api/backend/internal/service/admin_group_duplicate.go
T
yan9651688 9fc006546c 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
2026-07-16 18:18:28 +08:00

206 lines
7.7 KiB
Go

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
}
}
}