feat(login): 任务列表支持分页、跨页全选与一键清空
任务量早就过了单页能装下的规模(库里 1.4 万条,单批就 177 条),但接口
硬编码 limit(200)、前端 pagination={false},于是「全选」其实只能选到前
200 条,失败任务清不干净。
- 接口加 page/page_size/status:不传 page 时仍返回裸数组(Cookie 页等
老调用方依赖这个契约,不能改);传了才返回 {items,total},total 供跨页全选。
- 删除支持 all_matching:按当前筛选条件删全部,不必先翻完所有页去勾。
- 前端:分页控件(20/50/100/200/500,记住选择)、状态筛选、表头
「选择全部 N 条(含其他页)」、「清空失败」一键操作。
两条护栏:
- **pending/running 一律不删** —— 删掉会让批次线程找不到记录;要停批次
走 /stop/{batch_id}。
- 不给任何条件就拒绝删除,绝不默认全删。
顺带:带 join 的 query 不能 bulk delete(SQLAlchemy 会拒绝),改成先查
id 再按主键删。
This commit is contained in:
+123
-18
@@ -3,8 +3,14 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import func, or_
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, defer, load_only
|
||||
|
||||
from ..database import SessionLocal, get_db
|
||||
@@ -135,20 +141,14 @@ async def create_batch(
|
||||
return {"batch_id": batch_id, "count": len(valid_ids), "success": True}
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[LoginTaskOut])
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看登录任务列表。"""
|
||||
def _visible_tasks_query(db: Session, current: User, batch_id: str | None):
|
||||
"""按权限与批次过滤登录任务。客服只能看自己名下账号的任务。"""
|
||||
query = (
|
||||
db.query(LoginTask)
|
||||
.options(defer(LoginTask.cookie))
|
||||
.join(Account, LoginTask.account_id == Account.id)
|
||||
)
|
||||
|
||||
# 客服只能看自己账号的任务
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
query = query.filter(Account.assigned_to == current.id)
|
||||
|
||||
@@ -157,9 +157,11 @@ def list_tasks(
|
||||
query = query.filter(
|
||||
or_(LoginTask.batch_id == batch_id, LoginTask.attempt_batch_id == batch_id)
|
||||
)
|
||||
return query
|
||||
|
||||
rows = query.order_by(LoginTask.id.desc()).limit(200).all()
|
||||
# 批量收集 account_id,一次性查出 username
|
||||
|
||||
def _serialize_tasks(db: Session, rows) -> list[LoginTaskOut]:
|
||||
"""把任务行拼上账号名后序列化。"""
|
||||
account_ids = [t.account_id for t in rows]
|
||||
accounts_map = {}
|
||||
if account_ids:
|
||||
@@ -193,6 +195,54 @@ def list_tasks(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
# 默认值保持 None(而不是 Query(None)):这个接口会被测试与内部代码直接
|
||||
# 调用,用 Query() 做默认值时拿到的会是那个 Query 对象而非 None。
|
||||
page: int | None = None,
|
||||
page_size: int = 50,
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看登录任务列表。
|
||||
|
||||
**不传 `page` 时返回裸数组**(上限 200),沿用老调用方(Cookie 页等)的
|
||||
契约;传了 `page` 才返回 ``{items, total, page, page_size}``,供表格分页。
|
||||
|
||||
传 `page` 时同时返回 `total`,是为了让前端能「全选所有筛选结果」——
|
||||
否则跨页选择无从下手,而任务量早就过了单页能装下的规模。
|
||||
"""
|
||||
query = _visible_tasks_query(db, current, batch_id)
|
||||
if status:
|
||||
# 前端看到的是「最近一次尝试」的状态,与列表序列化口径保持一致。
|
||||
query = query.filter(
|
||||
or_(
|
||||
and_(
|
||||
LoginTask.attempt_status != "", LoginTask.attempt_status == status
|
||||
),
|
||||
and_(LoginTask.attempt_status == "", LoginTask.status == status),
|
||||
)
|
||||
)
|
||||
|
||||
ordered = query.order_by(LoginTask.id.desc())
|
||||
|
||||
if page is None:
|
||||
return _serialize_tasks(db, ordered.limit(200).all())
|
||||
|
||||
page = max(1, int(page))
|
||||
page_size = max(1, min(int(page_size), 500))
|
||||
total = ordered.count()
|
||||
rows = ordered.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {
|
||||
"items": _serialize_tasks(db, rows),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/summary")
|
||||
def tasks_summary(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -223,18 +273,73 @@ def delete_task(
|
||||
@router.delete("/tasks")
|
||||
def delete_tasks(
|
||||
task_ids: str = "",
|
||||
batch_id: str | None = None,
|
||||
status: str | None = None,
|
||||
all_matching: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("login:batch")),
|
||||
):
|
||||
"""批量删除登录任务。"""
|
||||
if not task_ids:
|
||||
raise HTTPException(status_code=400, detail="请指定任务ID")
|
||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||
"""批量删除登录任务。三种模式,互斥:
|
||||
|
||||
1. ``task_ids``:删指定 id(表格勾选);
|
||||
2. ``all_matching=true``:删当前筛选条件下的**全部**(跨页全选用);
|
||||
3. 两者都没给 → 400,绝不默认全删。
|
||||
|
||||
**进行中(pending/running)的任务一律跳过**:删掉会让批次线程找不到记录,
|
||||
而停止批次应该走 ``/stop/{batch_id}``。
|
||||
"""
|
||||
# 护栏:不删在途任务,避免批次线程与数据库状态打架。
|
||||
in_flight = ("pending", "running")
|
||||
not_in_flight = or_(
|
||||
and_(
|
||||
LoginTask.attempt_status != "",
|
||||
LoginTask.attempt_status.notin_(in_flight),
|
||||
),
|
||||
and_(LoginTask.attempt_status == "", LoginTask.status.notin_(in_flight)),
|
||||
)
|
||||
|
||||
if task_ids:
|
||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的任务ID")
|
||||
elif all_matching:
|
||||
# 带 join 的 query 不能直接 bulk delete(SQLAlchemy 会拒绝),
|
||||
# 所以先按权限与筛选条件查出 id,再按主键删。
|
||||
query = _visible_tasks_query(db, current, batch_id)
|
||||
if status:
|
||||
query = query.filter(
|
||||
or_(
|
||||
and_(
|
||||
LoginTask.attempt_status != "",
|
||||
LoginTask.attempt_status == status,
|
||||
),
|
||||
and_(LoginTask.attempt_status == "", LoginTask.status == status),
|
||||
)
|
||||
)
|
||||
ids = [row[0] for row in query.with_entities(LoginTask.id).all()]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="请指定任务ID或 all_matching=true")
|
||||
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的任务ID")
|
||||
return {"message": "没有符合条件的任务", "deleted": 0, "success": True}
|
||||
|
||||
target_ids = [
|
||||
row[0]
|
||||
for row in db.query(LoginTask.id)
|
||||
.filter(LoginTask.id.in_(ids))
|
||||
.filter(not_in_flight)
|
||||
.all()
|
||||
]
|
||||
if not target_ids:
|
||||
return {
|
||||
"message": "没有可删除的任务(在途任务已跳过)",
|
||||
"deleted": 0,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
deleted = (
|
||||
db.query(LoginTask)
|
||||
.filter(LoginTask.id.in_(ids))
|
||||
.filter(LoginTask.id.in_(target_ids))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user