2026-07-07 23:35:10 +08:00
package service
import (
"context"
"errors"
"fmt"
2026-07-21 10:22:28 +08:00
"net/http"
2026-07-07 23:35:10 +08:00
"strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
2026-09-07 09:20:48 +08:00
"github.com/Wei-Shaw/sub2api/internal/config"
2026-10-02 15:23:55 +08:00
"github.com/Wei-Shaw/sub2api/internal/domain"
2026-07-07 23:35:10 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
2026-10-02 13:51:58 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
2026-07-07 23:35:10 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
// Group management implementations
2026-09-07 09:20:48 +08:00
func ( s * adminServiceImpl ) ValidateSimpleModeGroupOperation ( operation AdminGroupOperation ) error {
return ValidateSimpleModeGroupOperation ( s . cfg , operation )
}
2026-07-07 23:35:10 +08:00
func ( s * adminServiceImpl ) ListGroups ( ctx context . Context , page , pageSize int , platform , status , search string , isExclusive * bool , sortBy , sortOrder string ) ([] Group , int64 , error ) {
params := pagination . PaginationParams { Page : page , PageSize : pageSize , SortBy : sortBy , SortOrder : sortOrder }
2026-09-07 09:20:48 +08:00
var groups [] Group
var result * pagination . PaginationResult
var err error
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple {
repo , ok := s . groupRepo .( interface {
ListBindableWithFilters ( context . Context , pagination . PaginationParams , string , string , string , * bool ) ([] Group , * pagination . PaginationResult , error )
})
if ! ok {
return nil , 0 , errors . New ( "group repository does not support simple-mode filtering" )
}
groups , result , err = repo . ListBindableWithFilters ( ctx , params , platform , status , search , isExclusive )
} else {
groups , result , err = s . groupRepo . ListWithFilters ( ctx , params , platform , status , search , isExclusive )
}
2026-07-07 23:35:10 +08:00
if err != nil {
return nil , 0 , err
}
return groups , result . Total , nil
}
func ( s * adminServiceImpl ) GetAllGroups ( ctx context . Context ) ([] Group , error ) {
return s . groupRepo . ListActive ( ctx )
}
func ( s * adminServiceImpl ) GetAllGroupsByPlatform ( ctx context . Context , platform string ) ([] Group , error ) {
return s . groupRepo . ListActiveByPlatform ( ctx , platform )
}
func ( s * adminServiceImpl ) GetAllGroupsIncludingInactive ( ctx context . Context ) ([] Group , error ) {
// ListWithFilters with empty status = no status filter, so active + disabled groups are returned.
// PageSize 10000 is intentionally large; group count is O(dozens) in practice.
groups , _ , err := s . groupRepo . ListWithFilters ( ctx , pagination . PaginationParams { Page : 1 , PageSize : 10000 }, "" , "" , "" , nil )
return groups , err
}
func ( s * adminServiceImpl ) GetGroup ( ctx context . Context , id int64 ) ( * Group , error ) {
2026-09-07 09:20:48 +08:00
group , err := s . groupRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
if err := s . validateSimpleModeGroupAccess ( group ); err != nil {
return nil , err
}
return group , nil
}
func ( s * adminServiceImpl ) validateSimpleModeGroupAccess ( group * Group ) error {
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple && ! IsGroupBindableInSimpleMode ( group ) {
return infraerrors . BadRequest ( "SIMPLE_MODE_GROUP_NOT_BINDABLE" , "composite groups are not supported in simple mode" )
}
return nil
2026-07-07 23:35:10 +08:00
}
func ( s * adminServiceImpl ) GetGroupModelsListCandidates ( ctx context . Context , id int64 , platform string ) ([] string , error ) {
platform = strings . TrimSpace ( platform )
if id > 0 {
group , err := s . groupRepo . GetByIDLite ( ctx , id )
if err != nil {
return nil , err
}
if platform == "" {
platform = group . Platform
}
}
if platform == "" {
platform = PlatformAnthropic
}
candidates := defaultModelsListCandidateIDs ( platform )
if id <= 0 || s . accountRepo == nil {
return candidates , nil
}
accounts , err := s . accountRepo . ListSchedulableByGroupID ( ctx , id )
if err != nil {
return nil , err
}
seen := make ( map [ string ] struct {}, len ( candidates ))
for _ , model := range candidates {
seen [ model ] = struct {}{}
}
for _ , acc := range accounts {
2026-07-01 08:25:47 +08:00
if platform == PlatformComposite {
if ! isConcreteRequestPlatform ( acc . Platform ) {
continue
}
} else if acc . Platform != platform {
2026-07-07 23:35:10 +08:00
continue
}
for model := range acc . GetModelMapping () {
model = strings . TrimSpace ( model )
if model == "" {
continue
}
if _ , ok := seen [ model ]; ok {
continue
}
seen [ model ] = struct {}{}
candidates = append ( candidates , model )
}
}
return candidates , nil
}
2026-07-01 09:27:47 +08:00
func ( s * adminServiceImpl ) ListCompositeRoutes ( ctx context . Context , groupID int64 ) ([] CompositeModelRoute , error ) {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationCompositeRoute ); err != nil {
return nil , err
}
2026-07-01 09:27:47 +08:00
if err := s . requireCompositeGroup ( ctx , groupID ); err != nil {
return nil , err
}
if s . compositeRouteRepo == nil {
return nil , fmt . Errorf ( "composite route repository is not configured" )
}
return s . compositeRouteRepo . ListByGroup ( ctx , groupID , true )
}
func ( s * adminServiceImpl ) CreateCompositeRoute ( ctx context . Context , groupID int64 , input CompositeRouteInput ) ( * CompositeModelRoute , error ) {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationCompositeRoute ); err != nil {
return nil , err
}
2026-07-01 09:27:47 +08:00
if err := s . requireCompositeGroup ( ctx , groupID ); err != nil {
return nil , err
}
if s . compositeRouteRepo == nil {
return nil , fmt . Errorf ( "composite route repository is not configured" )
}
route , err := compositeRouteFromInput ( groupID , input )
if err != nil {
return nil , err
}
if err := s . compositeRouteRepo . Create ( ctx , route ); err != nil {
return nil , err
}
return route , nil
}
func ( s * adminServiceImpl ) UpdateCompositeRoute ( ctx context . Context , groupID , routeID int64 , input CompositeRouteInput ) ( * CompositeModelRoute , error ) {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationCompositeRoute ); err != nil {
return nil , err
}
2026-07-01 09:27:47 +08:00
if err := s . requireCompositeGroup ( ctx , groupID ); err != nil {
return nil , err
}
if s . compositeRouteRepo == nil {
return nil , fmt . Errorf ( "composite route repository is not configured" )
}
if ok , err := s . compositeRouteBelongsToGroup ( ctx , groupID , routeID ); err != nil {
return nil , err
} else if ! ok {
return nil , ErrCompositeRouteNotFound
}
route , err := compositeRouteFromInput ( groupID , input )
if err != nil {
return nil , err
}
route . ID = routeID
if err := s . compositeRouteRepo . Update ( ctx , route ); err != nil {
return nil , err
}
return route , nil
}
func ( s * adminServiceImpl ) DeleteCompositeRoute ( ctx context . Context , groupID , routeID int64 ) error {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationCompositeRoute ); err != nil {
return err
}
2026-07-01 09:27:47 +08:00
if err := s . requireCompositeGroup ( ctx , groupID ); err != nil {
return err
}
if s . compositeRouteRepo == nil {
return fmt . Errorf ( "composite route repository is not configured" )
}
if ok , err := s . compositeRouteBelongsToGroup ( ctx , groupID , routeID ); err != nil {
return err
} else if ! ok {
return ErrCompositeRouteNotFound
}
return s . compositeRouteRepo . Delete ( ctx , routeID )
}
func ( s * adminServiceImpl ) PreviewCompositeRoute ( ctx context . Context , groupID int64 , input CompositeRoutePreviewRequest ) ( * CompositeRouteDecision , error ) {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationCompositeRoute ); err != nil {
return nil , err
}
2026-07-01 09:27:47 +08:00
if err := s . requireCompositeGroup ( ctx , groupID ); err != nil {
return nil , err
}
resolver := s . compositeResolver
if resolver == nil {
resolver = NewCompositeRouteResolver ( s . compositeRouteRepo )
}
decision , err := resolver . Resolve ( ctx , groupID , input . Model , input . Endpoint )
if err != nil {
return nil , err
}
return & decision , nil
}
func ( s * adminServiceImpl ) requireCompositeGroup ( ctx context . Context , groupID int64 ) error {
group , err := s . groupRepo . GetByIDLite ( ctx , groupID )
if err != nil {
return err
}
if group . Platform != PlatformComposite {
return fmt . Errorf ( "group %d is not a composite group" , groupID )
}
return nil
}
func ( s * adminServiceImpl ) compositeRouteBelongsToGroup ( ctx context . Context , groupID , routeID int64 ) ( bool , error ) {
routes , err := s . compositeRouteRepo . ListByGroup ( ctx , groupID , true )
if err != nil {
return false , err
}
for i := range routes {
if routes [ i ]. ID == routeID {
return true , nil
}
}
return false , nil
}
func compositeRouteFromInput ( groupID int64 , input CompositeRouteInput ) ( * CompositeModelRoute , error ) {
input = normalizeCompositeRouteInput ( input )
if input . PublicModel == "" {
return nil , fmt . Errorf ( "public_model is required" )
}
if ! isConcreteRequestPlatform ( input . TargetPlatform ) {
return nil , fmt . Errorf ( "target_platform must be a concrete provider" )
}
if input . Priority == 0 {
input . Priority = 100
}
return & CompositeModelRoute {
GroupID : groupID ,
PublicModel : input . PublicModel ,
MatchType : input . MatchType ,
TargetPlatform : input . TargetPlatform ,
UpstreamModel : input . UpstreamModel ,
Endpoint : input . Endpoint ,
Priority : input . Priority ,
Enabled : input . Enabled ,
Notes : input . Notes ,
}, nil
}
2026-07-07 23:35:10 +08:00
func defaultModelsListCandidateIDs ( platform string ) [] string {
switch platform {
case PlatformOpenAI :
return openai . DefaultModelIDs ()
case PlatformGemini :
ids := make ([] string , 0 , len ( geminicli . DefaultModels ))
for _ , model := range geminicli . DefaultModels {
ids = append ( ids , model . ID )
}
return ids
case PlatformAntigravity :
models := antigravity . DefaultModels ()
ids := make ([] string , 0 , len ( models ))
for _ , model := range models {
ids = append ( ids , model . ID )
}
return ids
case PlatformGrok :
return xai . DefaultModelIDs ()
2026-09-11 10:05:24 +08:00
case PlatformOpenCodeGo :
return DefaultOpenCodeGoModelIDs ()
2026-10-02 13:51:58 +08:00
case PlatformTypeSafe :
return [] string { typesafe . JevLatestModel }
2026-06-30 22:05:49 +08:00
case PlatformComposite :
2026-07-01 08:25:47 +08:00
return compositeDefaultModelsListCandidateIDs ()
2026-07-07 23:35:10 +08:00
default :
ids := make ([] string , 0 , len ( claude . DefaultModels ))
for _ , model := range claude . DefaultModels {
ids = append ( ids , model . ID )
}
return ids
}
}
func defaultAllowImageGenerationForPlatform ( platform string ) bool {
// Grok image and video generation routes share the legacy image-generation gate.
// Older clients send the false zero value, so Grok groups must default enabled.
return platform == PlatformGrok
}
2026-07-01 08:25:47 +08:00
func compositeDefaultModelsListCandidateIDs () [] string {
seen := make ( map [ string ] struct {})
ids := make ([] string , 0 )
2026-10-02 15:23:55 +08:00
for _ , platform := range domain . CompositePrecedencePlatformIDs () {
// TypeSafe stays out of the static composite candidates (jev-latest only works
// through /v1/systemone); groups with TypeSafe accounts still get it from the
// account model mappings collected by GetGroupModelsListCandidates.
if platform == PlatformTypeSafe {
continue
}
2026-07-01 08:25:47 +08:00
for _ , id := range defaultModelsListCandidateIDs ( platform ) {
if _ , ok := seen [ id ]; ok {
continue
}
seen [ id ] = struct {}{}
ids = append ( ids , id )
}
}
return ids
}
2026-06-30 22:05:49 +08:00
func canCopyAccountsFromGroupPlatform ( targetPlatform , sourcePlatform string ) bool {
if targetPlatform == PlatformComposite {
return sourcePlatform == PlatformComposite || isConcreteRequestPlatform ( sourcePlatform )
}
return sourcePlatform == targetPlatform
}
func groupSupportsOAuthOnlyFilter ( platform string ) bool {
return platform == PlatformOpenAI ||
platform == PlatformAntigravity ||
platform == PlatformAnthropic ||
platform == PlatformGemini ||
platform == PlatformGrok ||
platform == PlatformComposite
}
2026-08-23 18:57:41 +09:00
func groupSupportsOpenAIFast ( platform string ) bool {
return platform == PlatformOpenAI || platform == PlatformComposite
}
func sanitizeGroupOpenAIFast ( group * Group ) {
if group == nil || ! groupSupportsOpenAIFast ( group . Platform ) {
if group != nil {
group . ForceOpenAIFast = false
2026-09-01 04:31:33 +09:00
group . FreeOpenAIFast = false
2026-08-23 18:57:41 +09:00
}
}
}
2026-09-07 09:20:48 +08:00
func normalizeCreateGroupInputForSimpleMode ( input * CreateGroupInput ) {
if input == nil {
return
}
* input = CreateGroupInput {
Name : input . Name , Description : input . Description , Platform : input . Platform ,
RateMultiplier : 1 , SubscriptionType : SubscriptionTypeStandard ,
}
}
func normalizeUpdateGroupInputForSimpleMode ( input * UpdateGroupInput ) {
if input == nil {
return
}
* input = UpdateGroupInput { Name : input . Name , Description : input . Description }
}
2026-07-07 23:35:10 +08:00
func ( s * adminServiceImpl ) CreateGroup ( ctx context . Context , input * CreateGroupInput ) ( * Group , error ) {
2026-09-07 09:20:48 +08:00
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple && NormalizeGroupPlatform ( input . Platform ) == PlatformComposite {
return nil , infraerrors . BadRequest ( "SIMPLE_MODE_GROUP_NOT_BINDABLE" , "composite groups are not supported in simple mode" )
}
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple {
normalizeCreateGroupInputForSimpleMode ( input )
}
2026-07-07 23:35:10 +08:00
if input . RateMultiplier <= 0 {
return nil , errors . New ( "rate_multiplier must be > 0" )
}
2026-08-01 22:25:35 +08:00
platform := NormalizeGroupPlatform ( input . Platform )
2026-09-04 23:40:41 +08:00
// 固定账号 manifest 配置:账号绑定发生在创建之后,创建时无法校验成员关系,
// 拒绝开启并在创建后的编辑里配置。
if normalizeCodexModelsManifestConfig ( platform , input . CodexModelsManifestConfig ). Enabled {
return nil , infraerrors . New ( http . StatusBadRequest , "INVALID_CODEX_MODELS_MANIFEST_CONFIG" , "codex models manifest config cannot be enabled at group creation; configure it after creation in the group editor" )
}
2026-08-13 07:49:08 +08:00
modelPricing , err := normalizeGroupModelPricing ( platform , input . ModelPricing )
if err != nil {
return nil , err
}
2026-07-21 10:22:28 +08:00
maxReasoningEffort , err := normalizeMaxReasoningEffortForPlatform ( platform , input . MaxReasoningEffort )
if err != nil {
return nil , infraerrors . Newf ( http . StatusBadRequest , "INVALID_MAX_REASONING_EFFORT" , "%v" , err )
}
2026-09-01 10:15:45 +08:00
maxReasoningEffortOverLimit , err := normalizeMaxReasoningEffortOverLimitForPlatform ( platform , input . MaxReasoningEffortOverLimit )
if err != nil {
return nil , infraerrors . Newf ( http . StatusBadRequest , "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT" , "%v" , err )
}
2026-07-21 10:22:28 +08:00
reasoningEffortMappings , err := NormalizeReasoningEffortMappings ( platform , input . ReasoningEffortMappings )
if err != nil {
return nil , infraerrors . Newf ( http . StatusBadRequest , "INVALID_REASONING_EFFORT_MAPPING" , "%v" , err )
}
2026-07-07 23:35:10 +08:00
subscriptionType := input . SubscriptionType
if subscriptionType == "" {
subscriptionType = SubscriptionTypeStandard
}
// 限额字段:nil/负数 表示"无限制", 0 表示"不允许用量",正数表示具体限额
dailyLimit := normalizeLimit ( input . DailyLimitUSD )
weeklyLimit := normalizeLimit ( input . WeeklyLimitUSD )
monthlyLimit := normalizeLimit ( input . MonthlyLimitUSD )
// 图片价格:负数表示清除(使用默认价格),0 保留(表示免费)
imagePrice1K := normalizePrice ( input . ImagePrice1K )
imagePrice2K := normalizePrice ( input . ImagePrice2K )
imagePrice4K := normalizePrice ( input . ImagePrice4K )
2026-07-07 12:00:36 +08:00
videoPrice480P := normalizePrice ( input . VideoPrice480P )
videoPrice720P := normalizePrice ( input . VideoPrice720P )
videoPrice1080P := normalizePrice ( input . VideoPrice1080P )
2026-07-13 09:54:51 +08:00
webSearchPricePerCall := normalizePrice ( input . WebSearchPricePerCall )
2026-08-07 15:52:55 +08:00
searchPricePer1k := normalizePrice ( input . SearchPricePer1k )
2026-08-07 15:08:39 +08:00
audioRealtimePricePerMin := normalizePrice ( input . AudioRealtimePricePerMin )
audioTTSPricePerMillionChars := normalizePrice ( input . AudioTTSPricePerMillionChars )
audioSTTPricePerHour := normalizePrice ( input . AudioSTTPricePerHour )
2026-07-07 23:35:10 +08:00
imageRateMultiplier := 1.0
if input . ImageRateMultiplier != nil {
if * input . ImageRateMultiplier < 0 {
return nil , errors . New ( "image_rate_multiplier must be >= 0" )
}
imageRateMultiplier = * input . ImageRateMultiplier
}
batchImageDiscountMultiplier := defaultBatchImageDiscountMultiplier
if input . BatchImageDiscountMultiplier != nil {
if * input . BatchImageDiscountMultiplier < 0 {
return nil , errors . New ( "batch_image_discount_multiplier must be >= 0" )
}
batchImageDiscountMultiplier = * input . BatchImageDiscountMultiplier
}
batchImageHoldMultiplier := defaultBatchImageHoldMultiplier
if input . BatchImageHoldMultiplier != nil {
if * input . BatchImageHoldMultiplier < 0 {
return nil , errors . New ( "batch_image_hold_multiplier must be >= 0" )
}
batchImageHoldMultiplier = * input . BatchImageHoldMultiplier
}
// 不变式:hold 比例 >= discount 比例。否则批量任务成功率足够高时
// 实际成本会超过冻结额,结算永远失败、用户冻结余额无法解冻。
if batchImageHoldMultiplier < batchImageDiscountMultiplier {
return nil , errors . New ( "batch_image_hold_multiplier must be >= batch_image_discount_multiplier" )
}
2026-07-07 12:00:36 +08:00
videoRateMultiplier := 1.0
if input . VideoRateMultiplier != nil {
if * input . VideoRateMultiplier < 0 {
return nil , errors . New ( "video_rate_multiplier must be >= 0" )
}
videoRateMultiplier = * input . VideoRateMultiplier
}
2026-07-07 23:35:10 +08:00
peakRateMultiplier := 1.0
if input . PeakRateMultiplier != nil {
peakRateMultiplier = * input . PeakRateMultiplier
}
// 先归一化(非订阅分组清空高峰配置、清洗停用状态下的脏字段)再校验,与 UpdateGroup 同一收口。
peakRateEnabled , peakStart , peakEnd , peakRateMultiplier := NormalizePeakRateConfig ( subscriptionType , input . PeakRateEnabled , input . PeakStart , input . PeakEnd , peakRateMultiplier )
if err := ValidatePeakRateConfig ( subscriptionType , peakRateEnabled , peakStart , peakEnd , peakRateMultiplier ); err != nil {
2026-09-10 21:05:47 +08:00
return nil , infraerrors . BadRequest ( "INVALID_PEAK_RATE_CONFIG" , err . Error ())
2026-07-07 23:35:10 +08:00
}
2026-07-26 14:18:13 -04:00
profitMinMargin := 0.0
if input . ProfitMinMargin != nil {
profitMinMargin = * input . ProfitMinMargin
}
profitSafetyBuffer := 0.0
if input . ProfitSafetyBuffer != nil {
profitSafetyBuffer = * input . ProfitSafetyBuffer
}
// 利润控制与高峰倍率同一收口顺序:先按平台归一化(不支持的平台重置),再校验。
profitControlEnabled , profitMinMargin , profitSafetyBuffer := NormalizeProfitControlConfig ( platform , input . ProfitControlEnabled , profitMinMargin , profitSafetyBuffer )
if err := ValidateProfitControlConfig ( platform , profitControlEnabled , profitMinMargin , profitSafetyBuffer ); err != nil {
return nil , err
}
2026-07-07 23:35:10 +08:00
// 校验降级分组
if input . FallbackGroupID != nil {
if err := s . validateFallbackGroup ( ctx , 0 , * input . FallbackGroupID ); err != nil {
return nil , err
}
}
fallbackOnInvalidRequest := input . FallbackGroupIDOnInvalidRequest
if fallbackOnInvalidRequest != nil && * fallbackOnInvalidRequest <= 0 {
fallbackOnInvalidRequest = nil
}
// 校验无效请求兜底分组
if fallbackOnInvalidRequest != nil {
if err := s . validateFallbackGroupOnInvalidRequest ( ctx , 0 , platform , subscriptionType , * fallbackOnInvalidRequest ); err != nil {
return nil , err
}
}
// MCPXMLInject:默认为 true,仅当显式传入 false 时关闭
mcpXMLInject := true
if input . MCPXMLInject != nil {
mcpXMLInject = * input . MCPXMLInject
}
allowImageGeneration := input . AllowImageGeneration || defaultAllowImageGenerationForPlatform ( platform )
allowBatchImageGeneration := input . AllowBatchImageGeneration && allowImageGeneration && platform == PlatformGemini
// 如果指定了复制账号的源分组,先获取账号 ID 列表
var accountIDsToCopy [] int64
if len ( input . CopyAccountsFromGroupIDs ) > 0 {
// 去重源分组 IDs
seen := make ( map [ int64 ] struct {})
uniqueSourceGroupIDs := make ([] int64 , 0 , len ( input . CopyAccountsFromGroupIDs ))
for _ , srcGroupID := range input . CopyAccountsFromGroupIDs {
if _ , exists := seen [ srcGroupID ]; ! exists {
seen [ srcGroupID ] = struct {}{}
uniqueSourceGroupIDs = append ( uniqueSourceGroupIDs , srcGroupID )
}
}
// 校验源分组的平台是否与新分组一致
for _ , srcGroupID := range uniqueSourceGroupIDs {
srcGroup , err := s . groupRepo . GetByIDLite ( ctx , srcGroupID )
if err != nil {
return nil , fmt . Errorf ( "source group %d not found: %w" , srcGroupID , err )
}
2026-06-30 22:05:49 +08:00
if ! canCopyAccountsFromGroupPlatform ( platform , srcGroup . Platform ) {
2026-07-07 23:35:10 +08:00
return nil , fmt . Errorf ( "source group %d platform mismatch: expected %s, got %s" , srcGroupID , platform , srcGroup . Platform )
}
}
// 获取所有源分组的账号(去重)
var err error
accountIDsToCopy , err = s . groupRepo . GetAccountIDsByGroupIDs ( ctx , uniqueSourceGroupIDs )
if err != nil {
return nil , fmt . Errorf ( "failed to get accounts from source groups: %w" , err )
}
}
2026-09-05 15:58:26 +08:00
// 白名单在创建路径同样收口:开启但为空、通配位置非法都会 400。
modelAllowlist , err := normalizeGroupModelAllowlist ( input . ModelAllowlist )
if err != nil {
return nil , err
}
2026-07-07 23:35:10 +08:00
group := & Group {
Name : input . Name ,
Description : input . Description ,
Platform : platform ,
RateMultiplier : input . RateMultiplier ,
IsExclusive : input . IsExclusive ,
Status : StatusActive ,
SubscriptionType : subscriptionType ,
DailyLimitUSD : dailyLimit ,
WeeklyLimitUSD : weeklyLimit ,
MonthlyLimitUSD : monthlyLimit ,
2026-08-13 07:49:08 +08:00
LongContextPricingEnabled : input . LongContextPricingEnabled ,
ModelPricing : modelPricing ,
2026-07-07 23:35:10 +08:00
AllowImageGeneration : allowImageGeneration ,
AllowBatchImageGeneration : allowBatchImageGeneration ,
ImageRateIndependent : input . ImageRateIndependent ,
ImageRateMultiplier : imageRateMultiplier ,
BatchImageDiscountMultiplier : batchImageDiscountMultiplier ,
BatchImageHoldMultiplier : batchImageHoldMultiplier ,
2026-07-07 12:00:36 +08:00
VideoRateIndependent : input . VideoRateIndependent ,
VideoRateMultiplier : videoRateMultiplier ,
2026-07-07 23:35:10 +08:00
PeakRateEnabled : peakRateEnabled ,
PeakStart : peakStart ,
PeakEnd : peakEnd ,
PeakRateMultiplier : peakRateMultiplier ,
2026-07-26 14:18:13 -04:00
ProfitControlEnabled : profitControlEnabled ,
ProfitMinMargin : profitMinMargin ,
ProfitSafetyBuffer : profitSafetyBuffer ,
2026-07-07 23:35:10 +08:00
ImagePrice1K : imagePrice1K ,
ImagePrice2K : imagePrice2K ,
ImagePrice4K : imagePrice4K ,
2026-07-07 12:00:36 +08:00
VideoPrice480P : videoPrice480P ,
VideoPrice720P : videoPrice720P ,
VideoPrice1080P : videoPrice1080P ,
2026-08-07 13:08:22 +08:00
VideoModelPrices : NormalizeVideoModelPrices ( input . VideoModelPrices ),
2026-07-13 09:54:51 +08:00
WebSearchPricePerCall : webSearchPricePerCall ,
2026-08-07 15:52:55 +08:00
SearchPricePer1k : searchPricePer1k ,
2026-08-07 15:08:39 +08:00
AudioRealtimePricePerMin : audioRealtimePricePerMin ,
AudioTTSPricePerMillionChars : audioTTSPricePerMillionChars ,
AudioSTTPricePerHour : audioSTTPricePerHour ,
2026-07-07 23:35:10 +08:00
ClaudeCodeOnly : input . ClaudeCodeOnly ,
FallbackGroupID : input . FallbackGroupID ,
FallbackGroupIDOnInvalidRequest : fallbackOnInvalidRequest ,
ModelRouting : input . ModelRouting ,
MCPXMLInject : mcpXMLInject ,
SupportedModelScopes : input . SupportedModelScopes ,
AllowMessagesDispatch : input . AllowMessagesDispatch ,
2026-07-25 07:39:32 +08:00
AllowLive : input . AllowLive ,
2026-08-23 18:57:41 +09:00
ForceOpenAIFast : input . ForceOpenAIFast ,
2026-09-01 04:31:33 +09:00
FreeOpenAIFast : input . FreeOpenAIFast ,
2026-07-07 23:35:10 +08:00
RequireOAuthOnly : input . RequireOAuthOnly ,
RequirePrivacySet : input . RequirePrivacySet ,
DefaultMappedModel : input . DefaultMappedModel ,
MessagesDispatchModelConfig : normalizeOpenAIMessagesDispatchModelConfig ( input . MessagesDispatchModelConfig ),
2026-09-05 15:58:26 +08:00
ModelAllowlist : modelAllowlist ,
2026-09-04 23:40:41 +08:00
// 固定账号 manifest 配置:账号绑定发生在分组创建之后,创建路径禁止开启,
// 成员关系无从校验(前端创建对话框也不展示)。
CodexModelsManifestConfig : normalizeCodexModelsManifestConfig ( platform , input . CodexModelsManifestConfig ),
RPMLimit : input . RPMLimit ,
MaxReasoningEffort : maxReasoningEffort ,
MaxReasoningEffortOverLimit : maxReasoningEffortOverLimit ,
ReasoningEffortMappings : reasoningEffortMappings ,
2026-07-07 23:35:10 +08:00
}
sanitizeGroupMessagesDispatchFields ( group )
2026-08-23 18:57:41 +09:00
sanitizeGroupOpenAIFast ( group )
2026-08-19 04:40:32 +00:00
if group . Platform != PlatformOpenAI && group . Platform != PlatformComposite {
2026-07-25 07:39:32 +08:00
group . AllowLive = false
}
2026-07-21 10:22:28 +08:00
sanitizeGroupReasoningEffortPolicy ( group )
2026-07-07 23:35:10 +08:00
if err := s . groupRepo . Create ( ctx , group ); err != nil {
return nil , err
}
// require_oauth_only: 过滤掉 apikey 类型账号
2026-06-30 22:05:49 +08:00
if group . RequireOAuthOnly && groupSupportsOAuthOnlyFilter ( group . Platform ) && len ( accountIDsToCopy ) > 0 {
2026-07-07 23:35:10 +08:00
accounts , err := s . accountRepo . GetByIDs ( ctx , accountIDsToCopy )
if err != nil {
return nil , fmt . Errorf ( "failed to fetch accounts for oauth filter: %w" , err )
}
oauthIDs := make ( map [ int64 ] struct {}, len ( accounts ))
for _ , acc := range accounts {
if acc . Type != AccountTypeAPIKey {
oauthIDs [ acc . ID ] = struct {}{}
}
}
var filtered [] int64
for _ , aid := range accountIDsToCopy {
if _ , ok := oauthIDs [ aid ]; ok {
filtered = append ( filtered , aid )
}
}
accountIDsToCopy = filtered
}
// 如果有需要复制的账号,绑定到新分组
if len ( accountIDsToCopy ) > 0 {
if err := s . groupRepo . BindAccountsToGroup ( ctx , group . ID , accountIDsToCopy ); err != nil {
return nil , fmt . Errorf ( "failed to bind accounts to new group: %w" , err )
}
group . AccountCount = int64 ( len ( accountIDsToCopy ))
}
return group , nil
}
// normalizeLimit 将负数转换为 nil(表示无限制),0 保留(表示限额为零)
func normalizeLimit ( limit * float64 ) * float64 {
if limit == nil || * limit < 0 {
return nil
}
return limit
}
// normalizePrice 将负数转换为 nil(表示使用默认价格),0 保留(表示免费)
func normalizePrice ( price * float64 ) * float64 {
if price == nil || * price < 0 {
return nil
}
return price
}
// validateFallbackGroup 校验降级分组的有效性
// currentGroupID: 当前分组 ID(新建时为 0)
// fallbackGroupID: 降级分组 ID
func ( s * adminServiceImpl ) validateFallbackGroup ( ctx context . Context , currentGroupID , fallbackGroupID int64 ) error {
// 不能将自己设置为降级分组
if currentGroupID > 0 && currentGroupID == fallbackGroupID {
return fmt . Errorf ( "cannot set self as fallback group" )
}
visited := map [ int64 ] struct {}{}
nextID := fallbackGroupID
for {
if _ , seen := visited [ nextID ]; seen {
return fmt . Errorf ( "fallback group cycle detected" )
}
visited [ nextID ] = struct {}{}
if currentGroupID > 0 && nextID == currentGroupID {
return fmt . Errorf ( "fallback group cycle detected" )
}
// 检查降级分组是否存在
fallbackGroup , err := s . groupRepo . GetByIDLite ( ctx , nextID )
if err != nil {
return fmt . Errorf ( "fallback group not found: %w" , err )
}
// 降级分组不能启用 claude_code_only,否则会造成死循环
if nextID == fallbackGroupID && fallbackGroup . ClaudeCodeOnly {
return fmt . Errorf ( "fallback group cannot have claude_code_only enabled" )
}
if fallbackGroup . FallbackGroupID == nil {
return nil
}
nextID = * fallbackGroup . FallbackGroupID
}
}
// validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性
// currentGroupID: 当前分组 ID(新建时为 0)
// platform/subscriptionType: 当前分组的有效平台/订阅类型
// fallbackGroupID: 兜底分组 ID
func ( s * adminServiceImpl ) validateFallbackGroupOnInvalidRequest ( ctx context . Context , currentGroupID int64 , platform , subscriptionType string , fallbackGroupID int64 ) error {
if platform != PlatformAnthropic && platform != PlatformAntigravity {
return fmt . Errorf ( "invalid request fallback only supported for anthropic or antigravity groups" )
}
if subscriptionType == SubscriptionTypeSubscription {
return fmt . Errorf ( "subscription groups cannot set invalid request fallback" )
}
if currentGroupID > 0 && currentGroupID == fallbackGroupID {
return fmt . Errorf ( "cannot set self as invalid request fallback group" )
}
fallbackGroup , err := s . groupRepo . GetByIDLite ( ctx , fallbackGroupID )
if err != nil {
return fmt . Errorf ( "fallback group not found: %w" , err )
}
if fallbackGroup . Platform != PlatformAnthropic {
return fmt . Errorf ( "fallback group must be anthropic platform" )
}
if fallbackGroup . SubscriptionType == SubscriptionTypeSubscription {
return fmt . Errorf ( "fallback group cannot be subscription type" )
}
if fallbackGroup . FallbackGroupIDOnInvalidRequest != nil {
return fmt . Errorf ( "fallback group cannot have invalid request fallback configured" )
}
return nil
}
func ( s * adminServiceImpl ) UpdateGroup ( ctx context . Context , id int64 , input * UpdateGroupInput ) ( * Group , error ) {
group , err := s . groupRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
2026-09-07 09:20:48 +08:00
if err := s . validateSimpleModeGroupAccess ( group ); err != nil {
return nil , err
}
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple && input . Platform == PlatformComposite {
return nil , infraerrors . BadRequest ( "SIMPLE_MODE_GROUP_NOT_BINDABLE" , "composite groups are not supported in simple mode" )
}
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple {
normalizeUpdateGroupInputForSimpleMode ( input )
}
2026-07-07 23:35:10 +08:00
2026-08-12 03:22:06 +00:00
// 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾)
previousPlatform := group . Platform
2026-07-07 23:35:10 +08:00
if input . Name != "" {
group . Name = input . Name
}
if input . Description != nil {
group . Description = * input . Description
}
if input . Platform != "" {
group . Platform = input . Platform
}
if input . RateMultiplier != nil {
if * input . RateMultiplier <= 0 {
return nil , errors . New ( "rate_multiplier must be > 0" )
}
group . RateMultiplier = * input . RateMultiplier
}
if input . IsExclusive != nil {
group . IsExclusive = * input . IsExclusive
}
if input . Status != "" {
group . Status = input . Status
}
2026-08-13 07:49:08 +08:00
if input . LongContextPricingEnabled != nil {
group . LongContextPricingEnabled = * input . LongContextPricingEnabled
}
if input . ModelPricing != nil {
modelPricing , normalizeErr := normalizeGroupModelPricing ( group . Platform , * input . ModelPricing )
if normalizeErr != nil {
return nil , normalizeErr
}
group . ModelPricing = modelPricing
}
2026-07-07 23:35:10 +08:00
// 订阅相关字段
if input . SubscriptionType != "" {
group . SubscriptionType = input . SubscriptionType
}
2026-08-30 16:36:13 +08:00
// 限额字段:nil 表示不修改,负数表示"无限制", 0 表示"不允许用量",正数表示具体限额。
if input . DailyLimitUSD != nil {
group . DailyLimitUSD = normalizeLimit ( input . DailyLimitUSD )
}
if input . WeeklyLimitUSD != nil {
group . WeeklyLimitUSD = normalizeLimit ( input . WeeklyLimitUSD )
}
if input . MonthlyLimitUSD != nil {
group . MonthlyLimitUSD = normalizeLimit ( input . MonthlyLimitUSD )
}
2026-07-07 23:35:10 +08:00
// 图片生成计费配置:负数表示清除(使用默认价格)
if input . AllowImageGeneration != nil {
group . AllowImageGeneration = * input . AllowImageGeneration
}
if input . AllowBatchImageGeneration != nil {
group . AllowBatchImageGeneration = * input . AllowBatchImageGeneration
}
if ! group . AllowImageGeneration || group . Platform != PlatformGemini {
group . AllowBatchImageGeneration = false
}
if input . ImageRateIndependent != nil {
group . ImageRateIndependent = * input . ImageRateIndependent
}
if input . ImageRateMultiplier != nil {
if * input . ImageRateMultiplier < 0 {
return nil , errors . New ( "image_rate_multiplier must be >= 0" )
}
group . ImageRateMultiplier = * input . ImageRateMultiplier
}
if input . BatchImageDiscountMultiplier != nil {
if * input . BatchImageDiscountMultiplier < 0 {
return nil , errors . New ( "batch_image_discount_multiplier must be >= 0" )
}
group . BatchImageDiscountMultiplier = * input . BatchImageDiscountMultiplier
}
if input . BatchImageHoldMultiplier != nil {
if * input . BatchImageHoldMultiplier < 0 {
return nil , errors . New ( "batch_image_hold_multiplier must be >= 0" )
}
group . BatchImageHoldMultiplier = * input . BatchImageHoldMultiplier
}
// 仅在本次更新显式触碰任一比例时校验合并后的不变式(hold >= discount),
// 避免存量脏数据阻塞其他字段的正常更新(提交侧另有钳制兜底)。
if ( input . BatchImageDiscountMultiplier != nil || input . BatchImageHoldMultiplier != nil ) &&
group . BatchImageHoldMultiplier < group . BatchImageDiscountMultiplier {
return nil , errors . New ( "batch_image_hold_multiplier must be >= batch_image_discount_multiplier" )
}
2026-07-07 12:00:36 +08:00
if input . VideoRateIndependent != nil {
group . VideoRateIndependent = * input . VideoRateIndependent
}
if input . VideoRateMultiplier != nil {
if * input . VideoRateMultiplier < 0 {
return nil , errors . New ( "video_rate_multiplier must be >= 0" )
}
group . VideoRateMultiplier = * input . VideoRateMultiplier
}
2026-07-07 23:35:10 +08:00
if input . PeakRateEnabled != nil {
group . PeakRateEnabled = * input . PeakRateEnabled
}
if input . PeakStart != nil {
group . PeakStart = * input . PeakStart
}
if input . PeakEnd != nil {
group . PeakEnd = * input . PeakEnd
}
if input . PeakRateMultiplier != nil {
group . PeakRateMultiplier = * input . PeakRateMultiplier
}
// 先归一化(非订阅分组——含本次更新转为非订阅——静默清空高峰配置,清洗停用状态下的脏字段),
// 再收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验,
// 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。与 CreateGroup 同一收口。
group . PeakRateEnabled , group . PeakStart , group . PeakEnd , group . PeakRateMultiplier = NormalizePeakRateConfig ( group . SubscriptionType , group . PeakRateEnabled , group . PeakStart , group . PeakEnd , group . PeakRateMultiplier )
if err := ValidatePeakRateConfig ( group . SubscriptionType , group . PeakRateEnabled , group . PeakStart , group . PeakEnd , group . PeakRateMultiplier ); err != nil {
2026-09-10 21:05:47 +08:00
return nil , infraerrors . BadRequest ( "INVALID_PEAK_RATE_CONFIG" , err . Error ())
2026-07-07 23:35:10 +08:00
}
2026-07-26 14:18:13 -04:00
if input . ProfitControlEnabled != nil {
group . ProfitControlEnabled = * input . ProfitControlEnabled
}
if input . ProfitMinMargin != nil {
group . ProfitMinMargin = * input . ProfitMinMargin
}
if input . ProfitSafetyBuffer != nil {
group . ProfitSafetyBuffer = * input . ProfitSafetyBuffer
}
// 利润控制与高峰同一收口:按合并后的最终平台归一化(转到不支持平台时静默重置),
// 再对合并后的最终配置统一校验,防止部分字段更新拼出非法组合入库。
group . ProfitControlEnabled , group . ProfitMinMargin , group . ProfitSafetyBuffer = NormalizeProfitControlConfig ( group . Platform , group . ProfitControlEnabled , group . ProfitMinMargin , group . ProfitSafetyBuffer )
if err := ValidateProfitControlConfig ( group . Platform , group . ProfitControlEnabled , group . ProfitMinMargin , group . ProfitSafetyBuffer ); err != nil {
return nil , err
}
2026-07-07 23:35:10 +08:00
if input . ImagePrice1K != nil {
group . ImagePrice1K = normalizePrice ( input . ImagePrice1K )
}
if input . ImagePrice2K != nil {
group . ImagePrice2K = normalizePrice ( input . ImagePrice2K )
}
if input . ImagePrice4K != nil {
group . ImagePrice4K = normalizePrice ( input . ImagePrice4K )
}
2026-07-07 12:00:36 +08:00
if input . VideoPrice480P != nil {
group . VideoPrice480P = normalizePrice ( input . VideoPrice480P )
}
if input . VideoPrice720P != nil {
group . VideoPrice720P = normalizePrice ( input . VideoPrice720P )
}
if input . VideoPrice1080P != nil {
group . VideoPrice1080P = normalizePrice ( input . VideoPrice1080P )
}
2026-08-07 13:08:22 +08:00
// nil = leave unchanged; empty map = clear per-model prices.
if input . VideoModelPrices != nil {
group . VideoModelPrices = NormalizeVideoModelPrices ( input . VideoModelPrices )
}
2026-07-13 09:54:51 +08:00
if input . WebSearchPricePerCall != nil {
group . WebSearchPricePerCall = normalizePrice ( input . WebSearchPricePerCall )
}
2026-08-07 15:52:55 +08:00
if input . SearchPricePer1k != nil {
group . SearchPricePer1k = normalizePrice ( input . SearchPricePer1k )
}
2026-08-07 15:08:39 +08:00
if input . AudioRealtimePricePerMin != nil {
group . AudioRealtimePricePerMin = normalizePrice ( input . AudioRealtimePricePerMin )
}
if input . AudioTTSPricePerMillionChars != nil {
group . AudioTTSPricePerMillionChars = normalizePrice ( input . AudioTTSPricePerMillionChars )
}
if input . AudioSTTPricePerHour != nil {
group . AudioSTTPricePerHour = normalizePrice ( input . AudioSTTPricePerHour )
}
2026-07-07 23:35:10 +08:00
// Claude Code 客户端限制
if input . ClaudeCodeOnly != nil {
group . ClaudeCodeOnly = * input . ClaudeCodeOnly
}
if input . FallbackGroupID != nil {
// 校验降级分组
if * input . FallbackGroupID > 0 {
if err := s . validateFallbackGroup ( ctx , id , * input . FallbackGroupID ); err != nil {
return nil , err
}
group . FallbackGroupID = input . FallbackGroupID
} else {
// 传入 0 或负数表示清除降级分组
group . FallbackGroupID = nil
}
}
fallbackOnInvalidRequest := group . FallbackGroupIDOnInvalidRequest
if input . FallbackGroupIDOnInvalidRequest != nil {
if * input . FallbackGroupIDOnInvalidRequest > 0 {
fallbackOnInvalidRequest = input . FallbackGroupIDOnInvalidRequest
} else {
fallbackOnInvalidRequest = nil
}
}
if fallbackOnInvalidRequest != nil {
if err := s . validateFallbackGroupOnInvalidRequest ( ctx , id , group . Platform , group . SubscriptionType , * fallbackOnInvalidRequest ); err != nil {
return nil , err
}
}
group . FallbackGroupIDOnInvalidRequest = fallbackOnInvalidRequest
// 模型路由配置
if input . ModelRouting != nil {
group . ModelRouting = input . ModelRouting
}
if input . ModelRoutingEnabled != nil {
group . ModelRoutingEnabled = * input . ModelRoutingEnabled
}
if input . MCPXMLInject != nil {
group . MCPXMLInject = * input . MCPXMLInject
}
// 支持的模型系列(仅 antigravity 平台使用)
if input . SupportedModelScopes != nil {
group . SupportedModelScopes = * input . SupportedModelScopes
}
// OpenAI Messages 调度配置
if input . AllowMessagesDispatch != nil {
group . AllowMessagesDispatch = * input . AllowMessagesDispatch
}
2026-07-25 07:39:32 +08:00
if input . AllowLive != nil {
group . AllowLive = * input . AllowLive
}
2026-08-23 18:57:41 +09:00
if input . ForceOpenAIFast != nil {
group . ForceOpenAIFast = * input . ForceOpenAIFast
}
2026-09-01 04:31:33 +09:00
if input . FreeOpenAIFast != nil {
group . FreeOpenAIFast = * input . FreeOpenAIFast
}
2026-07-07 23:35:10 +08:00
if input . RequireOAuthOnly != nil {
group . RequireOAuthOnly = * input . RequireOAuthOnly
}
if input . RequirePrivacySet != nil {
group . RequirePrivacySet = * input . RequirePrivacySet
}
if input . DefaultMappedModel != nil {
group . DefaultMappedModel = * input . DefaultMappedModel
}
if input . MessagesDispatchModelConfig != nil {
group . MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig ( * input . MessagesDispatchModelConfig )
}
2026-09-05 15:58:26 +08:00
if input . ModelAllowlist != nil {
modelAllowlist , err := normalizeGroupModelAllowlist ( * input . ModelAllowlist )
if err != nil {
return nil , err
}
group . ModelAllowlist = modelAllowlist
2026-07-07 23:35:10 +08:00
}
2026-09-04 23:40:41 +08:00
if input . CodexModelsManifestConfig != nil {
group . CodexModelsManifestConfig = * input . CodexModelsManifestConfig
}
2026-07-07 23:35:10 +08:00
if input . RPMLimit != nil {
group . RPMLimit = * input . RPMLimit
}
2026-07-21 10:22:28 +08:00
if input . MaxReasoningEffort != nil {
maxReasoningEffort , err := normalizeMaxReasoningEffortForPlatform ( group . Platform , * input . MaxReasoningEffort )
if err != nil {
return nil , infraerrors . Newf ( http . StatusBadRequest , "INVALID_MAX_REASONING_EFFORT" , "%v" , err )
}
group . MaxReasoningEffort = maxReasoningEffort
}
2026-09-01 10:15:45 +08:00
if input . MaxReasoningEffortOverLimit != nil {
maxReasoningEffortOverLimit , err := normalizeMaxReasoningEffortOverLimitForPlatform ( group . Platform , * input . MaxReasoningEffortOverLimit )
if err != nil {
return nil , infraerrors . Newf ( http . StatusBadRequest , "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT" , "%v" , err )
}
group . MaxReasoningEffortOverLimit = maxReasoningEffortOverLimit
}
2026-07-21 10:22:28 +08:00
if input . ReasoningEffortMappings != nil {
reasoningEffortMappings , err := NormalizeReasoningEffortMappings ( group . Platform , * input . ReasoningEffortMappings )
if err != nil {
return nil , infraerrors . Newf ( http . StatusBadRequest , "INVALID_REASONING_EFFORT_MAPPING" , "%v" , err )
}
group . ReasoningEffortMappings = reasoningEffortMappings
}
2026-07-07 23:35:10 +08:00
sanitizeGroupMessagesDispatchFields ( group )
2026-08-23 18:57:41 +09:00
sanitizeGroupOpenAIFast ( group )
2026-08-19 04:40:32 +00:00
if group . Platform != PlatformOpenAI && group . Platform != PlatformComposite {
2026-07-25 07:39:32 +08:00
group . AllowLive = false
}
2026-07-21 10:22:28 +08:00
sanitizeGroupReasoningEffortPolicy ( group )
2026-09-04 23:40:41 +08:00
// 固定账号 manifest 配置:按最终平台归一化(切出 openai 平台时静默归零,
// 与 ForceOpenAIFast 同一收口);校验仅在本次显式携带配置时进行,
// 避免脏 ID 阻塞无关字段更新。
group . CodexModelsManifestConfig = normalizeCodexModelsManifestConfig ( group . Platform , group . CodexModelsManifestConfig )
if input . CodexModelsManifestConfig != nil {
if err := s . validateCodexModelsManifestConfig ( ctx , id , group . CodexModelsManifestConfig ); err != nil {
return nil , err
}
}
2026-07-07 23:35:10 +08:00
if err := s . groupRepo . Update ( ctx , group ); err != nil {
return nil , err
}
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByGroupID ( ctx , id )
}
2026-08-12 03:22:06 +00:00
// 平台变了就失效渠道缓存:该缓存持有 groupID → platform,而渠道定价 / 模型映射 /
// 模型白名单都按平台严格隔离。不失效的话,缓存最长 10 分钟仍按旧平台匹配,
// 期间定价查不到会静默回落到 LiteLLM 价格表、映射与白名单也不生效。
if group . Platform != previousPlatform && s . channelCacheInvalidator != nil {
s . channelCacheInvalidator . InvalidateCache ()
}
2026-07-07 23:35:10 +08:00
// 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号)
if len ( input . CopyAccountsFromGroupIDs ) > 0 {
// 去重源分组 IDs
seen := make ( map [ int64 ] struct {})
uniqueSourceGroupIDs := make ([] int64 , 0 , len ( input . CopyAccountsFromGroupIDs ))
for _ , srcGroupID := range input . CopyAccountsFromGroupIDs {
// 校验:源分组不能是自身
if srcGroupID == id {
return nil , fmt . Errorf ( "cannot copy accounts from self" )
}
// 去重
if _ , exists := seen [ srcGroupID ]; ! exists {
seen [ srcGroupID ] = struct {}{}
uniqueSourceGroupIDs = append ( uniqueSourceGroupIDs , srcGroupID )
}
}
// 校验源分组的平台是否与当前分组一致
for _ , srcGroupID := range uniqueSourceGroupIDs {
srcGroup , err := s . groupRepo . GetByIDLite ( ctx , srcGroupID )
if err != nil {
return nil , fmt . Errorf ( "source group %d not found: %w" , srcGroupID , err )
}
2026-06-30 22:05:49 +08:00
if ! canCopyAccountsFromGroupPlatform ( group . Platform , srcGroup . Platform ) {
2026-07-07 23:35:10 +08:00
return nil , fmt . Errorf ( "source group %d platform mismatch: expected %s, got %s" , srcGroupID , group . Platform , srcGroup . Platform )
}
}
// 获取所有源分组的账号(去重)
accountIDsToCopy , err := s . groupRepo . GetAccountIDsByGroupIDs ( ctx , uniqueSourceGroupIDs )
if err != nil {
return nil , fmt . Errorf ( "failed to get accounts from source groups: %w" , err )
}
// 先清空当前分组的所有账号绑定
if _ , err := s . groupRepo . DeleteAccountGroupsByGroupID ( ctx , id ); err != nil {
return nil , fmt . Errorf ( "failed to clear existing account bindings: %w" , err )
}
// require_oauth_only: 过滤掉 apikey 类型账号
2026-06-30 22:05:49 +08:00
if group . RequireOAuthOnly && groupSupportsOAuthOnlyFilter ( group . Platform ) && len ( accountIDsToCopy ) > 0 {
2026-07-07 23:35:10 +08:00
accounts , err := s . accountRepo . GetByIDs ( ctx , accountIDsToCopy )
if err != nil {
return nil , fmt . Errorf ( "failed to fetch accounts for oauth filter: %w" , err )
}
oauthIDs := make ( map [ int64 ] struct {}, len ( accounts ))
for _ , acc := range accounts {
if acc . Type != AccountTypeAPIKey {
oauthIDs [ acc . ID ] = struct {}{}
}
}
var filtered [] int64
for _ , aid := range accountIDsToCopy {
if _ , ok := oauthIDs [ aid ]; ok {
filtered = append ( filtered , aid )
}
}
accountIDsToCopy = filtered
}
// 再绑定源分组的账号
if len ( accountIDsToCopy ) > 0 {
if err := s . groupRepo . BindAccountsToGroup ( ctx , id , accountIDsToCopy ); err != nil {
return nil , fmt . Errorf ( "failed to bind accounts to group: %w" , err )
}
}
}
return group , nil
2026-08-13 07:49:08 +08:00
}
func normalizeGroupModelPricing ( platform string , pricing [] ChannelModelPricing ) ([] ChannelModelPricing , error ) {
out := make ([] ChannelModelPricing , len ( pricing ))
for i := range pricing {
out [ i ] = pricing [ i ]. Clone ()
out [ i ]. ID = 0
out [ i ]. ChannelID = 0
2026-08-17 19:13:03 +08:00
if out [ i ]. TimePricing != nil && len ( out [ i ]. TimePricing . Periods ) > 0 {
return nil , infraerrors . BadRequest (
"GROUP_MODEL_TIME_PRICING_UNSUPPORTED" ,
"group model pricing does not support time pricing" ,
)
}
2026-08-13 07:49:08 +08:00
if strings . TrimSpace ( out [ i ]. Platform ) == "" {
out [ i ]. Platform = platform
}
for j := range out [ i ]. Models {
out [ i ]. Models [ j ] = strings . TrimSpace ( out [ i ]. Models [ j ])
}
if len ( out [ i ]. Models ) == 0 {
return nil , infraerrors . New ( http . StatusBadRequest , "GROUP_MODEL_PRICING_MODELS_REQUIRED" , "group model pricing entry requires at least one model" )
}
}
if err := validatePricingEntries ( out ); err != nil {
return nil , err
}
return out , nil
2026-07-07 23:35:10 +08:00
}
func ( s * adminServiceImpl ) DeleteGroup ( ctx context . Context , id int64 ) error {
2026-09-05 09:29:22 +08:00
return s . deleteGroup ( ctx , id , false )
}
func ( s * adminServiceImpl ) DeleteGroupIfEmpty ( ctx context . Context , id int64 ) error {
return s . deleteGroup ( ctx , id , true )
}
func ( s * adminServiceImpl ) deleteGroup ( ctx context . Context , id int64 , requireEmpty bool ) error {
2026-09-07 09:20:48 +08:00
if s . cfg != nil && s . cfg . RunMode == config . RunModeSimple {
group , err := s . groupRepo . GetByIDLite ( ctx , id )
if err != nil {
return err
}
if err := s . validateSimpleModeGroupAccess ( group ); err != nil {
return err
}
}
2026-09-05 09:29:22 +08:00
if requireEmpty && s . emptyGroupDeleteRepo == nil {
return fmt . Errorf ( "guarded group deletion is unavailable" )
}
2026-07-07 23:35:10 +08:00
var groupKeys [] string
if s . authCacheInvalidator != nil {
keys , err := s . apiKeyRepo . ListKeysByGroupID ( ctx , id )
if err == nil {
groupKeys = keys
}
}
2026-09-05 09:29:22 +08:00
var affectedUserIDs [] int64
var err error
if requireEmpty {
affectedUserIDs , err = s . emptyGroupDeleteRepo . DeleteCascadeIfEmpty ( ctx , id )
} else {
affectedUserIDs , err = s . groupRepo . DeleteCascade ( ctx , id )
}
2026-07-07 23:35:10 +08:00
if err != nil {
return err
}
// 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理
// 事务成功后,异步失效受影响用户的订阅缓存
if len ( affectedUserIDs ) > 0 && s . billingCacheService != nil {
groupID := id
go func () {
cacheCtx , cancel := context . WithTimeout ( context . Background (), 30 * time . Second )
defer cancel ()
for _ , userID := range affectedUserIDs {
if err := s . billingCacheService . InvalidateSubscription ( cacheCtx , userID , groupID ); err != nil {
logger . LegacyPrintf ( "service.admin" , "invalidate subscription cache failed: user_id=%d group_id=%d err=%v" , userID , groupID , err )
}
}
}()
}
if s . authCacheInvalidator != nil {
for _ , key := range groupKeys {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , key )
}
}
return nil
}
func ( s * adminServiceImpl ) GetGroupAPIKeys ( ctx context . Context , groupID int64 , page , pageSize int ) ([] APIKey , int64 , error ) {
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
keys , result , err := s . apiKeyRepo . ListByGroupID ( ctx , groupID , params )
if err != nil {
return nil , 0 , err
}
return keys , result . Total , nil
}
func ( s * adminServiceImpl ) GetGroupRateMultipliers ( ctx context . Context , groupID int64 ) ([] UserGroupRateEntry , error ) {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationMultiplier ); err != nil {
return nil , err
}
2026-07-07 23:35:10 +08:00
if s . userGroupRateRepo == nil {
return nil , nil
}
return s . userGroupRateRepo . GetByGroupID ( ctx , groupID )
}
func ( s * adminServiceImpl ) ClearGroupRateMultipliers ( ctx context . Context , groupID int64 ) error {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationMultiplier ); err != nil {
return err
}
2026-07-07 23:35:10 +08:00
if s . userGroupRateRepo == nil {
return nil
}
return s . userGroupRateRepo . DeleteByGroupID ( ctx , groupID )
}
func ( s * adminServiceImpl ) BatchSetGroupRateMultipliers ( ctx context . Context , groupID int64 , entries [] GroupRateMultiplierInput ) error {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationMultiplier ); err != nil {
return err
}
2026-07-07 23:35:10 +08:00
if s . userGroupRateRepo == nil {
return nil
}
for _ , e := range entries {
if e . RateMultiplier <= 0 {
return fmt . Errorf ( "rate_multiplier must be > 0 (user_id=%d)" , e . UserID )
}
}
return s . userGroupRateRepo . SyncGroupRateMultipliers ( ctx , groupID , entries )
}
func ( s * adminServiceImpl ) ClearGroupRPMOverrides ( ctx context . Context , groupID int64 ) error {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationRPMOverride ); err != nil {
return err
}
2026-07-07 23:35:10 +08:00
if s . userGroupRateRepo == nil {
return nil
}
if err := s . userGroupRateRepo . ClearGroupRPMOverrides ( ctx , groupID ); err != nil {
return err
}
// RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByGroupID ( ctx , groupID )
}
return nil
}
func ( s * adminServiceImpl ) BatchSetGroupRPMOverrides ( ctx context . Context , groupID int64 , entries [] GroupRPMOverrideInput ) error {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationRPMOverride ); err != nil {
return err
}
2026-07-07 23:35:10 +08:00
if s . userGroupRateRepo == nil {
return nil
}
for _ , e := range entries {
if e . RPMOverride != nil && * e . RPMOverride < 0 {
return infraerrors . BadRequest ( "INVALID_RPM_OVERRIDE" , fmt . Sprintf ( "rpm_override must be >= 0 (user_id=%d)" , e . UserID ))
}
}
if err := s . userGroupRateRepo . SyncGroupRPMOverrides ( ctx , groupID , entries ); err != nil {
return err
}
// RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByGroupID ( ctx , groupID )
}
return nil
}
func ( s * adminServiceImpl ) UpdateGroupSortOrders ( ctx context . Context , updates [] GroupSortOrderUpdate ) error {
2026-09-07 09:20:48 +08:00
if err := s . ValidateSimpleModeGroupOperation ( AdminGroupOperationSort ); err != nil {
return err
}
2026-07-07 23:35:10 +08:00
return s . groupRepo . UpdateSortOrders ( ctx , updates )
}
// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定
// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组
func ( s * adminServiceImpl ) AdminUpdateAPIKeyGroupID ( ctx context . Context , keyID int64 , groupID * int64 ) ( * AdminUpdateAPIKeyGroupIDResult , error ) {
apiKey , err := s . apiKeyRepo . GetByID ( ctx , keyID )
if err != nil {
return nil , err
}
if groupID == nil {
// nil 表示不修改,直接返回
return & AdminUpdateAPIKeyGroupIDResult { APIKey : apiKey }, nil
}
if * groupID < 0 {
return nil , infraerrors . BadRequest ( "INVALID_GROUP_ID" , "group_id must be non-negative" )
}
result := & AdminUpdateAPIKeyGroupIDResult {}
if * groupID == 0 {
// 0 表示解绑分组(不修改 user_allowed_groups,避免影响用户其他 Key)
apiKey . GroupID = nil
apiKey . Group = nil
} else {
// 验证目标分组存在且状态为 active
group , err := s . groupRepo . GetByID ( ctx , * groupID )
if err != nil {
return nil , err
}
if group . Status != StatusActive {
return nil , infraerrors . BadRequest ( "GROUP_NOT_ACTIVE" , "target group is not active" )
}
// 订阅类型分组:用户须持有该分组的有效订阅才可绑定
if group . IsSubscriptionType () {
if s . userSubRepo == nil {
return nil , infraerrors . InternalServer ( "SUBSCRIPTION_REPOSITORY_UNAVAILABLE" , "subscription repository is not configured" )
}
if _ , err := s . userSubRepo . GetActiveByUserIDAndGroupID ( ctx , apiKey . UserID , * groupID ); err != nil {
if errors . Is ( err , ErrSubscriptionNotFound ) {
return nil , infraerrors . BadRequest ( "SUBSCRIPTION_REQUIRED" , "user does not have an active subscription for this group" )
}
return nil , err
}
}
gid := * groupID
apiKey . GroupID = & gid
apiKey . Group = group
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
if group . IsExclusive && ! group . IsSubscriptionType () {
opCtx := ctx
var tx * dbent . Tx
if s . entClient == nil {
logger . LegacyPrintf ( "service.admin" , "Warning: entClient is nil, skipping transaction protection for exclusive group binding" )
} else {
var txErr error
tx , txErr = s . entClient . Tx ( ctx )
if txErr != nil {
return nil , fmt . Errorf ( "begin transaction: %w" , txErr )
}
defer func () { _ = tx . Rollback () }()
opCtx = dbent . NewTxContext ( ctx , tx )
}
if addErr := s . userRepo . AddGroupToAllowedGroups ( opCtx , apiKey . UserID , gid ); addErr != nil {
return nil , fmt . Errorf ( "add group to user allowed groups: %w" , addErr )
}
2026-07-28 17:21:32 +08:00
if err := s . apiKeyRepo . Update ( opCtx , apiKey , APIKeyUpdateFields { GroupID : true }); err != nil {
2026-07-07 23:35:10 +08:00
return nil , fmt . Errorf ( "update api key: %w" , err )
}
if tx != nil {
if err := tx . Commit (); err != nil {
return nil , fmt . Errorf ( "commit transaction: %w" , err )
}
}
result . AutoGrantedGroupAccess = true
result . GrantedGroupID = & gid
result . GrantedGroupName = group . Name
// 失效认证缓存(在事务提交后执行)
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , apiKey . Key )
}
result . APIKey = apiKey
return result , nil
}
}
// 非专属分组 / 解绑:无需事务,单步更新即可
2026-07-28 17:21:32 +08:00
if err := s . apiKeyRepo . Update ( ctx , apiKey , APIKeyUpdateFields { GroupID : true }); err != nil {
2026-07-07 23:35:10 +08:00
return nil , fmt . Errorf ( "update api key: %w" , err )
}
// 失效认证缓存
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , apiKey . Key )
}
result . APIKey = apiKey
return result , nil
}
// AdminResetAPIKeyRateLimitUsage resets all API key rate-limit usage windows.
func ( s * adminServiceImpl ) AdminResetAPIKeyRateLimitUsage ( ctx context . Context , keyID int64 ) ( * APIKey , error ) {
apiKey , err := s . apiKeyRepo . GetByID ( ctx , keyID )
if err != nil {
return nil , err
}
apiKey . Usage5h = 0
apiKey . Usage1d = 0
apiKey . Usage7d = 0
apiKey . Window5hStart = nil
apiKey . Window1dStart = nil
apiKey . Window7dStart = nil
2026-07-28 17:21:32 +08:00
if err := s . apiKeyRepo . Update ( ctx , apiKey , APIKeyUpdateFields { RateLimitUsage : true }); err != nil {
2026-07-07 23:35:10 +08:00
return nil , fmt . Errorf ( "reset api key rate limit usage: %w" , err )
}
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , apiKey . Key )
}
if s . billingCacheService != nil {
_ = s . billingCacheService . InvalidateAPIKeyRateLimit ( ctx , apiKey . ID )
}
return apiKey , nil
}
// ReplaceUserGroup 替换用户的专属分组
func ( s * adminServiceImpl ) ReplaceUserGroup ( ctx context . Context , userID , oldGroupID , newGroupID int64 ) ( * ReplaceUserGroupResult , error ) {
if oldGroupID == newGroupID {
return nil , infraerrors . BadRequest ( "SAME_GROUP" , "old and new group must be different" )
}
// 验证新分组存在且为活跃的专属标准分组
newGroup , err := s . groupRepo . GetByID ( ctx , newGroupID )
if err != nil {
return nil , err
}
if newGroup . Status != StatusActive {
return nil , infraerrors . BadRequest ( "GROUP_NOT_ACTIVE" , "target group is not active" )
}
if ! newGroup . IsExclusive {
return nil , infraerrors . BadRequest ( "GROUP_NOT_EXCLUSIVE" , "target group is not exclusive" )
}
if newGroup . IsSubscriptionType () {
return nil , infraerrors . BadRequest ( "GROUP_IS_SUBSCRIPTION" , "subscription groups are not supported for replacement" )
}
// 事务保证原子性
if s . entClient == nil {
return nil , fmt . Errorf ( "entClient is nil, cannot perform group replacement" )
}
tx , err := s . entClient . Tx ( ctx )
if err != nil {
return nil , fmt . Errorf ( "begin transaction: %w" , err )
}
defer func () { _ = tx . Rollback () }()
opCtx := dbent . NewTxContext ( ctx , tx )
// 1. 授予新分组权限
if err := s . userRepo . AddGroupToAllowedGroups ( opCtx , userID , newGroupID ); err != nil {
return nil , fmt . Errorf ( "add new group to allowed groups: %w" , err )
}
// 2. 迁移绑定旧分组的 Key 到新分组
migrated , err := s . apiKeyRepo . UpdateGroupIDByUserAndGroup ( opCtx , userID , oldGroupID , newGroupID )
if err != nil {
return nil , fmt . Errorf ( "migrate api keys: %w" , err )
}
// 3. 移除旧分组权限
if err := s . userRepo . RemoveGroupFromUserAllowedGroups ( opCtx , userID , oldGroupID ); err != nil {
return nil , fmt . Errorf ( "remove old group from allowed groups: %w" , err )
}
if err := tx . Commit (); err != nil {
return nil , fmt . Errorf ( "commit transaction: %w" , err )
}
// 失效该用户所有 Key 的认证缓存
if s . authCacheInvalidator != nil {
keys , keyErr := s . apiKeyRepo . ListKeysByUserID ( ctx , userID )
if keyErr == nil {
for _ , k := range keys {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , k )
}
}
}
return & ReplaceUserGroupResult { MigratedKeys : migrated }, nil
}