fix: 修正分组账号可用计数口径

This commit is contained in:
shaw 2026-05-20 16:53:23 +08:00
parent dd4d482a70
commit df2b02e61c
2 changed files with 86 additions and 27 deletions

View File

@ -94,9 +94,13 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group
if err != nil { if err != nil {
return nil, err return nil, err
} }
total, active, _ := r.GetAccountCount(ctx, out.ID) counts, err := r.loadAccountCounts(ctx, []int64{out.ID})
out.AccountCount = total if err == nil {
out.ActiveAccountCount = active c := counts[out.ID]
out.AccountCount = c.Total
out.ActiveAccountCount = c.Active
out.RateLimitedAccountCount = c.RateLimited
}
return out, nil return out, nil
} }
@ -538,15 +542,12 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int
func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) { func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) {
var rateLimited int64 var rateLimited int64
err = scanSingleRow(ctx, r.sql, err = scanSingleRow(ctx, r.sql,
`SELECT COUNT(*), fmt.Sprintf(`SELECT
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true), COUNT(*) FILTER (WHERE a.deleted_at IS NULL),
COUNT(*) FILTER (WHERE a.status = 'active' AND ( COUNT(*) FILTER (WHERE %s),
a.rate_limit_reset_at > NOW() OR COUNT(*) FILTER (WHERE %s)
a.overload_until > NOW() OR
a.temp_unschedulable_until > NOW()
))
FROM account_groups ag JOIN accounts a ON a.id = ag.account_id FROM account_groups ag JOIN accounts a ON a.id = ag.account_id
WHERE ag.group_id = $1`, WHERE ag.group_id = $1`, groupAccountAvailableSQL, groupAccountTemporarilyLimitedSQL),
[]any{groupID}, &total, &active, &rateLimited) []any{groupID}, &total, &active, &rateLimited)
return return
} }
@ -680,6 +681,28 @@ type groupAccountCounts struct {
RateLimited 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) { func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) {
counts = make(map[int64]groupAccountCounts, len(groupIDs)) counts = make(map[int64]groupAccountCounts, len(groupIDs))
if len(groupIDs) == 0 { if len(groupIDs) == 0 {
@ -688,18 +711,14 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6
rows, err := r.sql.QueryContext( rows, err := r.sql.QueryContext(
ctx, ctx,
`SELECT ag.group_id, fmt.Sprintf(`SELECT ag.group_id,
COUNT(*) AS total, COUNT(*) FILTER (WHERE a.deleted_at IS NULL) AS total,
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true) AS active, COUNT(*) FILTER (WHERE %s) AS active,
COUNT(*) FILTER (WHERE a.status = 'active' AND ( COUNT(*) FILTER (WHERE %s) AS rate_limited
a.rate_limit_reset_at > NOW() OR
a.overload_until > NOW() OR
a.temp_unschedulable_until > NOW()
)) AS rate_limited
FROM account_groups ag FROM account_groups ag
JOIN accounts a ON a.id = ag.account_id JOIN accounts a ON a.id = ag.account_id
WHERE ag.group_id = ANY($1) WHERE ag.group_id = ANY($1)
GROUP BY ag.group_id`, GROUP BY ag.group_id`, groupAccountAvailableSQL, groupAccountTemporarilyLimitedSQL),
pq.Array(groupIDs), pq.Array(groupIDs),
) )
if err != nil { if err != nil {

View File

@ -714,9 +714,9 @@ func (s *GroupRepoSuite) TestListWithFilters_ActiveAccountCount_LessThanTotal()
s.Assert().Equal(found.ActiveAccountCount, active, "GetAccountCount active must match ListWithFilters ActiveAccountCount") s.Assert().Equal(found.ActiveAccountCount, active, "GetAccountCount active must match ListWithFilters ActiveAccountCount")
} }
// TestListWithFilters_RateLimitedAccountCount 验证 RateLimitedAccountCount 正确统计临时受限账号。 // TestListWithFilters_RateLimitedAccountCount 验证临时受限账号不会计入可用账号数
// 受限账号rate_limit_reset_at 尚未过期)仍然计入 ActiveAccountCount // rate_limit / overload / temp_unschedulable 都会让账号退出当前调度池
// 同时额外出现在 RateLimitedAccountCount 中 // 因此 ActiveAccountCount 必须与真实调度查询口径一致
func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() { func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() {
g := &service.Group{ g := &service.Group{
Name: "g-rate-limited", Name: "g-rate-limited",
@ -740,6 +740,24 @@ func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() {
[]any{"acc-rate-limited", service.PlatformAnthropic, service.AccountTypeOAuth}, []any{"acc-rate-limited", service.PlatformAnthropic, service.AccountTypeOAuth},
&rateLimitedID)) &rateLimitedID))
var overloadedID int64
s.Require().NoError(scanSingleRow(s.ctx, s.tx,
"INSERT INTO accounts (name, platform, type, overload_until) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') RETURNING id",
[]any{"acc-overloaded", service.PlatformAnthropic, service.AccountTypeOAuth},
&overloadedID))
var tempUnschedulableID int64
s.Require().NoError(scanSingleRow(s.ctx, s.tx,
"INSERT INTO accounts (name, platform, type, temp_unschedulable_until) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour') RETURNING id",
[]any{"acc-temp-unschedulable", service.PlatformAnthropic, service.AccountTypeOAuth},
&tempUnschedulableID))
var expiredID int64
s.Require().NoError(scanSingleRow(s.ctx, s.tx,
"INSERT INTO accounts (name, platform, type, expires_at, auto_pause_on_expired) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', TRUE) RETURNING id",
[]any{"acc-expired", service.PlatformAnthropic, service.AccountTypeOAuth},
&expiredID))
_, err := s.tx.ExecContext(s.ctx, _, err := s.tx.ExecContext(s.ctx,
"INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
normalID, g.ID, 1) normalID, g.ID, 1)
@ -748,6 +766,18 @@ func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() {
"INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
rateLimitedID, g.ID, 2) rateLimitedID, g.ID, 2)
s.Require().NoError(err) s.Require().NoError(err)
_, err = s.tx.ExecContext(s.ctx,
"INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
overloadedID, g.ID, 3)
s.Require().NoError(err)
_, err = s.tx.ExecContext(s.ctx,
"INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
tempUnschedulableID, g.ID, 4)
s.Require().NoError(err)
_, err = s.tx.ExecContext(s.ctx,
"INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
expiredID, g.ID, 5)
s.Require().NoError(err)
isExclusive := false isExclusive := false
groups, _, err := s.repo.ListWithFilters(s.ctx, groups, _, err := s.repo.ListWithFilters(s.ctx,
@ -763,10 +793,20 @@ func (s *GroupRepoSuite) TestListWithFilters_RateLimitedAccountCount() {
} }
} }
s.Require().NotNil(found, "created group must appear in ListWithFilters result") s.Require().NotNil(found, "created group must appear in ListWithFilters result")
s.Assert().Equal(int64(2), found.AccountCount, "AccountCount must be 2") s.Assert().Equal(int64(5), found.AccountCount, "AccountCount must include all linked accounts")
// rate-limited account is still active+schedulable, so it counts toward active s.Assert().Equal(int64(1), found.ActiveAccountCount, "ActiveAccountCount must include only currently schedulable accounts")
s.Assert().Equal(int64(2), found.ActiveAccountCount, "rate-limited account still counts as active") s.Assert().Equal(int64(3), found.RateLimitedAccountCount, "RateLimitedAccountCount must include temporarily limited accounts")
s.Assert().Equal(int64(1), found.RateLimitedAccountCount, "RateLimitedAccountCount must be 1")
total, active, err := s.repo.GetAccountCount(s.ctx, g.ID)
s.Require().NoError(err)
s.Assert().Equal(found.AccountCount, total, "GetAccountCount total must match ListWithFilters AccountCount")
s.Assert().Equal(found.ActiveAccountCount, active, "GetAccountCount active must match ListWithFilters ActiveAccountCount")
detail, err := s.repo.GetByID(s.ctx, g.ID)
s.Require().NoError(err)
s.Assert().Equal(found.AccountCount, detail.AccountCount, "GetByID AccountCount must match ListWithFilters")
s.Assert().Equal(found.ActiveAccountCount, detail.ActiveAccountCount, "GetByID ActiveAccountCount must match ListWithFilters")
s.Assert().Equal(found.RateLimitedAccountCount, detail.RateLimitedAccountCount, "GetByID RateLimitedAccountCount must match ListWithFilters")
} }
// --- DeleteAccountGroupsByGroupID --- // --- DeleteAccountGroupsByGroupID ---