| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200 |
- package repository
- import (
- "context"
- "database/sql"
- "encoding/json"
- "errors"
- "fmt"
- "sort"
- "strings"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/group"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/lib/pq"
- entsql "entgo.io/ent/dialect/sql"
- )
- type sqlExecutor interface {
- ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
- QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
- }
- type groupRepository struct {
- client *dbent.Client
- sql sqlExecutor
- }
- // lockLiveGroups makes account-group inserts participate in the same row-lock
- // protocol as guarded group deletion. FOR SHARE conflicts with the deleter's
- // FOR UPDATE lock, and READ COMMITTED rechecks deleted_at after any wait.
- func lockLiveGroups(ctx context.Context, exec sqlExecutor, groupIDs []int64) error {
- if len(groupIDs) == 0 {
- return nil
- }
- unique := make(map[int64]struct{}, len(groupIDs))
- for _, id := range groupIDs {
- unique[id] = struct{}{}
- }
- rows, err := exec.QueryContext(ctx, `/* account_group_live_group_lock */
- SELECT id FROM groups
- WHERE id = ANY($1) AND deleted_at IS NULL
- ORDER BY id
- FOR SHARE`, pq.Array(groupIDs))
- if err != nil {
- return err
- }
- defer func() { _ = rows.Close() }()
- locked := 0
- for rows.Next() {
- locked++
- }
- if err := rows.Err(); err != nil {
- return err
- }
- if locked != len(unique) {
- return service.ErrGroupNotFound
- }
- return nil
- }
- func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupRepository {
- 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 {
- 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")
- }
- modelPricing, err := json.Marshal(groupIn.ModelPricing)
- if err != nil {
- return fmt.Errorf("marshal group model pricing: %w", err)
- }
- builder := client.Group.Create().
- SetName(groupIn.Name).
- SetDescription(groupIn.Description).
- SetPlatform(groupIn.Platform).
- SetRateMultiplier(groupIn.RateMultiplier).
- SetSortOrder(groupIn.SortOrder).
- SetIsExclusive(groupIn.IsExclusive).
- SetSecurityPolicyEnabled(groupIn.SecurityPolicyEnabled).
- SetSecurityPolicyMode(service.NormalizeSecurityPolicyMode(groupIn.SecurityPolicyMode)).
- SetSecurityPolicyEmailEnabled(groupIn.SecurityPolicyEmailEnabled).
- SetStatus(groupIn.Status).
- SetSubscriptionType(groupIn.SubscriptionType).
- SetNillableDailyLimitUsd(groupIn.DailyLimitUSD).
- SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD).
- SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD).
- SetAllowImageGeneration(groupIn.AllowImageGeneration).
- SetAllowBatchImageGeneration(groupIn.AllowBatchImageGeneration).
- SetImageRateIndependent(groupIn.ImageRateIndependent).
- SetImageRateMultiplier(groupIn.ImageRateMultiplier).
- SetNillableImagePrice1k(groupIn.ImagePrice1K).
- SetNillableImagePrice2k(groupIn.ImagePrice2K).
- SetNillableImagePrice4k(groupIn.ImagePrice4K).
- SetBatchImageDiscountMultiplier(groupIn.BatchImageDiscountMultiplier).
- SetBatchImageHoldMultiplier(groupIn.BatchImageHoldMultiplier).
- SetVideoRateIndependent(groupIn.VideoRateIndependent).
- SetVideoRateMultiplier(groupIn.VideoRateMultiplier).
- SetNillableVideoPrice480p(groupIn.VideoPrice480P).
- SetNillableVideoPrice720p(groupIn.VideoPrice720P).
- SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
- SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
- SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall).
- SetNillableSearchPricePer1k(groupIn.SearchPricePer1k).
- SetNillableAudioRealtimePricePerMin(groupIn.AudioRealtimePricePerMin).
- SetNillableAudioTtsPricePerMillionChars(groupIn.AudioTTSPricePerMillionChars).
- SetNillableAudioSttPricePerHour(groupIn.AudioSTTPricePerHour).
- SetLongContextPricingEnabled(groupIn.LongContextPricingEnabled).
- SetModelPricing(modelPricing).
- SetDefaultValidityDays(groupIn.DefaultValidityDays).
- SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
- SetNillableFallbackGroupID(groupIn.FallbackGroupID).
- SetNillableFallbackGroupIDOnInvalidRequest(groupIn.FallbackGroupIDOnInvalidRequest).
- SetModelRoutingEnabled(groupIn.ModelRoutingEnabled).
- SetMcpXMLInject(groupIn.MCPXMLInject).
- SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
- SetAllowLive(groupIn.AllowLive).
- SetForceOpenaiFast(groupIn.ForceOpenAIFast).
- SetFreeOpenaiFast(groupIn.FreeOpenAIFast).
- SetRequireOauthOnly(groupIn.RequireOAuthOnly).
- SetRequirePrivacySet(groupIn.RequirePrivacySet).
- SetDefaultMappedModel(groupIn.DefaultMappedModel).
- SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
- SetModelAllowlist(service.DomainGroupModelAllowlist(groupIn.ModelAllowlist)).
- SetCodexModelsManifestConfig(groupIn.CodexModelsManifestConfig).
- SetRpmLimit(groupIn.RPMLimit).
- SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
- SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
- SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
- SetPeakRateEnabled(groupIn.PeakRateEnabled).
- SetPeakStart(groupIn.PeakStart).
- SetPeakEnd(groupIn.PeakEnd).
- SetPeakRateMultiplier(groupIn.PeakRateMultiplier).
- SetProfitControlEnabled(groupIn.ProfitControlEnabled).
- SetProfitMinMargin(groupIn.ProfitMinMargin).
- SetProfitSafetyBuffer(groupIn.ProfitSafetyBuffer)
- if groupIn.DuplicateOperationID != "" {
- builder = builder.SetDuplicateOperationID(groupIn.DuplicateOperationID)
- }
- // 设置模型路由配置
- if groupIn.ModelRouting != nil {
- builder = builder.SetModelRouting(groupIn.ModelRouting)
- }
- // 设置支持的模型系列(始终设置,空数组表示不限制)
- 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 {
- 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 nil
- }
- func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group, error) {
- out, err := r.GetByIDLite(ctx, id)
- if err != nil {
- return nil, err
- }
- counts, err := r.loadAccountCounts(ctx, []int64{out.ID})
- if err == nil {
- c := counts[out.ID]
- out.AccountCount = c.Total
- out.ActiveAccountCount = c.Active
- out.RateLimitedAccountCount = c.RateLimited
- }
- return out, nil
- }
- func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) {
- // AccountCount is intentionally not loaded here; use GetByID when needed.
- m, err := r.client.Group.Query().
- Where(group.IDEQ(id)).
- Only(ctx)
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil)
- }
- return groupEntityToService(m), nil
- }
- func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) error {
- modelPricing, err := json.Marshal(groupIn.ModelPricing)
- if err != nil {
- return fmt.Errorf("marshal group model pricing: %w", err)
- }
- builder := r.client.Group.UpdateOneID(groupIn.ID).
- SetName(groupIn.Name).
- SetDescription(groupIn.Description).
- SetPlatform(groupIn.Platform).
- SetRateMultiplier(groupIn.RateMultiplier).
- SetIsExclusive(groupIn.IsExclusive).
- SetSecurityPolicyEnabled(groupIn.SecurityPolicyEnabled).
- SetSecurityPolicyMode(service.NormalizeSecurityPolicyMode(groupIn.SecurityPolicyMode)).
- SetSecurityPolicyEmailEnabled(groupIn.SecurityPolicyEmailEnabled).
- SetStatus(groupIn.Status).
- SetSubscriptionType(groupIn.SubscriptionType).
- SetNillableDailyLimitUsd(groupIn.DailyLimitUSD).
- SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD).
- SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD).
- SetAllowImageGeneration(groupIn.AllowImageGeneration).
- SetAllowBatchImageGeneration(groupIn.AllowBatchImageGeneration).
- SetImageRateIndependent(groupIn.ImageRateIndependent).
- SetImageRateMultiplier(groupIn.ImageRateMultiplier).
- SetNillableImagePrice1k(groupIn.ImagePrice1K).
- SetNillableImagePrice2k(groupIn.ImagePrice2K).
- SetNillableImagePrice4k(groupIn.ImagePrice4K).
- SetBatchImageDiscountMultiplier(groupIn.BatchImageDiscountMultiplier).
- SetBatchImageHoldMultiplier(groupIn.BatchImageHoldMultiplier).
- SetVideoRateIndependent(groupIn.VideoRateIndependent).
- SetVideoRateMultiplier(groupIn.VideoRateMultiplier).
- SetNillableVideoPrice480p(groupIn.VideoPrice480P).
- SetNillableVideoPrice720p(groupIn.VideoPrice720P).
- SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
- SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
- SetLongContextPricingEnabled(groupIn.LongContextPricingEnabled).
- SetModelPricing(modelPricing).
- SetDefaultValidityDays(groupIn.DefaultValidityDays).
- SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
- SetModelRoutingEnabled(groupIn.ModelRoutingEnabled).
- SetMcpXMLInject(groupIn.MCPXMLInject).
- SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
- SetAllowLive(groupIn.AllowLive).
- SetForceOpenaiFast(groupIn.ForceOpenAIFast).
- SetFreeOpenaiFast(groupIn.FreeOpenAIFast).
- SetRequireOauthOnly(groupIn.RequireOAuthOnly).
- SetRequirePrivacySet(groupIn.RequirePrivacySet).
- SetDefaultMappedModel(groupIn.DefaultMappedModel).
- SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
- SetModelAllowlist(service.DomainGroupModelAllowlist(groupIn.ModelAllowlist)).
- SetCodexModelsManifestConfig(groupIn.CodexModelsManifestConfig).
- SetRpmLimit(groupIn.RPMLimit).
- SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
- SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
- SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
- SetPeakRateEnabled(groupIn.PeakRateEnabled).
- SetPeakStart(groupIn.PeakStart).
- SetPeakEnd(groupIn.PeakEnd).
- SetPeakRateMultiplier(groupIn.PeakRateMultiplier).
- SetProfitControlEnabled(groupIn.ProfitControlEnabled).
- SetProfitMinMargin(groupIn.ProfitMinMargin).
- SetProfitSafetyBuffer(groupIn.ProfitSafetyBuffer)
- // 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
- if groupIn.DailyLimitUSD != nil {
- builder = builder.SetDailyLimitUsd(*groupIn.DailyLimitUSD)
- } else {
- builder = builder.ClearDailyLimitUsd()
- }
- if groupIn.WeeklyLimitUSD != nil {
- builder = builder.SetWeeklyLimitUsd(*groupIn.WeeklyLimitUSD)
- } else {
- builder = builder.ClearWeeklyLimitUsd()
- }
- if groupIn.MonthlyLimitUSD != nil {
- builder = builder.SetMonthlyLimitUsd(*groupIn.MonthlyLimitUSD)
- } else {
- builder = builder.ClearMonthlyLimitUsd()
- }
- if groupIn.ImagePrice1K != nil {
- builder = builder.SetImagePrice1k(*groupIn.ImagePrice1K)
- } else {
- builder = builder.ClearImagePrice1k()
- }
- if groupIn.ImagePrice2K != nil {
- builder = builder.SetImagePrice2k(*groupIn.ImagePrice2K)
- } else {
- builder = builder.ClearImagePrice2k()
- }
- if groupIn.ImagePrice4K != nil {
- builder = builder.SetImagePrice4k(*groupIn.ImagePrice4K)
- } else {
- builder = builder.ClearImagePrice4k()
- }
- if groupIn.VideoPrice480P != nil {
- builder = builder.SetVideoPrice480p(*groupIn.VideoPrice480P)
- } else {
- builder = builder.ClearVideoPrice480p()
- }
- if groupIn.VideoPrice720P != nil {
- builder = builder.SetVideoPrice720p(*groupIn.VideoPrice720P)
- } else {
- builder = builder.ClearVideoPrice720p()
- }
- if groupIn.VideoPrice1080P != nil {
- builder = builder.SetVideoPrice1080p(*groupIn.VideoPrice1080P)
- } else {
- builder = builder.ClearVideoPrice1080p()
- }
- if groupIn.WebSearchPricePerCall != nil {
- builder = builder.SetWebSearchPricePerCall(*groupIn.WebSearchPricePerCall)
- } else {
- builder = builder.ClearWebSearchPricePerCall()
- }
- if groupIn.SearchPricePer1k != nil {
- builder = builder.SetSearchPricePer1k(*groupIn.SearchPricePer1k)
- } else {
- builder = builder.ClearSearchPricePer1k()
- }
- if groupIn.AudioRealtimePricePerMin != nil {
- builder = builder.SetAudioRealtimePricePerMin(*groupIn.AudioRealtimePricePerMin)
- } else {
- builder = builder.ClearAudioRealtimePricePerMin()
- }
- if groupIn.AudioTTSPricePerMillionChars != nil {
- builder = builder.SetAudioTtsPricePerMillionChars(*groupIn.AudioTTSPricePerMillionChars)
- } else {
- builder = builder.ClearAudioTtsPricePerMillionChars()
- }
- if groupIn.AudioSTTPricePerHour != nil {
- builder = builder.SetAudioSttPricePerHour(*groupIn.AudioSTTPricePerHour)
- } else {
- builder = builder.ClearAudioSttPricePerHour()
- }
- // 处理 FallbackGroupID:nil 时清除,否则设置
- if groupIn.FallbackGroupID != nil {
- builder = builder.SetFallbackGroupID(*groupIn.FallbackGroupID)
- } else {
- builder = builder.ClearFallbackGroupID()
- }
- // 处理 FallbackGroupIDOnInvalidRequest:nil 时清除,否则设置
- if groupIn.FallbackGroupIDOnInvalidRequest != nil {
- builder = builder.SetFallbackGroupIDOnInvalidRequest(*groupIn.FallbackGroupIDOnInvalidRequest)
- } else {
- builder = builder.ClearFallbackGroupIDOnInvalidRequest()
- }
- // 处理 ModelRouting:nil 时清除,否则设置
- if groupIn.ModelRouting != nil {
- builder = builder.SetModelRouting(groupIn.ModelRouting)
- } else {
- builder = builder.ClearModelRouting()
- }
- // 处理 SupportedModelScopes(始终设置,空数组表示不限制)
- builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes)
- updated, err := builder.Save(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists)
- }
- groupIn.UpdatedAt = updated.UpdatedAt
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
- logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group update failed: group=%d err=%v", groupIn.ID, err)
- }
- return nil
- }
- func (r *groupRepository) Delete(ctx context.Context, id int64) error {
- _, err := r.client.Group.Delete().Where(group.IDEQ(id)).Exec(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrGroupNotFound, nil)
- }
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &id, nil); err != nil {
- logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group delete failed: group=%d err=%v", id, err)
- }
- return nil
- }
- func (r *groupRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) {
- return r.ListWithFilters(ctx, params, "", "", "", nil)
- }
- func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) {
- q := r.client.Group.Query()
- return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive)
- }
- func (r *groupRepository) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) {
- q := r.client.Group.Query().Where(group.PlatformNEQ(service.PlatformComposite))
- return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive)
- }
- func (r *groupRepository) listWithFiltersQuery(ctx context.Context, q *dbent.GroupQuery, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) {
- if platform != "" {
- q = q.Where(group.PlatformEQ(platform))
- }
- if status != "" {
- q = q.Where(group.StatusEQ(status))
- }
- if search != "" {
- q = q.Where(group.Or(
- group.NameContainsFold(search),
- group.DescriptionContainsFold(search),
- ))
- }
- if isExclusive != nil {
- q = q.Where(group.IsExclusiveEQ(*isExclusive))
- }
- total, err := q.Clone().Count(ctx)
- if err != nil {
- return nil, nil, err
- }
- if strings.EqualFold(strings.TrimSpace(params.SortBy), "account_count") {
- return r.listWithAccountCountSort(ctx, q, params, total)
- }
- groupsQuery := q.
- Offset(params.Offset()).
- Limit(params.Limit())
- for _, order := range groupListOrder(params) {
- groupsQuery = groupsQuery.Order(order)
- }
- groups, err := groupsQuery.All(ctx)
- if err != nil {
- return nil, nil, err
- }
- groupIDs := make([]int64, 0, len(groups))
- outGroups := make([]service.Group, 0, len(groups))
- for i := range groups {
- g := groupEntityToService(groups[i])
- outGroups = append(outGroups, *g)
- groupIDs = append(groupIDs, g.ID)
- }
- counts, err := r.loadAccountCounts(ctx, groupIDs)
- if err == nil {
- for i := range outGroups {
- c := counts[outGroups[i].ID]
- outGroups[i].AccountCount = c.Total
- outGroups[i].ActiveAccountCount = c.Active
- outGroups[i].RateLimitedAccountCount = c.RateLimited
- }
- }
- return outGroups, paginationResultFromTotal(int64(total), params), nil
- }
- func (r *groupRepository) listWithAccountCountSort(ctx context.Context, q *dbent.GroupQuery, params pagination.PaginationParams, total int) ([]service.Group, *pagination.PaginationResult, error) {
- // 第一步:只查 ID + sort_order(轻量,不做分页 — 需要全量排序 account_count)。
- rows, err := q.Clone().
- Select(group.FieldID, group.FieldSortOrder).
- Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)).
- All(ctx)
- if err != nil {
- return nil, nil, err
- }
- type sortEntry struct {
- id int64
- sortOrder int
- accountCount int64
- }
- entries := make([]sortEntry, 0, len(rows))
- groupIDs := make([]int64, len(rows))
- for i, r := range rows {
- groupIDs[i] = r.ID
- entries = append(entries, sortEntry{id: r.ID, sortOrder: r.SortOrder})
- }
- // 第二步:批量加载 account counts(一次 SQL)。
- counts, err := r.loadAccountCounts(ctx, groupIDs)
- if err != nil {
- return nil, nil, err
- }
- for i := range entries {
- c := counts[entries[i].id]
- if c.Total > 0 {
- entries[i].accountCount = c.Total
- }
- }
- // 第三步:Go 侧排序(数据量 = Group 总数,通常 < 200,安全)。
- sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
- tieCmp := func(a, b sortEntry) bool {
- if a.sortOrder == b.sortOrder {
- return a.id < b.id
- }
- return a.sortOrder < b.sortOrder
- }
- sort.SliceStable(entries, func(i, j int) bool {
- if entries[i].accountCount == entries[j].accountCount {
- return tieCmp(entries[i], entries[j])
- }
- if sortOrder == pagination.SortOrderAsc {
- return entries[i].accountCount < entries[j].accountCount
- }
- return entries[i].accountCount > entries[j].accountCount
- })
- // 第四步:分页,只加载当前页需要的完整 Group。
- page := paginateSlice(entries, params)
- if len(page) == 0 {
- return nil, paginationResultFromTotal(int64(total), params), nil
- }
- pageIDs := make([]int64, len(page))
- pageIdx := make(map[int64]int, len(page))
- for i, e := range page {
- pageIDs[i] = e.id
- pageIdx[e.id] = i
- }
- groups, err := r.client.Group.Query().
- Where(group.IDIn(pageIDs...)).
- All(ctx)
- if err != nil {
- return nil, nil, err
- }
- outGroups := make([]service.Group, len(page))
- for i := range groups {
- g := groupEntityToService(groups[i])
- c := counts[g.ID]
- g.AccountCount = c.Total
- g.ActiveAccountCount = c.Active
- g.RateLimitedAccountCount = c.RateLimited
- if idx, ok := pageIdx[g.ID]; ok {
- outGroups[idx] = *g
- }
- }
- return outGroups, paginationResultFromTotal(int64(total), params), nil
- }
- func groupListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
- sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
- sortOrder := params.NormalizedSortOrder(pagination.SortOrderAsc)
- var field string
- tieField := group.FieldID
- defaultOrder := true
- switch sortBy {
- case "", "sort_order":
- field = group.FieldSortOrder
- case "name":
- field = group.FieldName
- defaultOrder = false
- case "platform":
- field = group.FieldPlatform
- defaultOrder = false
- case "billing_type", "subscription_type":
- field = group.FieldSubscriptionType
- defaultOrder = false
- case "rate_multiplier":
- field = group.FieldRateMultiplier
- defaultOrder = false
- case "is_exclusive":
- field = group.FieldIsExclusive
- defaultOrder = false
- case "status":
- field = group.FieldStatus
- defaultOrder = false
- case "created_at":
- field = group.FieldCreatedAt
- defaultOrder = false
- case "id":
- field = group.FieldID
- defaultOrder = false
- tieField = ""
- default:
- field = group.FieldSortOrder
- }
- if sortOrder == pagination.SortOrderDesc && sortBy != "" {
- if tieField == "" {
- return []func(*entsql.Selector){dbent.Desc(field)}
- }
- return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(tieField)}
- }
- if defaultOrder {
- return []func(*entsql.Selector){dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)}
- }
- if tieField == "" {
- return []func(*entsql.Selector){dbent.Asc(field)}
- }
- return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(tieField)}
- }
- func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, error) {
- groups, err := r.client.Group.Query().
- Where(group.StatusEQ(service.StatusActive)).
- Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- groupIDs := make([]int64, 0, len(groups))
- outGroups := make([]service.Group, 0, len(groups))
- for i := range groups {
- g := groupEntityToService(groups[i])
- outGroups = append(outGroups, *g)
- groupIDs = append(groupIDs, g.ID)
- }
- counts, err := r.loadAccountCounts(ctx, groupIDs)
- if err == nil {
- for i := range outGroups {
- c := counts[outGroups[i].ID]
- outGroups[i].AccountCount = c.Total
- outGroups[i].ActiveAccountCount = c.Active
- outGroups[i].RateLimitedAccountCount = c.RateLimited
- }
- }
- return outGroups, nil
- }
- func (r *groupRepository) ListActiveIDs(ctx context.Context) ([]int64, error) {
- if r.sql != nil {
- rows, err := r.sql.QueryContext(ctx, `
- SELECT id
- FROM groups
- WHERE status = $1
- AND deleted_at IS NULL
- ORDER BY sort_order ASC, id ASC
- `, service.StatusActive)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- ids := make([]int64, 0)
- for rows.Next() {
- var id int64
- if err := rows.Scan(&id); err != nil {
- return nil, err
- }
- ids = append(ids, id)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return ids, nil
- }
- groups, err := r.client.Group.Query().
- Where(group.StatusEQ(service.StatusActive)).
- Select(group.FieldID).
- Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- ids := make([]int64, 0, len(groups))
- for i := range groups {
- ids = append(ids, groups[i].ID)
- }
- return ids, nil
- }
- func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform string) ([]service.Group, error) {
- groups, err := r.client.Group.Query().
- Where(group.StatusEQ(service.StatusActive), group.PlatformEQ(platform)).
- Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- groupIDs := make([]int64, 0, len(groups))
- outGroups := make([]service.Group, 0, len(groups))
- for i := range groups {
- g := groupEntityToService(groups[i])
- outGroups = append(outGroups, *g)
- groupIDs = append(groupIDs, g.ID)
- }
- counts, err := r.loadAccountCounts(ctx, groupIDs)
- if err == nil {
- for i := range outGroups {
- c := counts[outGroups[i].ID]
- outGroups[i].AccountCount = c.Total
- outGroups[i].ActiveAccountCount = c.Active
- outGroups[i].RateLimitedAccountCount = c.RateLimited
- }
- }
- return outGroups, nil
- }
- func (r *groupRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
- return r.client.Group.Query().Where(group.NameEQ(name)).Exist(ctx)
- }
- // ExistsByIDs 批量检查分组是否存在(仅检查未软删除记录)。
- // 返回结构:map[groupID]exists。
- func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error) {
- result := make(map[int64]bool, len(ids))
- if len(ids) == 0 {
- return result, nil
- }
- uniqueIDs := make([]int64, 0, len(ids))
- seen := make(map[int64]struct{}, len(ids))
- for _, id := range ids {
- if id <= 0 {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- uniqueIDs = append(uniqueIDs, id)
- result[id] = false
- }
- if len(uniqueIDs) == 0 {
- return result, nil
- }
- rows, err := r.sql.QueryContext(ctx, `
- SELECT id
- FROM groups
- WHERE id = ANY($1) AND deleted_at IS NULL
- `, pq.Array(uniqueIDs))
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- for rows.Next() {
- var id int64
- if err := rows.Scan(&id); err != nil {
- return nil, err
- }
- result[id] = true
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) {
- var rateLimited int64
- err = scanSingleRow(ctx, r.sql,
- fmt.Sprintf(`SELECT
- COUNT(*) FILTER (WHERE a.deleted_at IS NULL),
- COUNT(*) FILTER (WHERE %s),
- COUNT(*) FILTER (WHERE %s)
- FROM account_groups ag JOIN accounts a ON a.id = ag.account_id
- WHERE ag.group_id = $1`, groupAccountAvailableSQL, groupAccountTemporarilyLimitedSQL),
- []any{groupID}, &total, &active, &rateLimited)
- return
- }
- func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
- res, err := r.sql.ExecContext(ctx, "DELETE FROM account_groups WHERE group_id = $1", groupID)
- if err != nil {
- return 0, err
- }
- affected, _ := res.RowsAffected()
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
- logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group account clear failed: group=%d err=%v", groupID, err)
- }
- return affected, nil
- }
- func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, error) {
- return r.deleteCascade(ctx, id, false)
- }
- func (r *groupRepository) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) {
- return r.deleteCascade(ctx, id, true)
- }
- func (r *groupRepository) deleteCascade(ctx context.Context, id int64, requireEmpty bool) ([]int64, error) {
- // 使用 ent 事务统一包裹:避免手工基于 *sql.Tx 构造 ent client 带来的驱动断言问题,
- // 同时保证级联删除的原子性。
- tx, err := r.client.Tx(ctx)
- if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
- return nil, err
- }
- exec := r.client
- txClient := r.client
- if err == nil {
- defer func() { _ = tx.Rollback() }()
- exec = tx.Client()
- txClient = exec
- }
- // err 为 dbent.ErrTxStarted 时,复用当前 client 参与同一事务。
- // Lock the group row to avoid concurrent writes while we cascade.
- // 这里使用 exec.QueryContext 手动扫描,确保同一事务内加锁并能区分"未找到"与其他错误。
- rows, err := exec.QueryContext(ctx, "SELECT id, subscription_type FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id)
- if err != nil {
- return nil, err
- }
- var lockedID int64
- var subscriptionType string
- if rows.Next() {
- if err := rows.Scan(&lockedID, &subscriptionType); err != nil {
- _ = rows.Close()
- return nil, err
- }
- }
- if err := rows.Close(); err != nil {
- return nil, err
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- if lockedID == 0 {
- return nil, service.ErrGroupNotFound
- }
- if requireEmpty {
- var hasAccount bool
- if err := scanSingleRow(ctx, exec, `SELECT EXISTS (
- SELECT 1 FROM account_groups ag
- JOIN accounts a ON a.id = ag.account_id
- WHERE ag.group_id = $1 AND a.deleted_at IS NULL
- )`, []any{id}, &hasAccount); err != nil {
- return nil, err
- }
- if hasAccount {
- return nil, service.ErrGroupNotEmpty
- }
- }
- var affectedUserIDs []int64
- if subscriptionType == service.SubscriptionTypeSubscription {
- // 只查询未软删除的订阅,避免通知已取消订阅的用户
- rows, err := exec.QueryContext(ctx, "SELECT user_id FROM user_subscriptions WHERE group_id = $1 AND deleted_at IS NULL", id)
- if err != nil {
- return nil, err
- }
- for rows.Next() {
- var userID int64
- if scanErr := rows.Scan(&userID); scanErr != nil {
- _ = rows.Close()
- return nil, scanErr
- }
- affectedUserIDs = append(affectedUserIDs, userID)
- }
- if err := rows.Close(); err != nil {
- return nil, err
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- // 软删除订阅:设置 deleted_at 而非硬删除
- if _, err := exec.ExecContext(ctx, "UPDATE user_subscriptions SET deleted_at = NOW() WHERE group_id = $1 AND deleted_at IS NULL", id); err != nil {
- return nil, err
- }
- }
- // 2. Remove the group id from user_allowed_groups join table.
- // Legacy users.allowed_groups 列已弃用,不再同步。
- if _, err := exec.ExecContext(ctx, "DELETE FROM user_allowed_groups WHERE group_id = $1", id); err != nil {
- return nil, err
- }
- // 3. Delete account_groups join rows.
- if _, err := exec.ExecContext(ctx, "DELETE FROM account_groups WHERE group_id = $1", id); err != nil {
- return nil, err
- }
- // 4. Soft-delete composite model routes owned by this group.
- if _, err := exec.ExecContext(ctx, "UPDATE composite_model_routes SET deleted_at = NOW() WHERE group_id = $1 AND deleted_at IS NULL", id); err != nil {
- return nil, err
- }
- // 5. Soft-delete group itself.
- if _, err := txClient.Group.Delete().Where(group.IDEQ(id)).Exec(ctx); err != nil {
- return nil, err
- }
- if tx != nil {
- if err := tx.Commit(); err != nil {
- return nil, err
- }
- }
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &id, nil); err != nil {
- logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group cascade delete failed: group=%d err=%v", id, err)
- }
- return affectedUserIDs, nil
- }
- type groupAccountCounts struct {
- Total int64
- Active int64
- RateLimited int64
- }
- const (
- // 分组页的"可用"账号数必须与账号仓储的 ListSchedulableByGroupID 过滤口径一致。
- groupAccountAvailableSQL = `a.deleted_at IS NULL
- AND a.status = 'active'
- AND a.schedulable = true
- AND (a.expires_at IS NULL OR a.expires_at > NOW() OR a.auto_pause_on_expired = FALSE)
- AND (a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW())
- AND (a.overload_until IS NULL OR a.overload_until <= NOW())
- AND (a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW())`
- // 这里沿用历史字段名 RateLimitedAccountCount,但统计的是会让账号暂时退出调度的时间窗口。
- groupAccountTemporarilyLimitedSQL = `a.deleted_at IS NULL
- AND a.status = 'active'
- AND a.schedulable = true
- AND (a.expires_at IS NULL OR a.expires_at > NOW() OR a.auto_pause_on_expired = FALSE)
- AND (
- a.rate_limit_reset_at > NOW() OR
- a.overload_until > NOW() OR
- a.temp_unschedulable_until > NOW()
- )`
- )
- func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) {
- counts = make(map[int64]groupAccountCounts, len(groupIDs))
- if len(groupIDs) == 0 {
- return counts, nil
- }
- rows, err := r.sql.QueryContext(
- ctx,
- fmt.Sprintf(`SELECT ag.group_id,
- COUNT(*) FILTER (WHERE a.deleted_at IS NULL) AS total,
- COUNT(*) FILTER (WHERE %s) AS active,
- COUNT(*) FILTER (WHERE %s) AS rate_limited
- FROM account_groups ag
- JOIN accounts a ON a.id = ag.account_id
- WHERE ag.group_id = ANY($1)
- GROUP BY ag.group_id`, groupAccountAvailableSQL, groupAccountTemporarilyLimitedSQL),
- pq.Array(groupIDs),
- )
- if err != nil {
- return nil, err
- }
- defer func() {
- if closeErr := rows.Close(); closeErr != nil && err == nil {
- err = closeErr
- counts = nil
- }
- }()
- for rows.Next() {
- var groupID int64
- var c groupAccountCounts
- if err = rows.Scan(&groupID, &c.Total, &c.Active, &c.RateLimited); err != nil {
- return nil, err
- }
- counts[groupID] = c
- }
- if err = rows.Err(); err != nil {
- return nil, err
- }
- return counts, nil
- }
- // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重)
- func (r *groupRepository) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
- if len(groupIDs) == 0 {
- return nil, nil
- }
- rows, err := r.sql.QueryContext(
- ctx,
- "SELECT DISTINCT account_id FROM account_groups WHERE group_id = ANY($1) ORDER BY account_id",
- pq.Array(groupIDs),
- )
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- var accountIDs []int64
- for rows.Next() {
- var accountID int64
- if err := rows.Scan(&accountID); err != nil {
- return nil, err
- }
- accountIDs = append(accountIDs, accountID)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return accountIDs, nil
- }
- // BindAccountsToGroup 将多个账号绑定到指定分组(批量插入,忽略已存在的绑定)
- func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
- if len(accountIDs) == 0 {
- return nil
- }
- tx, err := r.client.Tx(ctx)
- if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
- return err
- }
- exec := sqlExecutor(r.client)
- if tx != nil {
- defer func() { _ = tx.Rollback() }()
- exec = tx.Client()
- }
- if err := lockLiveGroups(ctx, exec, []int64{groupID}); err != nil {
- return err
- }
- // 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定
- _, err = exec.ExecContext(
- ctx,
- `INSERT INTO account_groups (account_id, group_id, priority, created_at)
- SELECT unnest($1::bigint[]), $2, 50, NOW()
- ON CONFLICT (account_id, group_id) DO NOTHING`,
- pq.Array(accountIDs),
- groupID,
- )
- if err != nil {
- return err
- }
- if tx != nil {
- if err := tx.Commit(); err != nil {
- return err
- }
- }
- // 发送调度器事件
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
- logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue bind accounts to group failed: group=%d err=%v", groupID, err)
- }
- return nil
- }
- // UpdateSortOrders 批量更新分组排序
- func (r *groupRepository) UpdateSortOrders(ctx context.Context, updates []service.GroupSortOrderUpdate) error {
- if len(updates) == 0 {
- return nil
- }
- // 去重后保留最后一次排序值,避免重复 ID 造成 CASE 分支冲突。
- sortOrderByID := make(map[int64]int, len(updates))
- groupIDs := make([]int64, 0, len(updates))
- for _, u := range updates {
- if u.ID <= 0 {
- continue
- }
- if _, exists := sortOrderByID[u.ID]; !exists {
- groupIDs = append(groupIDs, u.ID)
- }
- sortOrderByID[u.ID] = u.SortOrder
- }
- if len(groupIDs) == 0 {
- return nil
- }
- // 与旧实现保持一致:任何不存在/已删除的分组都返回 not found,且不执行更新。
- var existingCount int
- if err := scanSingleRow(
- ctx,
- r.sql,
- `SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL AND id = ANY($1)`,
- []any{pq.Array(groupIDs)},
- &existingCount,
- ); err != nil {
- return err
- }
- if existingCount != len(groupIDs) {
- return service.ErrGroupNotFound
- }
- args := make([]any, 0, len(groupIDs)*2+1)
- caseClauses := make([]string, 0, len(groupIDs))
- placeholder := 1
- for _, id := range groupIDs {
- caseClauses = append(caseClauses, fmt.Sprintf("WHEN $%d THEN $%d", placeholder, placeholder+1))
- args = append(args, id, sortOrderByID[id])
- placeholder += 2
- }
- args = append(args, pq.Array(groupIDs))
- query := fmt.Sprintf(`
- UPDATE groups
- SET sort_order = CASE id
- %s
- ELSE sort_order
- END
- WHERE deleted_at IS NULL AND id = ANY($%d)
- `, strings.Join(caseClauses, "\n\t\t\t"), placeholder)
- result, err := r.sql.ExecContext(ctx, query, args...)
- if err != nil {
- return err
- }
- affected, err := result.RowsAffected()
- if err != nil {
- return err
- }
- if affected != int64(len(groupIDs)) {
- return service.ErrGroupNotFound
- }
- for _, id := range groupIDs {
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &id, nil); err != nil {
- logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group sort update failed: group=%d err=%v", id, err)
- }
- }
- return nil
- }
|