From 307ff42c2de0f7f33ddd1ed20878ddd39c8f24b8 Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Fri, 17 Apr 2026 16:40:57 +0800 Subject: [PATCH] =?UTF-8?q?[fix]=E7=BB=A7=E6=89=BF=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=201=E3=80=81=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E6=9C=AA=E9=87=8A=E6=94=BE=E9=94=81=202?= =?UTF-8?q?=E3=80=81=E5=8F=96=E6=B6=88=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/v1/history.py | 10 +++++----- backend/core/database.py | 19 +++++++++++++++++++ frontend/src/pages/history/HistoryPage.tsx | 16 +++++++++++++++- frontend/src/services/index.ts | 12 +++++++++--- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/backend/api/v1/history.py b/backend/api/v1/history.py index 589120c..0a676be 100644 --- a/backend/api/v1/history.py +++ b/backend/api/v1/history.py @@ -24,7 +24,7 @@ # get_db: 数据库会话的提供者函数(定义在 database.py 中) # FastAPI 的 Depends(get_db) 会自动调用它,创建一个 session 并在请求结束后关闭 -from backend.core.database import get_db +from backend.core.database import async_session_on_pinned_connection, get_db from backend.core.dependencies import get_current_user # 从 JWT 解析当前用户,未登录返回 401 # PageResponse: 通用分页响应模型 { items, total, page, page_size } from backend.schemas.common import PageResponse @@ -127,23 +127,23 @@ async def get_inherit_source_records_endpoint( @router.post("/inherit-failure-reason", response_model=InheritFailureReasonResponse) async def post_inherit_failure_reason( req: InheritFailureReasonRequest, - db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_user), ): """执行失败原因继承,支持批次维度和用例维度。""" operator_employee_id = payload.get("sub", "") - return await inherit_failure_reason(db, req, operator_employee_id) + async with async_session_on_pinned_connection() as db: + return await inherit_failure_reason(db, req, operator_employee_id) @router.post("/one-click-analyze", response_model=OneClickAnalyzeResponse) async def post_one_click_analyze( req: OneClickAnalyzeRequest, - db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_user), ): """一键分析:整批未分析失败/异常用例标记为 bug,跟踪人为用例开发责任人(姓名+工号)。""" analyzer_employee_id = payload.get("sub", "") - return await one_click_analyze(db, req, analyzer_employee_id) + async with async_session_on_pinned_connection() as db: + return await one_click_analyze(db, req, analyzer_employee_id) @router.post("/one-click-bug-notify", response_model=OneClickBugNotifyResponse) diff --git a/backend/core/database.py b/backend/core/database.py index fa9360d..c1bf58c 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -7,6 +7,8 @@ import logging import time +from contextlib import asynccontextmanager +from typing import AsyncIterator # SQLAlchemy 是 Python 最流行的 ORM(对象关系映射)库。 # "async" 前缀表示异步版本 — 不会阻塞其他请求,性能更好。 @@ -57,6 +59,23 @@ def _after_cursor_execute(conn, cursor, statement, parameters, context, executem async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) +@asynccontextmanager +async def async_session_on_pinned_connection() -> AsyncIterator[AsyncSession]: + """ + 在单条池连接上创建 AsyncSession,直至上下文结束再归还连接。 + + 普通 get_db 会话在 commit() 后可能把连接放回池并改用另一条连接执行后续 SQL; + 而 MySQL GET_LOCK / RELEASE_LOCK 必须发生在**同一连接**上,否则 RELEASE 无效、 + 锁会随旧连接滞留在池中,导致后续同名的 GET_LOCK 长时间阻塞直至超时。 + """ + async with engine.connect() as conn: + session = AsyncSession(bind=conn, expire_on_commit=False) + try: + yield session + finally: + await session.close() + + # 这是 FastAPI 的"依赖注入"函数 — 后面 API 层会通过 Depends(get_db) 来自动调用它 # 它是一个异步生成器(async generator),用 yield 而非 return: # 1. 请求进来时 → 创建一个数据库会话并交给 API 函数使用 diff --git a/frontend/src/pages/history/HistoryPage.tsx b/frontend/src/pages/history/HistoryPage.tsx index e6ae79c..909d7d2 100644 --- a/frontend/src/pages/history/HistoryPage.tsx +++ b/frontend/src/pages/history/HistoryPage.tsx @@ -250,6 +250,8 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const [inheritForm] = Form.useForm(); const [inheritModalVisible, setInheritModalVisible] = useState(false); const [inheritSubmitLoading, setInheritSubmitLoading] = useState(false); + /** 继承提交进行中时用于中止 HTTP,避免点取消后仍占服务端 GET_LOCK */ + const inheritAbortRef = useRef(null); const [oneClickLoading, setOneClickLoading] = useState(false); const [bugNotifyLoading, setBugNotifyLoading] = useState(false); const [reportModalVisible, setReportModalVisible] = useState(false); @@ -611,6 +613,8 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const openInheritModal = async () => { if (!processBtnEnabled) return; + inheritAbortRef.current?.abort(); + inheritAbortRef.current = null; setInheritModalVisible(true); setInheritSourceRecords([]); inheritForm.setFieldsValue({ @@ -665,8 +669,9 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { payload.source_pfr_id = values.source_pfr_id; payload.history_ids = failedOnlyIds.map(Number); } + inheritAbortRef.current = new AbortController(); setInheritSubmitLoading(true); - const res = await historyApi.inheritFailureReason(payload); + const res = await historyApi.inheritFailureReason(payload, inheritAbortRef.current.signal); message.success(res.message || `继承成功,共继承 ${res.inherited_count} 条`); setInheritModalVisible(false); inheritForm.resetFields(); @@ -678,6 +683,11 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { page_size: params.page_size ?? 20, }); } catch (e: unknown) { + const code = (e as { code?: string })?.code; + const name = (e as { name?: string })?.name; + if (code === "ERR_CANCELED" || name === "CanceledError") { + return; + } const err = e as { response?: { data?: { detail?: string } }; message?: string }; const msg = err?.response?.data?.detail || err?.message || "继承失败"; if (typeof msg === "string") { @@ -689,11 +699,15 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { message.error("继承失败"); } } finally { + inheritAbortRef.current = null; setInheritSubmitLoading(false); } }; const handleInheritModalCancel = () => { + inheritAbortRef.current?.abort(); + inheritAbortRef.current = null; + setInheritSubmitLoading(false); setInheritModalVisible(false); inheritForm.resetFields(); }; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 520765e..8c2933c 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -258,9 +258,15 @@ export const historyApi = { if (batch) params.batch = batch; return request.get("/history/inherit-source-records", { params }) as any; }, - /** 提交失败原因继承(大批量可能较慢,单独 60s 超时) */ - inheritFailureReason(data: InheritFailureReasonRequest): Promise { - return request.post("/history/inherit-failure-reason", data, { timeout: 60000 }) as any; + /** 提交失败原因继承(大批量可能较慢,单独 60s 超时);signal 用于关闭弹窗时中止请求、尽快释放服务端锁 */ + inheritFailureReason( + data: InheritFailureReasonRequest, + signal?: AbortSignal + ): Promise { + return request.post("/history/inherit-failure-reason", data, { + timeout: 60000, + signal, + }) as any; }, /** 一键分析:整批未分析失败/异常标记为 bug(锚点解析批次) */ oneClickAnalyze(data: OneClickAnalyzeRequest): Promise {