diff --git a/tests/test_account_assign_filter.py b/tests/test_account_assign_filter.py index 9d547d1..bee9668 100644 --- a/tests/test_account_assign_filter.py +++ b/tests/test_account_assign_filter.py @@ -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)] diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index a6b73d1..2f25822 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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 diff --git a/web/backend/schemas.py b/web/backend/schemas.py index f48af1d..a3754ff 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 = "" diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index a82be23..f7dab6d 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -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; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index b20ff7f..3c2b36b 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/AssignmentsPage.tsx b/web/frontend/src/pages/AssignmentsPage.tsx index 02980d5..6a25f59 100644 --- a/web/frontend/src/pages/AssignmentsPage.tsx +++ b/web/frontend/src/pages/AssignmentsPage.tsx @@ -72,7 +72,9 @@ export default function AssignmentsPage() { const params: Parameters[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() { - } /> + } />