Files
sub2api/backend/internal/repository/group_repo.go
T

1111 lines
35 KiB
Go
Raw Normal View History

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
}
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).
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).
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
SetRequirePrivacySet(groupIn.RequirePrivacySet).
SetDefaultMappedModel(groupIn.DefaultMappedModel).
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
SetModelsListConfig(groupIn.ModelsListConfig).
SetRpmLimit(groupIn.RPMLimit).
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
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).
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).
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
SetRequirePrivacySet(groupIn.RequirePrivacySet).
SetDefaultMappedModel(groupIn.DefaultMappedModel).
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig).
SetModelsListConfig(groupIn.ModelsListConfig).
SetRpmLimit(groupIn.RPMLimit).
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
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()
}
// 处理 FallbackGroupIDnil 时清除,否则设置
if groupIn.FallbackGroupID != nil {
builder = builder.SetFallbackGroupID(*groupIn.FallbackGroupID)
} else {
builder = builder.ClearFallbackGroupID()
}
// 处理 FallbackGroupIDOnInvalidRequestnil 时清除,否则设置
if groupIn.FallbackGroupIDOnInvalidRequest != nil {
builder = builder.SetFallbackGroupIDOnInvalidRequest(*groupIn.FallbackGroupIDOnInvalidRequest)
} else {
builder = builder.ClearFallbackGroupIDOnInvalidRequest()
}
// 处理 ModelRoutingnil 时清除,否则设置
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()
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) {
g, err := r.client.Group.Query().Where(group.IDEQ(id)).Only(ctx)
if err != nil {
return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil)
}
groupSvc := groupEntityToService(g)
// 使用 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 FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id)
if err != nil {
return nil, err
}
var lockedID int64
if rows.Next() {
if err := rows.Scan(&lockedID); 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
}
var affectedUserIDs []int64
if groupSvc.IsSubscriptionType() {
// 只查询未软删除的订阅,避免通知已取消订阅的用户
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
}
// 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定
_, err := r.sql.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 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
}