From a0db26def30eee81e2804eca67803240a80fef7f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 10:37:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(login):=20=E4=BB=BB=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E5=88=86=E9=A1=B5=E3=80=81=E8=B7=A8?= =?UTF-8?q?=E9=A1=B5=E5=85=A8=E9=80=89=E4=B8=8E=E4=B8=80=E9=94=AE=E6=B8=85?= =?UTF-8?q?=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务量早就过了单页能装下的规模(库里 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 再按主键删。 --- README.md | 33 ++++ tests/test_login_tasks_pagination.py | 231 ++++++++++++++++++++++ web/backend/routers/login.py | 141 +++++++++++-- web/frontend/src/api/login.ts | 15 ++ web/frontend/src/pages/LoginTasksPage.tsx | 164 +++++++++++++-- 5 files changed, 550 insertions(+), 34 deletions(-) create mode 100644 tests/test_login_tasks_pagination.py diff --git a/README.md b/README.md index 715c19b..73d12a8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,39 @@ live-hub-py/ ./deploy.sh reset # 重置所有数据(需二次确认) ``` +## 时区口径(查库前必读) + +**全库时间列一律存 UTC 裸值**(`DATETIME`,无 `TIMESTAMP` 列、无 `CURRENT_TIMESTAMP` 默认值)。MySQL 容器以 +`--default-time-zone=+00:00` 启动,因此 **`NOW()` 返回的也是 UTC** —— 这**不是故障**,是刻意对齐存储口径。 + +| 层 | 看到的时间 | 说明 | +| --- | --- | --- | +| 数据库裸值 / `NOW()` | UTC | 命令行直连就是这个 | +| ORM 读出(`UTCDateTime`) | `+00:00` aware | 读出来永远带时区,比较不会踩 naive/aware 的坑 | +| HTTP 接口返回 | `...+00:00` | 前端按本地时区渲染 | +| 前端显示 | 北京时间(UTC+8) | `dayjs` 自动转换 | +| 应用日志 | 北京时间 | 容器内 `TZ=Asia/Shanghai` | + +> ⚠️ **别拿 `SELECT NOW()` 当"真实时间"去和业务时间比对** —— 它比你手表慢 8 小时是正常的。 +> 已踩过一次:据此误判"数据库时区错了",实际端到端完全正确。 + +命令行查库要按北京时间看,用 `CONVERT_TZ`: + +```sql +SELECT id, status, + CONVERT_TZ(created_at, '+00:00', '+08:00') AS 创建时间, + CONVERT_TZ(finished_at, '+00:00', '+08:00') AS 完成时间 +FROM login_tasks ORDER BY id DESC LIMIT 10; +``` + +按"北京时间某段"筛选用同样的换算,别直接写本地时间字面量: + +```sql +-- 查北京时间 10-03 09:00 之后的任务 +SELECT * FROM login_tasks +WHERE created_at >= CONVERT_TZ('2026-10-03 09:00:00', '+08:00', '+00:00'); +``` + ## 运行时与密钥 生产是 **单个应用容器 + 内网 MySQL 8.4**。数据库不映射公网;本机 `./dev.sh` 通过 `127.0.0.1:3307` 连 compose 里的 MySQL。 diff --git a/tests/test_login_tasks_pagination.py b/tests/test_login_tasks_pagination.py new file mode 100644 index 0000000..67fefb2 --- /dev/null +++ b/tests/test_login_tasks_pagination.py @@ -0,0 +1,231 @@ +"""回归:登录任务列表分页、跨页全选删除、以及「不删在途任务」的护栏。 + +任务量早就过了单页能装下的规模(库里 1.4 万条、单批就 177 条),前端只能看 +200 条于是「全选」其实选不到全部。分页 + ``all_matching`` 是为了让「清空失败 +任务」这类操作真能一键做完。 + +三条必须守住: + +1. **不传 page 时返回裸数组** —— Cookie 页等老调用方依赖这个契约,改了就崩; +2. **传 page 才返回 {items,total}**,total 用于跨页全选; +3. **pending/running 一律不删** —— 删掉会让批次线程找不到记录。要停批次 + 走 ``/stop/{batch_id}``,不是删除。 +""" + +import pytest +from sqlalchemy.orm import sessionmaker + +import web.backend.database as database_module +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, LoginTask, User +from web.backend.routers.login import delete_tasks, list_tasks + + +class TestTaskPagination: + def setup_method(self): + self.engine = get_test_engine() + database_module.SessionLocal.configure(bind=self.engine) + self.session = make_test_session(self.engine) + + self.user = User(username="admin", password_hash="h", role="super_admin") + self.session.add(self.user) + self.session.flush() + + self.account = Account( + username="分页测试账号", + password="p", + email="paged@example.com", + email_password="ep", + ) + self.session.add(self.account) + self.session.flush() + + # 建 12 条:7 条 failed、5 条 success,用于验证筛选与分页 + self.task_ids = [] + for i in range(12): + task = LoginTask( + batch_id="paged-batch", + account_id=self.account.id, + created_by=self.user.id, + status="failed" if i < 7 else "success", + message=f"任务{i}", + ) + self.session.add(task) + self.session.flush() + self.task_ids.append(task.id) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _fresh(self): + return sessionmaker(bind=self.engine)() + + def test_without_page_returns_plain_list_for_backward_compat(self): + """老调用方(Cookie 页)拿到的必须是数组,不能是 {items,...}。""" + db = self._fresh() + try: + rows = list_tasks(db=db, current=self.user) + finally: + db.close() + assert isinstance(rows, list) + assert len(rows) == 12 + + def test_with_page_returns_items_and_total(self): + db = self._fresh() + try: + data = list_tasks(page=1, page_size=5, db=db, current=self.user) + finally: + db.close() + assert isinstance(data, dict) + assert data["total"] == 12 + assert data["page"] == 1 + assert len(data["items"]) == 5 + + def test_pages_do_not_overlap_and_cover_all(self): + db = self._fresh() + try: + seen = [] + for page in (1, 2, 3): + data = list_tasks(page=page, page_size=5, db=db, current=self.user) + seen.extend(item.id for item in data["items"]) + finally: + db.close() + assert len(seen) == 12 + assert len(set(seen)) == 12, "分页出现重复" + + def test_status_filter_narrows_total(self): + """total 必须是**筛选后**的总数,否则跨页全选会多删。""" + db = self._fresh() + try: + data = list_tasks( + page=1, page_size=5, status="failed", db=db, current=self.user + ) + finally: + db.close() + assert data["total"] == 7 + assert all(item.status == "failed" for item in data["items"]) + + def test_page_beyond_range_is_empty(self): + db = self._fresh() + try: + data = list_tasks(page=99, page_size=5, db=db, current=self.user) + finally: + db.close() + assert data["items"] == [] + assert data["total"] == 12 + + +class TestBulkDelete: + def setup_method(self): + self.engine = get_test_engine() + database_module.SessionLocal.configure(bind=self.engine) + self.session = make_test_session(self.engine) + + self.user = User(username="admin2", password_hash="h", role="super_admin") + self.session.add(self.user) + self.session.flush() + self.account = Account( + username="删除测试账号", + password="p", + email="del@example.com", + email_password="ep", + ) + self.session.add(self.account) + self.session.flush() + + self.failed_ids = [] + self.pending_ids = [] + for i in range(5): + for status, bucket in ( + ("failed", self.failed_ids), + ("pending", self.pending_ids), + ): + task = LoginTask( + batch_id="del-batch", + account_id=self.account.id, + created_by=self.user.id, + status=status, + message=f"{status}{i}", + ) + self.session.add(task) + self.session.flush() + bucket.append(task.id) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _fresh(self): + return sessionmaker(bind=self.engine)() + + def _remaining(self): + db = self._fresh() + try: + return { + row[0] + for row in db.query(LoginTask.id).filter( + LoginTask.batch_id == "del-batch" + ) + } + finally: + db.close() + + def test_delete_by_ids(self): + db = self._fresh() + try: + res = delete_tasks( + task_ids=",".join(str(i) for i in self.failed_ids[:3]), + db=db, + current=self.user, + ) + finally: + db.close() + assert res["deleted"] == 3 + remaining = self._remaining() + assert all(i not in remaining for i in self.failed_ids[:3]) + + def test_delete_all_matching_status(self): + """跨页全选:删掉筛选条件下的全部 failed。""" + db = self._fresh() + try: + res = delete_tasks( + status="failed", all_matching=True, db=db, current=self.user + ) + finally: + db.close() + assert res["deleted"] == 5 + remaining = self._remaining() + assert all(i not in remaining for i in self.failed_ids) + assert all(i in remaining for i in self.pending_ids) + + def test_delete_all_matching_never_touches_in_flight(self): + """核心护栏:pending/running 必须留下,否则批次线程会找不到记录。""" + db = self._fresh() + try: + res = delete_tasks(all_matching=True, db=db, current=self.user) + finally: + db.close() + # 只有 5 条 failed 可删,5 条 pending 被护栏挡住 + assert res["deleted"] == 5 + remaining = self._remaining() + assert all(i in remaining for i in self.pending_ids) + + def test_delete_without_criteria_rejected(self): + """不给条件就拒绝 —— 绝不默认全删。""" + db = self._fresh() + try: + with pytest.raises(Exception) as exc: + delete_tasks(db=db, current=self.user) + finally: + db.close() + assert "请指定任务ID" in str(exc.value) + + def test_delete_invalid_ids_rejected(self): + db = self._fresh() + try: + with pytest.raises(Exception) as exc: + delete_tasks(task_ids="abc,xyz", db=db, current=self.user) + finally: + db.close() + assert "无效的任务ID" in str(exc.value) diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index 36cf3a4..9ee779f 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -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() diff --git a/web/frontend/src/api/login.ts b/web/frontend/src/api/login.ts index b36d03e..d5727dd 100644 --- a/web/frontend/src/api/login.ts +++ b/web/frontend/src/api/login.ts @@ -10,13 +10,28 @@ interface CreateBatchParams { mode?: 'login' | 'check'; } +export interface TaskPage { + items: LoginTaskItem[]; + total: number; + page: number; + page_size: number; +} + export const loginApi = { createBatch: (params: CreateBatchParams) => api.post('/login/batch', params), + // 不传 page 时后端返回裸数组(老调用方契约),传了才返回 {items,total}。 listTasks: (batch_id?: string) => api.get('/login/tasks', { params: batch_id ? { batch_id } : {} }), + listTasksPaged: (params: { page: number; page_size: number; batch_id?: string; status?: string }) => + api.get('/login/tasks', { params: { ...params, batch_id: params.batch_id ?? '' } }), tasksSummary: () => api.get('/login/tasks/summary'), stop: (batch_id: string) => api.post(`/login/stop/${batch_id}`), deleteTask: (id: number) => api.delete(`/login/tasks/${id}`), deleteTasks: (ids: number[]) => api.delete(`/login/tasks`, { params: { task_ids: ids.join(',') } }), + // 跨页全选用:按当前筛选条件删除全部(后端会跳过在途任务)。 + deleteTasksMatching: (params: { batch_id?: string; status?: string }) => + api.delete('/login/tasks', { + params: { all_matching: true, batch_id: params.batch_id ?? '', status: params.status ?? '' }, + }), }; diff --git a/web/frontend/src/pages/LoginTasksPage.tsx b/web/frontend/src/pages/LoginTasksPage.tsx index ba396a1..5bbe43c 100644 --- a/web/frontend/src/pages/LoginTasksPage.tsx +++ b/web/frontend/src/pages/LoginTasksPage.tsx @@ -89,6 +89,16 @@ export default function LoginTasksPage() { useEffect(() => { localStorage.setItem('login_max_login_retries', String(maxLoginRetries)); }, [maxLoginRetries]); useEffect(() => { localStorage.setItem('login_api_strategy', apiStrategy); }, [apiStrategy]); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [selectedAllMatching, setSelectedAllMatching] = useState(false); + // 任务列表分页:库里上万条,单页装不下;total 同时用于「全选所有结果」。 + const [taskPage, setTaskPage] = useState(1); + const [taskPageSize, setTaskPageSize] = useState(() => { + const v = localStorage.getItem('login_task_page_size'); + return v ? Math.max(1, Math.min(500, Number(v) || 50)) : 50; + }); + const [taskStatus, setTaskStatus] = useState(''); + const [taskTotal, setTaskTotal] = useState(0); + useEffect(() => { localStorage.setItem('login_task_page_size', String(taskPageSize)); }, [taskPageSize]); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { can } = usePermissions(); @@ -199,14 +209,23 @@ export default function LoginTasksPage() { if (tasksLoadingRef.current) return; tasksLoadingRef.current = true; try { - const data = await loginApi.listTasks(batchId || undefined); - setTasks(data); + const data = await loginApi.listTasksPaged({ + page: taskPage, + page_size: taskPageSize, + batch_id: batchId || undefined, + status: taskStatus || undefined, + }); + setTasks(data.items); + setTaskTotal(data.total); + // 翻页/筛选后清掉失效的勾选,避免「选中了看不见的行」。 + const visible = new Set(data.items.map((t) => t.id)); + setSelectedRowKeys((prev) => (selectedAllMatching ? prev : prev.filter((k) => visible.has(k)))); } catch { // 忽略轮询失败,下一次定时刷新会继续尝试。 } finally { tasksLoadingRef.current = false; } - }, [batchId]); + }, [batchId, taskPage, taskPageSize, taskStatus, selectedAllMatching]); const hasActiveTasks = useMemo( () => tasks.some((task) => ['pending', 'running'].includes(task.status)), @@ -331,20 +350,54 @@ export default function LoginTasksPage() { }; const handleDeleteSelected = async () => { - if (selectedRowKeys.length === 0) { + if (!selectedAllMatching && selectedRowKeys.length === 0) { message.warning('请选择要删除的任务'); return; } try { - await loginApi.deleteTasks(selectedRowKeys); - message.success(`已删除 ${selectedRowKeys.length} 个任务`); + const res = selectedAllMatching + ? await loginApi.deleteTasksMatching({ + batch_id: batchId || undefined, + status: taskStatus || undefined, + }) + : await loginApi.deleteTasks(selectedRowKeys); + message.success(res.message || `已删除 ${res.deleted} 个任务`); setSelectedRowKeys([]); + setSelectedAllMatching(false); + setTaskPage(1); loadTasks(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; + // 一键清空当前筛选下的失败任务(跨页,不用先翻完所有页去勾) + const handleClearFailed = async () => { + try { + const res = await loginApi.deleteTasksMatching({ + batch_id: batchId || undefined, + status: 'failed', + }); + message.success(res.message || `已删除 ${res.deleted} 个任务`); + setSelectedRowKeys([]); + setSelectedAllMatching(false); + setTaskPage(1); + loadTasks(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }; + + const handleSelectAllMatching = () => { + setSelectedAllMatching(true); + setSelectedRowKeys([]); + }; + + const clearSelection = () => { + setSelectedAllMatching(false); + setSelectedRowKeys([]); + }; + const successCount = tasks.filter((t) => t.status === 'success').length; const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length; const checkedCount = tasks.filter((t) => [ @@ -556,19 +609,64 @@ export default function LoginTasksPage() { {/* 概览 + 任务列表区域 */}
{/* 概览 */} -
- 共 {tasks.length} 个任务 +
+ 共 {taskTotal} 个任务 成功 {successCount} 失败 {failedCount} {checkedCount > 0 && 检测 {checkedCount}} {effectiveBatchId && 批次: {effectiveBatchId}} +