fix: 修正分组账号可用计数口径
This commit is contained in:
parent
dd4d482a70
commit
df2b02e61c
@ -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 {
|
||||||
|
|||||||
@ -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 ---
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user