admin_group_duplicate.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "errors"
  6. "fmt"
  7. "strconv"
  8. "strings"
  9. "time"
  10. )
  11. const (
  12. maxGroupNameRunes = 100
  13. duplicateGroupInactiveStatus = "inactive"
  14. )
  15. func duplicateGroupOperationID(sourceID int64, actorScope, operationKey string) string {
  16. operationKey = strings.TrimSpace(operationKey)
  17. if operationKey == "" {
  18. return ""
  19. }
  20. actorScope = strings.TrimSpace(actorScope)
  21. if actorScope == "" {
  22. actorScope = "admin:0"
  23. }
  24. payload := "admin.groups.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey
  25. digest := sha256.Sum256([]byte(payload))
  26. return fmt.Sprintf("%x", digest)
  27. }
  28. func duplicateGroupName(sourceName string, copyNumber int) string {
  29. if copyNumber < 1 {
  30. copyNumber = 1
  31. }
  32. suffix := " (Copy)"
  33. if copyNumber > 1 {
  34. suffix = fmt.Sprintf(" (Copy %d)", copyNumber)
  35. }
  36. baseRunes := []rune(strings.TrimSpace(sourceName))
  37. maxBaseRunes := maxGroupNameRunes - len([]rune(suffix))
  38. if maxBaseRunes < 0 {
  39. maxBaseRunes = 0
  40. }
  41. if len(baseRunes) > maxBaseRunes {
  42. baseRunes = baseRunes[:maxBaseRunes]
  43. }
  44. return string(baseRunes) + suffix
  45. }
  46. func cloneGroupValuePointer[T any](value *T) *T {
  47. if value == nil {
  48. return nil
  49. }
  50. cloned := *value
  51. return &cloned
  52. }
  53. func cloneGroupModelRouting(value map[string][]int64) map[string][]int64 {
  54. if value == nil {
  55. return nil
  56. }
  57. cloned := make(map[string][]int64, len(value))
  58. for model, accountIDs := range value {
  59. cloned[model] = append([]int64(nil), accountIDs...)
  60. }
  61. return cloned
  62. }
  63. func cloneGroupVideoModelPrices(value map[string]map[string]float64) map[string]map[string]float64 {
  64. if value == nil {
  65. return nil
  66. }
  67. cloned := make(map[string]map[string]float64, len(value))
  68. for model, prices := range value {
  69. clonedPrices := make(map[string]float64, len(prices))
  70. for resolution, price := range prices {
  71. clonedPrices[resolution] = price
  72. }
  73. cloned[model] = clonedPrices
  74. }
  75. return cloned
  76. }
  77. func cloneGroupMessagesDispatchModelConfig(value OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig {
  78. cloned := value
  79. if value.ExactModelMappings != nil {
  80. cloned.ExactModelMappings = make(map[string]string, len(value.ExactModelMappings))
  81. for requestedModel, mappedModel := range value.ExactModelMappings {
  82. cloned.ExactModelMappings[requestedModel] = mappedModel
  83. }
  84. }
  85. return cloned
  86. }
  87. func cloneGroupForDuplicate(source *Group, operationID string) *Group {
  88. return &Group{
  89. Name: duplicateGroupName(source.Name, 1),
  90. Description: source.Description,
  91. Platform: source.Platform,
  92. RateMultiplier: source.RateMultiplier,
  93. PeakRateEnabled: source.PeakRateEnabled,
  94. PeakStart: source.PeakStart,
  95. PeakEnd: source.PeakEnd,
  96. PeakRateMultiplier: source.PeakRateMultiplier,
  97. ProfitControlEnabled: source.ProfitControlEnabled,
  98. ProfitMinMargin: source.ProfitMinMargin,
  99. ProfitSafetyBuffer: source.ProfitSafetyBuffer,
  100. IsExclusive: source.IsExclusive,
  101. Status: duplicateGroupInactiveStatus,
  102. DuplicateOperationID: operationID,
  103. SubscriptionType: source.SubscriptionType,
  104. DailyLimitUSD: cloneGroupValuePointer(source.DailyLimitUSD),
  105. WeeklyLimitUSD: cloneGroupValuePointer(source.WeeklyLimitUSD),
  106. MonthlyLimitUSD: cloneGroupValuePointer(source.MonthlyLimitUSD),
  107. DefaultValidityDays: source.DefaultValidityDays,
  108. AllowImageGeneration: source.AllowImageGeneration,
  109. AllowBatchImageGeneration: source.AllowBatchImageGeneration,
  110. ImageRateIndependent: source.ImageRateIndependent,
  111. ImageRateMultiplier: source.ImageRateMultiplier,
  112. ImagePrice1K: cloneGroupValuePointer(source.ImagePrice1K),
  113. ImagePrice2K: cloneGroupValuePointer(source.ImagePrice2K),
  114. ImagePrice4K: cloneGroupValuePointer(source.ImagePrice4K),
  115. BatchImageDiscountMultiplier: source.BatchImageDiscountMultiplier,
  116. BatchImageHoldMultiplier: source.BatchImageHoldMultiplier,
  117. VideoRateIndependent: source.VideoRateIndependent,
  118. VideoRateMultiplier: source.VideoRateMultiplier,
  119. VideoPrice480P: cloneGroupValuePointer(source.VideoPrice480P),
  120. VideoPrice720P: cloneGroupValuePointer(source.VideoPrice720P),
  121. VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P),
  122. VideoModelPrices: cloneGroupVideoModelPrices(source.VideoModelPrices),
  123. WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall),
  124. SearchPricePer1k: cloneGroupValuePointer(source.SearchPricePer1k),
  125. AudioRealtimePricePerMin: cloneGroupValuePointer(source.AudioRealtimePricePerMin),
  126. AudioTTSPricePerMillionChars: cloneGroupValuePointer(source.AudioTTSPricePerMillionChars),
  127. AudioSTTPricePerHour: cloneGroupValuePointer(source.AudioSTTPricePerHour),
  128. ClaudeCodeOnly: source.ClaudeCodeOnly,
  129. FallbackGroupID: cloneGroupValuePointer(source.FallbackGroupID),
  130. FallbackGroupIDOnInvalidRequest: cloneGroupValuePointer(source.FallbackGroupIDOnInvalidRequest),
  131. ModelRouting: cloneGroupModelRouting(source.ModelRouting),
  132. ModelRoutingEnabled: source.ModelRoutingEnabled,
  133. MCPXMLInject: source.MCPXMLInject,
  134. SupportedModelScopes: append([]string(nil), source.SupportedModelScopes...),
  135. SortOrder: source.SortOrder,
  136. AllowMessagesDispatch: source.AllowMessagesDispatch,
  137. AllowLive: source.AllowLive,
  138. ForceOpenAIFast: source.ForceOpenAIFast,
  139. FreeOpenAIFast: source.FreeOpenAIFast,
  140. RequireOAuthOnly: source.RequireOAuthOnly,
  141. RequirePrivacySet: source.RequirePrivacySet,
  142. DefaultMappedModel: source.DefaultMappedModel,
  143. MessagesDispatchModelConfig: cloneGroupMessagesDispatchModelConfig(source.MessagesDispatchModelConfig),
  144. ModelAllowlist: GroupModelAllowlist{
  145. Enabled: source.ModelAllowlist.Enabled,
  146. Models: append([]string(nil), source.ModelAllowlist.Models...),
  147. },
  148. // 固定账号 manifest 配置指向源分组的账号 ID,复制后成员关系可能变化,重置为关闭且列表为空。
  149. CodexModelsManifestConfig: GroupCodexModelsManifestConfig{},
  150. RPMLimit: source.RPMLimit,
  151. MaxReasoningEffort: source.MaxReasoningEffort,
  152. MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit,
  153. ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
  154. }
  155. }
  156. // RecoverDuplicateGroup performs a read-only lookup for a copy that was already
  157. // committed for the same actor, source group, and idempotency key.
  158. func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) {
  159. if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil {
  160. return nil, err
  161. }
  162. operationID := duplicateGroupOperationID(id, actorScope, operationKey)
  163. if operationID == "" {
  164. return nil, nil
  165. }
  166. if s.groupDuplicateRepo == nil {
  167. return nil, errors.New("group duplicate repository is not configured")
  168. }
  169. group, err := s.groupDuplicateRepo.FindByDuplicateOperationID(ctx, operationID)
  170. if err != nil {
  171. return nil, fmt.Errorf("find duplicate group operation: %w", err)
  172. }
  173. if group == nil {
  174. return nil, nil
  175. }
  176. hydrated, err := s.groupRepo.GetByID(ctx, group.ID)
  177. if err != nil {
  178. return nil, fmt.Errorf("load recovered duplicate group: %w", err)
  179. }
  180. return hydrated, nil
  181. }
  182. // DuplicateGroup creates an inactive copy of a group's configuration and exact
  183. // account priorities. The repository commits the group, bindings, and outbox
  184. // event atomically so a failed binding never leaves an orphan group.
  185. func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) {
  186. if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil {
  187. return nil, err
  188. }
  189. existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey)
  190. if err != nil {
  191. return nil, err
  192. }
  193. if existing != nil {
  194. return existing, nil
  195. }
  196. source, err := s.groupRepo.GetByID(ctx, id)
  197. if err != nil {
  198. return nil, err
  199. }
  200. if s.groupDuplicateRepo == nil {
  201. return nil, errors.New("group duplicate repository is not configured")
  202. }
  203. duplicate := cloneGroupForDuplicate(source, duplicateGroupOperationID(id, actorScope, operationKey))
  204. sanitizeGroupReasoningEffortPolicy(duplicate)
  205. for copyNumber := 1; ; copyNumber++ {
  206. duplicate.Name = duplicateGroupName(source.Name, copyNumber)
  207. duplicate.ID = 0
  208. duplicate.CreatedAt = time.Time{}
  209. duplicate.UpdatedAt = time.Time{}
  210. if err := s.groupDuplicateRepo.CreateFromSource(ctx, duplicate, source.ID); err == nil {
  211. hydrated, loadErr := s.groupRepo.GetByID(ctx, duplicate.ID)
  212. if loadErr != nil {
  213. return nil, fmt.Errorf("load duplicate group: %w", loadErr)
  214. }
  215. return hydrated, nil
  216. } else if !errors.Is(err, ErrGroupExists) {
  217. return nil, fmt.Errorf("create duplicate group: %w", err)
  218. }
  219. // A unique conflict can be either the generated name or the operation ID.
  220. // Recover first; if no operation row exists, advance to the next name.
  221. recovered, recoverErr := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey)
  222. if recoverErr != nil {
  223. return nil, recoverErr
  224. }
  225. if recovered != nil {
  226. return recovered, nil
  227. }
  228. }
  229. }