diff --git a/acceptance_service.py b/acceptance_service.py index 8bbbbff..f452f07 100644 --- a/acceptance_service.py +++ b/acceptance_service.py @@ -840,13 +840,22 @@ def _poll_screenshot_results(public_id: str) -> None: def retry_failed_acceptance_devices(public_id: str) -> dict[str, Any]: - """仅对截图失败的“关键词 × 设备”组合重新立即下发。""" + """补采失败及批次开始后新增的设备,仍写入原自然日批次。""" with connection() as conn: task = conn.execute( "SELECT * FROM acceptance_tasks WHERE public_task_id=?", (public_id,), ).fetchone() if not task: raise TaskError("ACCEPTANCE_TASK_NOT_FOUND", "验收任务不存在", 404) + if task["batch_date"]: + batch_day = datetime.strptime(task["batch_date"], "%Y-%m-%d").date() + today = datetime.now().astimezone().date() + if today > batch_day + timedelta(days=1): + raise TaskError( + "ACCEPTANCE_RETRY_WINDOW_EXPIRED", + f"{task['batch_date']} 批次仅允许在批次当天或次日重采", + 409, + ) if task["acceptance_method"] not in {"screenshot", "combined"}: raise TaskError("SCREENSHOT_RETRY_NOT_SUPPORTED", "该任务不包含截图采集", 409) rows = conn.execute( @@ -878,8 +887,24 @@ def retry_failed_acceptance_devices(public_id: str) -> dict[str, Any]: ) if status in terminal_failures or (status == "completed" and not has_screenshot): failed_by_keyword.setdefault(keyword, []).append(account_id) + active_device_ids = { + str(item.get("xhs_account_id") or "").strip() + for item in list_xhs_wen_xapi_devices() + if str(item.get("xhs_account_id") or "").strip() + } + for keyword in keyword_runs: + collected_ids = { + account_id for (row_keyword, account_id) in latest if row_keyword == keyword + } + missing_ids = active_device_ids - collected_ids + if missing_ids: + failed_by_keyword.setdefault(keyword, []).extend(sorted(missing_ids)) + failed_by_keyword = { + keyword: list(dict.fromkeys(account_ids)) + for keyword, account_ids in failed_by_keyword.items() if account_ids + } if not failed_by_keyword: - raise TaskError("NO_FAILED_SCREENSHOT_DEVICES", "当前没有可重新采集的失败设备", 409) + raise TaskError("NO_FAILED_SCREENSHOT_DEVICES", "当前没有失败或新增设备需要补采", 409) dispatched_devices: set[str] = set() dispatched_pairs = 0 @@ -1705,6 +1730,10 @@ def _task_dict(conn, task) -> dict[str, Any]: "dashboard_batch_key": task["dashboard_batch_key"], "use_all_devices": bool(task["use_all_devices"]), "batch_date": task["batch_date"], + "retry_deadline": ( + (datetime.strptime(task["batch_date"], "%Y-%m-%d").date() + timedelta(days=1)).isoformat() + if task["batch_date"] else None + ), "schedule_enabled": bool(task["schedule_enabled"]), "schedule_weekday": task["schedule_weekday"], "schedule_weekdays": _decode_json(task["schedule_weekdays"], None) or ( diff --git a/apifox-business-tasks.openapi.json b/apifox-business-tasks.openapi.json index 08e81ef..0ee83d6 100644 --- a/apifox-business-tasks.openapi.json +++ b/apifox-business-tasks.openapi.json @@ -99,8 +99,8 @@ }, "/api/v1/acceptance-tasks/{task_id}/retry-failed-devices": { "post": { - "summary": "重新采集全部失败截图设备", - "description": "按每个关键词读取最新 XAPI 结果,仅将失败、取消、异常或已完成但无截图的设备精确重新下发。成功设备不会重复执行,新批次继续归入原验收任务。", + "summary": "补采失败及新增截图设备", + "description": "按关键词补采失败设备,以及批次完成后新加入设备库但尚未参与该批次的设备。结果仍归原日期批次;只允许在批次当天和次日执行。", "tags": ["项目验收"], "parameters": [{"$ref": "#/components/parameters/TaskId"}], "responses": { diff --git a/tests/test_acceptance_creation.py b/tests/test_acceptance_creation.py index ce645b1..1cb68c2 100644 --- a/tests/test_acceptance_creation.py +++ b/tests/test_acceptance_creation.py @@ -174,9 +174,14 @@ class AcceptanceCreationTest(unittest.TestCase): @patch("acceptance_service.threading.Thread", _ThreadWithoutStart) @patch("acceptance_service.schedule_xapi_screenshots") + @patch("acceptance_service.list_xhs_wen_xapi_devices") @patch("acceptance_service.get_wen_resources") - def test_retry_failed_devices_dispatches_only_failed_accounts(self, resources, schedule): + def test_retry_failed_devices_dispatches_only_failed_accounts(self, resources, devices, schedule): resources.return_value = {"cookie_count": 0, "device_count": 2} + devices.return_value = [ + {"xhs_account_id": "failed-account"}, + {"xhs_account_id": "successful-account"}, + ] schedule.return_value = { "execution_mode": "immediate", "batches": [{