提交 a9074f8c authored 作者: 陈泽健's avatar 陈泽健

fix(executor): 修复FastAPI端点执行Playwright报asyncio检测错误

- playwright_executor.py: 在工作线程中设置 asyncio.set_event_loop(None) 绕过 Playwright 的 asyncio 事件循环检测
- execution_service.py: 优化 run_all_cases_sync 日志输出,清理调试 print 语句
- CLAUDE.md: 新增踩坑记录 #9,更新 Playwright 正确模式第 5 条
- HANDOFF.md: 更新状态为已修复,重构文档结构

根因:Playwright sync_playwright().start() 内部使用 asyncio.get_running_loop() 检测,
在 FastAPI run_in_executor 工作线程中需将事件循环设为 None 才能绕过检测
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 001a68a9
...@@ -110,6 +110,7 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。** ...@@ -110,6 +110,7 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。**
2. `playwright_executor.py` 使用 `sync_playwright` 同步 API 2. `playwright_executor.py` 使用 `sync_playwright` 同步 API
3. `execution_service.py` 通过 `loop.run_in_executor()` 在线程池中执行 3. `execution_service.py` 通过 `loop.run_in_executor()` 在线程池中执行
4. **每个测试用例创建独立的 `PlaywrightExecutor` 实例**(共享实例在 `stop()``_is_running=False`,下次 `start()` 会失败) 4. **每个测试用例创建独立的 `PlaywrightExecutor` 实例**(共享实例在 `stop()``_is_running=False`,下次 `start()` 会失败)
5. **`playwright_executor.py` 的 `start()` 方法中必须 `asyncio.set_event_loop(None)`**——Playwright 内部用 `asyncio.get_running_loop()` 检测 asyncio 循环,在工作线程中必须将事件循环设为 None 才能绕过检测
### 2. 前端 useRoute() 必须在 setup 顶层 ### 2. 前端 useRoute() 必须在 setup 顶层
...@@ -145,6 +146,7 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。** ...@@ -145,6 +146,7 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。**
| 6 | `claude` 命令不在 PATH | Windows 未配全局 PATH | `which claude` 找路径,补 `.cmd` 后缀 | | 6 | `claude` 命令不在 PATH | Windows 未配全局 PATH | `which claude` 找路径,补 `.cmd` 后缀 |
| 7 | 前端页面空白 | `useRoute()` 在 computed 内调用 | 移到 setup 顶层 | | 7 | 前端页面空白 | `useRoute()` 在 computed 内调用 | 移到 setup 顶层 |
| 8 | `NOT NULL constraint failed: error_message` | 字段 nullable=False 但成功时为 None | 改为 nullable=True | | 8 | `NOT NULL constraint failed: error_message` | 字段 nullable=False 但成功时为 None | 改为 nullable=True |
| 9 | Playwright Sync API inside asyncio loop | `run_in_executor` 线程中 Playwright 检测到 asyncio 循环 | `asyncio.set_event_loop(None)` 在工作线程中 |
详见 `HANDOFF.md` 第五节。 详见 `HANDOFF.md` 第五节。
......
此差异已折叠。
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
import logging import logging
import os import os
import asyncio
from typing import Optional, Callable, Dict, Any, List from typing import Optional, Callable, Dict, Any, List
from datetime import datetime from datetime import datetime
from dataclasses import dataclass, field from dataclasses import dataclass, field
...@@ -178,6 +179,18 @@ class PlaywrightExecutor: ...@@ -178,6 +179,18 @@ class PlaywrightExecutor:
RuntimeError: 当启动失败时抛出 RuntimeError: 当启动失败时抛出
""" """
try: try:
# 在 Windows 上,当从 run_in_executor 调用时,需要绕过 asyncio 检测
# Playwright sync_playwright().start() 内部会检测是否有事件循环
# 关键:必须确保当前线程没有关联任何 asyncio 事件循环
# asyncio.get_event_loop() 可能返回主线程的循环,所以必须用
# asyncio.get_running_loop() 检测或直接设置 None
import threading
if threading.current_thread() is not threading.main_thread():
# 非主线程(run_in_executor 环境)
# 将当前线程的事件循环设为 None,这样 Playwright 的检测就不会发现 asyncio 循环
# 注意:不能设为新的 event_loop,因为新循环也可能被 Playwright 检测到
asyncio.set_event_loop(None)
self._playwright = sync_playwright().start() self._playwright = sync_playwright().start()
# 启动浏览器 # 启动浏览器
...@@ -895,8 +908,6 @@ class PlaywrightExecutor: ...@@ -895,8 +908,6 @@ class PlaywrightExecutor:
break break
except Exception as e: except Exception as e:
import sys as _sys
print(f"===DEBUG step异常: name={step_result.name} action={action} err={type(e).__name__}: {e}===", file=_sys.stderr, flush=True)
last_error = e last_error = e
step_result.status = "failed" step_result.status = "failed"
step_result.error = str(e) step_result.error = str(e)
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
""" """
import logging import logging
import os
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
...@@ -336,3 +337,69 @@ async def get_knowledge_base(): ...@@ -336,3 +337,69 @@ async def get_knowledge_base():
total_vulns=len(historical_vulns), total_vulns=len(historical_vulns),
total_redlines=len(huawei_redlines), total_redlines=len(huawei_redlines),
) )
# ==================== 报告 ====================
@router.get("/executions/{execution_id}/report", summary="获取安全测试报告")
async def get_security_report(
execution_id: str,
db: AsyncSession = Depends(get_db)
):
"""
获取安全测试 Markdown 报告内容
Args:
execution_id: 执行ID
Returns:
dict: 报告内容
"""
from app.services.security_report_service import SecurityReportService
try:
report_service = SecurityReportService()
markdown = await report_service.get_report_content(execution_id, db)
return {
"execution_id": execution_id,
"content": markdown,
"format": "markdown",
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"获取安全测试报告失败: {e}")
raise HTTPException(status_code=500, detail=f"获取报告失败: {str(e)}")
@router.get("/executions/{execution_id}/report/download", summary="下载安全测试报告")
async def download_security_report(
execution_id: str,
db: AsyncSession = Depends(get_db)
):
"""
下载安全测试 Markdown 报告文件
Args:
execution_id: 执行ID
Returns:
FileResponse: 报告文件
"""
from fastapi.responses import FileResponse
from app.services.security_report_service import SecurityReportService
try:
report_service = SecurityReportService()
file_path = await report_service.generate_report(execution_id, db)
filename = os.path.basename(file_path)
return FileResponse(
file_path,
media_type="text/markdown",
filename=filename,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"下载安全测试报告失败: {e}")
raise HTTPException(status_code=500, detail=f"下载报告失败: {str(e)}")
...@@ -259,6 +259,8 @@ class ExecutionService: ...@@ -259,6 +259,8 @@ class ExecutionService:
ev_loop: 主事件循环(用于线程安全广播) ev_loop: 主事件循环(用于线程安全广播)
broadcast_fn: 异步广播函数(async broadcast) broadcast_fn: 异步广播函数(async broadcast)
""" """
logger.info(f"[run_all_cases_sync] 开始执行, 用例数: {len(cases_list)}")
executor_config = exec_config or {} executor_config = exec_config or {}
# auto_login 由用例自身配置决定,不强制覆盖 # auto_login 由用例自身配置决定,不强制覆盖
# 透传单步重试次数(默认 2) # 透传单步重试次数(默认 2)
...@@ -269,12 +271,14 @@ class ExecutionService: ...@@ -269,12 +271,14 @@ class ExecutionService:
try: try:
executor.start() executor.start()
logger.info(f"[run_all_cases_sync] 执行器启动成功")
# 第一个用例会自动登录 # 第一个用例会自动登录
for i, case_dict in enumerate(cases_list): for i, case_dict in enumerate(cases_list):
case_id = case_dict.get("id", "") case_id = case_dict.get("id", "")
case_name = case_dict.get("name", "") case_name = case_dict.get("name", "")
logger.info(f"⏳ 执行用例 {i+1}/{len(cases_list)}: {case_name}") steps_count = len(case_dict.get("steps", []))
logger.info(f"⏳ 执行用例 {i+1}/{len(cases_list)}: {case_name} (steps={steps_count})")
# 构造线程安全的步骤回调(跨线程提交到事件循环) # 构造线程安全的步骤回调(跨线程提交到事件循环)
def step_callback(step_result, _cid=case_id, _cname=case_name): def step_callback(step_result, _cid=case_id, _cname=case_name):
...@@ -302,10 +306,16 @@ class ExecutionService: ...@@ -302,10 +306,16 @@ class ExecutionService:
logger.error(f"✗ 步骤回调提交失败: {str(e)}") logger.error(f"✗ 步骤回调提交失败: {str(e)}")
result = executor.execute_case(case=case_dict, callback=step_callback) result = executor.execute_case(case=case_dict, callback=step_callback)
logger.info(f"[run_all_cases_sync] 用例 {case_name} 执行完成, status={result.status}")
results.append(result) results.append(result)
logger.info(f"[run_all_cases_sync] 所有用例执行完成, 结果数: {len(results)}")
return results return results
except Exception as e:
logger.error(f"[run_all_cases_sync] 异常: {type(e).__name__}: {e}")
raise
finally: finally:
logger.info("[run_all_cases_sync] 停止执行器")
executor.stop() executor.stop()
try: try:
......
...@@ -93,6 +93,18 @@ export function getSecuritySummary(executionId: string) { ...@@ -93,6 +93,18 @@ export function getSecuritySummary(executionId: string) {
) )
} }
/** 获取安全测试报告内容(Markdown) */
export function getSecurityReport(executionId: string) {
return request.get<{ execution_id: string; content: string; format: string }>(
`/api/security/executions/${executionId}/report`
)
}
/** 安全测试报告下载地址 */
export function getSecurityReportDownloadUrl(executionId: string): string {
return `/api/security/executions/${executionId}/report/download`
}
// ==================== 知识库 ==================== // ==================== 知识库 ====================
/** 获取安全知识库 */ /** 获取安全知识库 */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论