fix(assignments): 未分配列表不再混入 CK 失效账号

分配池统一为:持 CK、CK 未判失效、未被停用/归档。新增 assignable_only 参数贯穿账号列表、批量分配与分配概览;前端列表与一键全部分配使用同一口径。
This commit is contained in:
yml2213
2026-10-22 11:01:55 +08:00
parent 3761267015
commit 96c4f41674
6 changed files with 109 additions and 12 deletions
+65 -1
View File
@@ -14,7 +14,11 @@
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, User
from web.backend.routers.accounts import batch_assign_accounts, list_accounts
from web.backend.routers.accounts import (
assignments_summary,
batch_assign_accounts,
list_accounts,
)
from web.backend.schemas import BatchAssign
@@ -97,6 +101,66 @@ class TestAssignFilter(_Base):
assert self.names(assign_status="unassigned", tag="10.1-测试") == ["tag-a-free"]
class TestAssignablePool(_Base):
"""分配池口径:CK 失效的号必须先修复,不能混进「未分配」让人误分配。"""
def test_assignable_only_excludes_invalid_ck(self):
self.make_account("ap-valid", ck_check_status="valid")
self.make_account("ap-unchecked") # 还没检测过:按可用处理
self.make_account("ap-error", ck_check_status="error") # 查不到 ≠ 失效
self.make_account("ap-invalid", ck_check_status="invalid")
self.make_account("ap-disabled", ck_check_status="valid", status="disabled")
assert self.names(assign_status="unassigned", assignable_only=True) == [
"ap-valid",
"ap-unchecked",
"ap-error",
]
def test_summary_counts_only_assignable(self):
self.make_account("as-valid", ck_check_status="valid")
self.make_account("as-invalid", ck_check_status="invalid")
self.make_account(
"as-assigned-valid", ck_check_status="valid", assigned_to=self.support.id
)
self.make_account(
"as-assigned-invalid",
ck_check_status="invalid",
assigned_to=self.support.id,
)
summary = assignments_summary(db=self.session, current=self.admin)
assert summary["unassigned_count"] == 1
assert summary["assigned_count"] == 1
assert summary["total_count"] == 2
support = next(
u for u in summary["support_users"] if u["id"] == self.support.id
)
assert support["assigned_count"] == 1
def test_all_matching_assignable_only_skips_invalid(self):
valid = self.make_account("am-valid", ck_check_status="valid")
invalid = self.make_account("am-invalid", ck_check_status="invalid")
result = batch_assign_accounts(
BatchAssign(
all_matching=True,
assign_status="unassigned",
assignable_only=True,
assigned_to=self.support.id,
),
db=self.session,
current=self.admin,
)
assert result["count"] == 1
self.session.refresh(valid)
self.session.refresh(invalid)
assert valid.assigned_to == self.support.id
assert invalid.assigned_to is None
class TestAllMatchingAssign(_Base):
def test_assigns_every_matching_unassigned(self):
free = [self.make_account(f"mf-{i}") for i in range(3)]
+34 -9
View File
@@ -55,10 +55,7 @@ from ..services.account_lifecycle import (
STATUS_MANUAL,
set_manual_state,
)
from ..services.account_service import (
cookie_account_ids_query,
parse_and_build_accounts,
)
from ..services.account_service import parse_and_build_accounts
from ..services.account_value import (
SUIT_BAIRIMENG,
SUIT_MENGTU,
@@ -84,6 +81,23 @@ def _visible_accounts_query(db: Session, current: User):
raise HTTPException(status_code=403, detail="无权查看账号")
def _assignable_account_filter():
"""分配池口径:有 CK 且 CK **还没被判失效**,并且没被人工停用/归档。
- `ck_check_status` 为空 = 还没检测过(`derive_status` 同样按可用处理);
- `error` = 本轮查不到,不代表失效,照常留在池子里;
- `invalid` = 巡检已判失效,必须先重登/修复,分配给它也跑不了任何任务。
"""
return and_(
cookie_holding_filter(),
or_(Account.ck_check_status.is_(None), Account.ck_check_status != "invalid"),
or_(
Account.status.is_(None),
Account.status.notin_([STATUS_DISABLED, STATUS_ARCHIVED]),
),
)
def _query_str(value) -> str:
"""把查询参数安全地取成字符串。
@@ -120,6 +134,7 @@ def _filter_accounts_query(
assigned_only: bool = False,
assign_status: str = "",
assigned_user_id: int | None = None,
assignable_only: bool = False,
tag: str | None = None,
has_cookie: bool = False,
search: str = "",
@@ -140,6 +155,8 @@ def _filter_accounts_query(
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
if _query_bool(has_cookie):
query = query.filter(Account.cookie.isnot(None), Account.cookie != "")
if _query_bool(assignable_only):
query = query.filter(_assignable_account_filter())
if _query_bool(assigned_only) and user_has_permission(current, "account:view_all"):
query = query.filter(Account.assigned_to.isnot(None))
# 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、
@@ -263,6 +280,7 @@ def _selected_account_ids(
assigned_only=req.assigned_only,
assign_status=req.assign_status,
assigned_user_id=req.assigned_user_id,
assignable_only=req.assignable_only,
tag=req.tag,
has_cookie=req.has_cookie,
search=req.search,
@@ -416,6 +434,10 @@ def list_accounts(
"", description="归属筛选:unassigned=未分配 / assigned=已分配;空=全部"
),
assigned_user_id: int | None = Query(None, description="只看某个客服名下的账号"),
assignable_only: bool = Query(
False,
description="只看可分配账号:持 CK 且 CK 未失效(排除已停用/归档)",
),
tag: str = Query(None),
account_names: str = Query(
"", description="多行斗鱼账号名(每行一个),精确匹配并自动去重"
@@ -465,6 +487,7 @@ def list_accounts(
assigned_only=assigned_only,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
assignable_only=assignable_only,
tag=tag,
has_cookie=has_cookie,
search=search,
@@ -1113,15 +1136,18 @@ def assignments_summary(
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:assign")),
):
"""分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。"""
"""分配概览:每个客服分到了多少**可分配账号**(持 CK 且 CK 未失效)。
失效 CK 的账号必须先重登/修复,放进分配池只会让客服以为能跑任务,
所以列表、全量分配与这里的统计统一用 `_assignable_account_filter()`。
"""
results = (
db.query(User.id, User.username, func.count(Account.id).label("count"))
.outerjoin(
Account,
and_(
Account.assigned_to == User.id,
Account.cookie.isnot(None),
Account.cookie != "",
_assignable_account_filter(),
),
)
.filter(User.role == "support")
@@ -1133,8 +1159,7 @@ def assignments_summary(
db.query(func.count(Account.id))
.filter(
Account.assigned_to.is_(None),
Account.cookie.isnot(None),
Account.cookie != "",
_assignable_account_filter(),
)
.scalar()
) or 0
+2
View File
@@ -207,6 +207,8 @@ class AccountBulkSelection(BaseModel):
assign_status: str = ""
#: 只看某个客服名下的账号(与 assign_status 可叠加)
assigned_user_id: int | None = None
#: 分配池口径:持 CK 且 CK 未失效(排除停用/归档)
assignable_only: bool = False
has_cookie: bool = False
status: str = ""
ck_status: str = ""
+1
View File
@@ -25,6 +25,7 @@ export const accountApi = {
assigned_only?: boolean;
assign_status?: string;
assigned_user_id?: number;
assignable_only?: boolean;
tag?: string;
has_cookie?: boolean;
include_sensitive?: boolean;
+2
View File
@@ -36,6 +36,8 @@ export interface AccountBulkSelection {
assign_status?: string;
/** 只看某个客服名下的账号 */
assigned_user_id?: number;
/** 分配池口径:持 CK 且 CK 未失效(排除停用/归档) */
assignable_only?: boolean;
has_cookie?: boolean;
status?: string;
ck_status?: string;
+5 -2
View File
@@ -72,7 +72,9 @@ export default function AssignmentsPage() {
const params: Parameters<typeof accountApi.listPaged>[0] = {
page: currentPage,
page_size: pageSize,
has_cookie: true,
// 分配池只收「有 CK 且 CK 未失效」的账号:失效号的下一步是重登/修复,
// 不是分配给客服(分过去也跑不了任务)。
assignable_only: true,
include_sensitive: false,
search: searchText.trim() || undefined,
tag: filterTag || undefined,
@@ -180,6 +182,7 @@ export default function AssignmentsPage() {
const res = await accountApi.batchAssignSelection({
all_matching: true,
assign_status: 'unassigned',
assignable_only: true,
tag: filterTag || undefined,
search: searchText.trim() || undefined,
assigned_to: selectedUser.id,
@@ -252,7 +255,7 @@ export default function AssignmentsPage() {
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col span={6}>
<Card size="small">
<Statistic title="总账号 (持CK)" value={totalAccountCount} prefix={<TeamOutlined />} />
<Statistic title="总账号 (CK 有效)" value={totalAccountCount} prefix={<TeamOutlined />} />
</Card>
</Col>
<Col span={6}>