fix(kdocs): 修复上传线程卡住和超时问题
1. 禁用无效的二分搜索 - _get_cell_value_fast() 使用的 DOM 选择器在金山文档中不存在 2. 移除 _upload_image_to_cell 中重复的导航调用 3. 为 expect_file_chooser 添加 15 秒超时防止无限阻塞 4. 包含看门狗自动恢复机制(之前已实现) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
KDocs Uploader with Auto-Recovery Mechanism
|
||||||
|
自动恢复机制:当检测到上传线程卡住时,自动重启线程
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -31,11 +35,16 @@ except Exception: # pragma: no cover - 运行环境缺少 playwright 时降级
|
|||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
|
# 看门狗配置
|
||||||
|
WATCHDOG_CHECK_INTERVAL = 60 # 每60秒检查一次
|
||||||
|
WATCHDOG_TIMEOUT = 300 # 如果5分钟没有活动且队列有任务,认为线程卡住
|
||||||
|
|
||||||
|
|
||||||
class KDocsUploader:
|
class KDocsUploader:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._queue: queue.Queue = queue.Queue(maxsize=int(os.environ.get("KDOCS_QUEUE_MAXSIZE", "200")))
|
self._queue: queue.Queue = queue.Queue(maxsize=int(os.environ.get("KDOCS_QUEUE_MAXSIZE", "200")))
|
||||||
self._thread = threading.Thread(target=self._run, name="kdocs-uploader", daemon=True)
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._thread_id = 0 # 线程ID,用于追踪重启次数
|
||||||
self._running = False
|
self._running = False
|
||||||
self._last_error: Optional[str] = None
|
self._last_error: Optional[str] = None
|
||||||
self._last_success_at: Optional[float] = None
|
self._last_success_at: Optional[float] = None
|
||||||
@@ -49,17 +58,108 @@ class KDocsUploader:
|
|||||||
self._last_login_ok: Optional[bool] = None
|
self._last_login_ok: Optional[bool] = None
|
||||||
self._doc_url: Optional[str] = None
|
self._doc_url: Optional[str] = None
|
||||||
|
|
||||||
|
# 自动恢复机制相关
|
||||||
|
self._last_activity: float = time.time() # 最后活动时间
|
||||||
|
self._watchdog_thread: Optional[threading.Thread] = None
|
||||||
|
self._watchdog_running = False
|
||||||
|
self._restart_count = 0 # 重启次数统计
|
||||||
|
self._lock = threading.Lock() # 线程安全锁
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._running:
|
with self._lock:
|
||||||
return
|
if self._running:
|
||||||
self._running = True
|
return
|
||||||
self._thread.start()
|
self._running = True
|
||||||
|
self._thread_id += 1
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name=f"kdocs-uploader-{self._thread_id}",
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
self._last_activity = time.time()
|
||||||
|
|
||||||
|
# 启动看门狗线程
|
||||||
|
if not self._watchdog_running:
|
||||||
|
self._watchdog_running = True
|
||||||
|
self._watchdog_thread = threading.Thread(
|
||||||
|
target=self._watchdog_run,
|
||||||
|
name="kdocs-watchdog",
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._watchdog_thread.start()
|
||||||
|
logger.info("[KDocs] 看门狗线程已启动")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if not self._running:
|
with self._lock:
|
||||||
return
|
if not self._running:
|
||||||
self._running = False
|
return
|
||||||
self._queue.put({"action": "shutdown"})
|
self._running = False
|
||||||
|
self._watchdog_running = False
|
||||||
|
self._queue.put({"action": "shutdown"})
|
||||||
|
|
||||||
|
def _watchdog_run(self) -> None:
|
||||||
|
"""看门狗线程:监控上传线程健康状态"""
|
||||||
|
logger.info("[KDocs] 看门狗开始监控")
|
||||||
|
while self._watchdog_running:
|
||||||
|
try:
|
||||||
|
time.sleep(WATCHDOG_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查线程是否存活
|
||||||
|
if self._thread is None or not self._thread.is_alive():
|
||||||
|
logger.warning("[KDocs] 检测到上传线程已停止,正在重启...")
|
||||||
|
self._restart_thread()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否有任务堆积且长时间无活动
|
||||||
|
queue_size = self._queue.qsize()
|
||||||
|
time_since_activity = time.time() - self._last_activity
|
||||||
|
|
||||||
|
if queue_size > 0 and time_since_activity > WATCHDOG_TIMEOUT:
|
||||||
|
logger.warning(
|
||||||
|
f"[KDocs] 检测到上传线程可能卡住: "
|
||||||
|
f"队列={queue_size}, 无活动时间={time_since_activity:.0f}秒"
|
||||||
|
)
|
||||||
|
self._restart_thread()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[KDocs] 看门狗检查异常: {e}")
|
||||||
|
|
||||||
|
def _restart_thread(self) -> None:
|
||||||
|
"""重启上传线程"""
|
||||||
|
with self._lock:
|
||||||
|
self._restart_count += 1
|
||||||
|
logger.warning(f"[KDocs] 正在重启上传线程 (第{self._restart_count}次重启)")
|
||||||
|
|
||||||
|
# 清理浏览器资源
|
||||||
|
try:
|
||||||
|
self._cleanup_browser()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[KDocs] 清理浏览器时出错: {e}")
|
||||||
|
|
||||||
|
# 停止旧线程(如果还在运行)
|
||||||
|
old_running = self._running
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# 等待一小段时间让旧线程有机会退出
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 启动新线程
|
||||||
|
self._running = True
|
||||||
|
self._thread_id += 1
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name=f"kdocs-uploader-{self._thread_id}",
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
self._last_activity = time.time()
|
||||||
|
self._last_error = f"线程已自动恢复 (第{self._restart_count}次)"
|
||||||
|
logger.info(f"[KDocs] 上传线程已重启 (ID={self._thread_id})")
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -68,6 +168,8 @@ class KDocsUploader:
|
|||||||
"last_error": self._last_error,
|
"last_error": self._last_error,
|
||||||
"last_success_at": self._last_success_at,
|
"last_success_at": self._last_success_at,
|
||||||
"last_login_ok": self._last_login_ok,
|
"last_login_ok": self._last_login_ok,
|
||||||
|
"restart_count": self._restart_count, # 新增:重启次数
|
||||||
|
"thread_alive": self._thread.is_alive() if self._thread else False, # 新增:线程状态
|
||||||
}
|
}
|
||||||
|
|
||||||
def enqueue_upload(
|
def enqueue_upload(
|
||||||
@@ -130,28 +232,57 @@ class KDocsUploader:
|
|||||||
return {"success": False, "error": "操作超时"}
|
return {"success": False, "error": "操作超时"}
|
||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
while True:
|
thread_id = self._thread_id
|
||||||
task = self._queue.get()
|
logger.info(f"[KDocs] 上传线程启动 (ID={thread_id})")
|
||||||
if not task:
|
|
||||||
continue
|
|
||||||
action = task.get("action")
|
|
||||||
if action == "shutdown":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
if action == "upload":
|
|
||||||
self._handle_upload(task.get("payload") or {})
|
|
||||||
elif action == "qr":
|
|
||||||
result = self._handle_qr(task.get("payload") or {})
|
|
||||||
task.get("response").put(result)
|
|
||||||
elif action == "clear_login":
|
|
||||||
result = self._handle_clear_login()
|
|
||||||
task.get("response").put(result)
|
|
||||||
elif action == "status":
|
|
||||||
result = self._handle_status_check()
|
|
||||||
task.get("response").put(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[KDocs] 处理任务失败: {e}")
|
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# 使用超时获取任务,以便定期检查 _running 状态
|
||||||
|
try:
|
||||||
|
task = self._queue.get(timeout=5)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 更新最后活动时间
|
||||||
|
self._last_activity = time.time()
|
||||||
|
|
||||||
|
action = task.get("action")
|
||||||
|
if action == "shutdown":
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == "upload":
|
||||||
|
self._handle_upload(task.get("payload") or {})
|
||||||
|
elif action == "qr":
|
||||||
|
result = self._handle_qr(task.get("payload") or {})
|
||||||
|
task.get("response").put(result)
|
||||||
|
elif action == "clear_login":
|
||||||
|
result = self._handle_clear_login()
|
||||||
|
task.get("response").put(result)
|
||||||
|
elif action == "status":
|
||||||
|
result = self._handle_status_check()
|
||||||
|
task.get("response").put(result)
|
||||||
|
|
||||||
|
# 任务处理完成后更新活动时间
|
||||||
|
self._last_activity = time.time()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[KDocs] 处理任务失败: {e}")
|
||||||
|
# 如果有响应队列,返回错误
|
||||||
|
if "response" in task and task.get("response"):
|
||||||
|
try:
|
||||||
|
task["response"].put({"success": False, "error": str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[KDocs] 线程主循环异常: {e}")
|
||||||
|
time.sleep(1) # 避免异常时的紧密循环
|
||||||
|
|
||||||
|
logger.info(f"[KDocs] 上传线程退出 (ID={thread_id})")
|
||||||
self._cleanup_browser()
|
self._cleanup_browser()
|
||||||
|
|
||||||
def _load_system_config(self) -> Dict[str, Any]:
|
def _load_system_config(self) -> Dict[str, Any]:
|
||||||
@@ -180,6 +311,7 @@ class KDocsUploader:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._last_error = f"浏览器启动失败: {e}"
|
self._last_error = f"浏览器启动失败: {e}"
|
||||||
self._cleanup_browser()
|
self._cleanup_browser()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cleanup_browser(self) -> None:
|
def _cleanup_browser(self) -> None:
|
||||||
@@ -784,6 +916,7 @@ class KDocsUploader:
|
|||||||
self._login_required = False
|
self._login_required = False
|
||||||
self._last_login_ok = None
|
self._last_login_ok = None
|
||||||
self._cleanup_browser()
|
self._cleanup_browser()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
def _handle_status_check(self) -> Dict[str, Any]:
|
def _handle_status_check(self) -> Dict[str, Any]:
|
||||||
@@ -1335,7 +1468,7 @@ class KDocsUploader:
|
|||||||
logger.info("[KDocs调试] ====================================")
|
logger.info("[KDocs调试] ====================================")
|
||||||
|
|
||||||
def _find_person_with_unit(
|
def _find_person_with_unit(
|
||||||
self, unit: str, name: str, unit_col: str, max_attempts: int = 50, row_start: int = 0, row_end: int = 0
|
self, unit: str, name: str, unit_col: str, max_attempts: int = 10, row_start: int = 0, row_end: int = 0
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
查找人员所在行号。
|
查找人员所在行号。
|
||||||
@@ -1359,17 +1492,17 @@ class KDocsUploader:
|
|||||||
# 只搜索姓名 - 这是目前唯一可靠的方式
|
# 只搜索姓名 - 这是目前唯一可靠的方式
|
||||||
logger.info(f"[KDocs调试] 搜索姓名: '{name}'")
|
logger.info(f"[KDocs调试] 搜索姓名: '{name}'")
|
||||||
|
|
||||||
# 首先尝试二分搜索优化
|
# 注意: 二分搜索已禁用 - _get_cell_value_fast() 使用的 DOM 选择器在金山文档中不存在
|
||||||
binary_result = self._binary_search_person(name, unit_col, row_start, row_end)
|
# 直接使用线性搜索,这是唯一可靠的方法
|
||||||
if binary_result > 0:
|
# binary_result = self._binary_search_person(name, unit_col, row_start, row_end)
|
||||||
logger.info(f"[KDocs调试] [OK] 二分搜索成功! 找到行号={binary_result}")
|
# if binary_result > 0:
|
||||||
# 缓存结果
|
# logger.info(f"[KDocs调试] [OK] 二分搜索成功! 找到行号={binary_result}")
|
||||||
if not hasattr(self, "_person_cache"):
|
# if not hasattr(self, "_person_cache"):
|
||||||
self._person_cache = {}
|
# self._person_cache = {}
|
||||||
self._person_cache[cache_key] = binary_result
|
# self._person_cache[cache_key] = binary_result
|
||||||
return binary_result
|
# return binary_result
|
||||||
|
|
||||||
# 如果二分搜索失败,回退到线性搜索
|
# 使用线性搜索(Ctrl+F 方式)
|
||||||
row_num = self._search_and_get_row(
|
row_num = self._search_and_get_row(
|
||||||
name, max_attempts=max_attempts, expected_col="C", row_start=row_start, row_end=row_end
|
name, max_attempts=max_attempts, expected_col="C", row_start=row_start, row_end=row_end
|
||||||
)
|
)
|
||||||
@@ -1562,8 +1695,7 @@ class KDocsUploader:
|
|||||||
|
|
||||||
def _upload_image_to_cell(self, row_num: int, image_path: str, image_col: str) -> bool:
|
def _upload_image_to_cell(self, row_num: int, image_path: str, image_col: str) -> bool:
|
||||||
cell_address = f"{image_col}{row_num}"
|
cell_address = f"{image_col}{row_num}"
|
||||||
self._navigate_to_cell(cell_address)
|
# 注意: 移除了重复的导航调用,只保留一次导航
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
# 清除单元格现有内容
|
# 清除单元格现有内容
|
||||||
try:
|
try:
|
||||||
@@ -1603,7 +1735,8 @@ class KDocsUploader:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
local_option = self._page.get_by_role("option", name="本地")
|
local_option = self._page.get_by_role("option", name="本地")
|
||||||
with self._page.expect_file_chooser() as fc_info:
|
# 添加超时防止无限阻塞
|
||||||
|
with self._page.expect_file_chooser(timeout=15000) as fc_info:
|
||||||
local_option.click()
|
local_option.click()
|
||||||
file_chooser = fc_info.value
|
file_chooser = fc_info.value
|
||||||
file_chooser.set_files(image_path)
|
file_chooser.set_files(image_path)
|
||||||
@@ -1623,3 +1756,4 @@ def get_kdocs_uploader() -> KDocsUploader:
|
|||||||
_kdocs_uploader = KDocsUploader()
|
_kdocs_uploader = KDocsUploader()
|
||||||
_kdocs_uploader.start()
|
_kdocs_uploader.start()
|
||||||
return _kdocs_uploader
|
return _kdocs_uploader
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user