From 8d489a499f55c25ef47c8d2ff068eeb5c0e3a11d Mon Sep 17 00:00:00 2001 From: risingLee <871066422@qq.com> Date: Tue, 10 Feb 2026 16:35:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BA=BF=E7=A8=8Bbug?= =?UTF-8?q?=EF=BC=8C=E8=BF=9B=E5=BA=A6=E6=9D=A1=E7=BB=93=E6=9D=9F=E5=90=8E?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqlserver_writer.py | 81 +++++++++++++++++++++++----------- ui_main.py | 104 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/sqlserver_writer.py b/sqlserver_writer.py index c572c0b..6ccdee4 100644 --- a/sqlserver_writer.py +++ b/sqlserver_writer.py @@ -138,38 +138,23 @@ class SQLServerWriter: start_time = data.get('start_time') end_time = data.get('end_time') - # 检查记录是否已存在(基于 order_no, start_time, end_time 组合) + # 检查记录是否已存在 + # 目前数据库在 pump_600_no_load_run_in 上的唯一键约束是基于工单号(order_no), + # 所以这里优先按 order_no 检查是否存在,避免与数据库唯一键不一致导致插入冲突。 cursor = self.connection.cursor() - - # 构建查询条件 - if start_time and end_time: - # 如果有开始和结束时间,使用三个字段组合查询 - cursor.execute( - """SELECT id FROM pump_600_no_load_run_in - WHERE order_no = ? AND start_time = ? AND end_time = ?""", - (order_no, start_time, end_time) - ) - elif start_time: - # 只有开始时间 - cursor.execute( - """SELECT id FROM pump_600_no_load_run_in - WHERE order_no = ? AND start_time = ?""", - (order_no, start_time) - ) - else: - # 只有工单号(向后兼容) - cursor.execute( - "SELECT id FROM pump_600_no_load_run_in WHERE order_no = ?", - (order_no,) - ) + cursor.execute( + "SELECT id, start_time, end_time FROM pump_600_no_load_run_in WHERE order_no = ?", + (order_no,) + ) existing = cursor.fetchone() if existing: - existing_id = existing[0] + existing_id, existing_start, existing_end = existing logger.info( f"找到已存在的记录 (id={existing_id}, order_no={order_no}, " - f"start_time={start_time}, end_time={end_time}),将更新数据" + f"start_time={existing_start}, end_time={existing_end}),将更新数据 " + f"(新 start_time={start_time}, 新 end_time={end_time})" ) return self._update_pump_600_data_by_id(existing_id, data) else: @@ -277,10 +262,54 @@ class SQLServerWriter: return True except Exception as e: + # 处理唯一键冲突:如果工单号已存在,则回退为更新逻辑 + import pyodbc as _pyodbc # 局部导入以避免类型检查器告警 + is_integrity_error = isinstance(e, _pyodbc.IntegrityError) + error_text = str(e) + if is_integrity_error and ("2627" in error_text or "2601" in error_text): + order_no = data.get('order_no') + logger.warning( + f"插入 SQL Server 数据时发生唯一键冲突,将尝试改为更新记录 " + f"(order_no={order_no}, 错误={error_text})" + ) + try: + # 查找已存在记录的ID(按唯一键 order_no) + cursor = self.connection.cursor() + cursor.execute( + "SELECT id FROM pump_600_no_load_run_in WHERE order_no = ?", + (order_no,) + ) + row = cursor.fetchone() + if row: + record_id = row[0] + logger.info( + f"找到冲突记录 (id={record_id}, order_no={order_no}),执行更新替代插入" + ) + # 回滚当前事务后执行更新 + try: + self.connection.rollback() + except Exception: + pass + return self._update_pump_600_data_by_id(record_id, data) + else: + logger.error( + f"唯一键冲突但未能找到已存在记录 (order_no={order_no}),放弃更新" + ) + except Exception as e2: + logger.error( + f"处理唯一键冲突为更新时失败: {e2}", + exc_info=True + ) + try: + self.connection.rollback() + except Exception: + pass + return False + logger.error(f"插入 SQL Server 数据失败: {e}", exc_info=True) try: self.connection.rollback() - except: + except Exception: pass return False diff --git a/ui_main.py b/ui_main.py index 48380ed..06131f7 100644 --- a/ui_main.py +++ b/ui_main.py @@ -1118,6 +1118,7 @@ class MainWindow(QMainWindow): # 定义信号用于线程安全的UI更新 state_changed_signal = Signal(str, str) # old_state, new_state connection_changed_signal = Signal(bool, str) # is_connected, message + reload_experiments_signal = Signal() def __init__(self) -> None: super().__init__() @@ -1133,6 +1134,8 @@ class MainWindow(QMainWindow): # 连接信号到槽函数 self.state_changed_signal.connect(self._handle_state_change_in_main_thread) self.connection_changed_signal.connect(self._handle_connection_change_in_main_thread) + # 刷新实验列表必须在主线程执行(后台线程通过 emit 请求刷新) + self.reload_experiments_signal.connect(self._reload_experiments, Qt.QueuedConnection) self.template_path: Path | None = None self.config: AppConfig = AppConfig() @@ -2639,8 +2642,20 @@ class MainWindow(QMainWindow): thread.quit() # 使用QTimer延迟等待,避免在信号槽中直接调用wait导致死锁 def do_wait(): + # 线程还在跑就继续等,避免 “QThread: Destroyed while thread is still running” if thread.isRunning(): - thread.wait(100) # 最多等待100ms + # 不在这里强行 deleteLater,等线程真正结束后再清理 + QTimer.singleShot(50, do_wait) + return + # 线程已结束:请求在其所属线程中安全销毁 + try: + worker.deleteLater() + except Exception: + pass + try: + thread.deleteLater() + except Exception: + pass # 从列表中移除 try: self._bg_threads.remove(thread) @@ -2663,7 +2678,10 @@ class MainWindow(QMainWindow): progress.show() bridge = ProgressBridge() - bridge.updated.connect(lambda msg, cur, total: progress.setLabelText(f"{msg} ({cur}/{total})")) + # 进度更新也可能来自后台线程,必须使用 QueuedConnection + def _on_progress_updated(msg: str, cur: int, total: int) -> None: + progress.setLabelText(f"{msg} ({cur}/{total})") + bridge.updated.connect(_on_progress_updated, Qt.QueuedConnection) def _progress_text(msg: str, cur: int, total: int) -> None: bridge.updated.emit(msg, cur, total) @@ -2683,6 +2701,11 @@ class MainWindow(QMainWindow): set_progress_callback(None) if not progress.wasCanceled() and not cancelled[0]: progress.close() + # 确保在主线程事件循环中销毁对话框,避免跨线程析构告警 + try: + progress.deleteLater() + except Exception: + pass # 使用安全的清理方法 self._cleanup_thread_safe(thread, worker) if cancelled[0]: @@ -2703,9 +2726,29 @@ class MainWindow(QMainWindow): progress.canceled.connect(on_cancel) - worker.finished.connect(lambda res: (None if cancelled[0] else (_cleanup(True), on_success(res)))) - worker.failed.connect(lambda msg: (None if cancelled[0] else (_cleanup(), (self.logger.error("Task failed: %s", msg), QMessageBox.critical(self, "错误", msg))))) - worker.cancelled.connect(lambda: (_cleanup() if not cancelled[0] else None)) + # 注意:worker 的信号在其所属线程中发射。为了避免跨线程操作 Qt UI(会导致卡死/警告), + # 这里必须使用 QueuedConnection,确保回调在主线程事件循环中执行。 + def on_finished(res): + if cancelled[0]: + return + _cleanup(True) + on_success(res) + + def on_failed(msg): + if cancelled[0]: + return + _cleanup() + self.logger.error("Task failed: %s", msg) + QMessageBox.critical(self, "错误", msg) + + def on_cancelled(): + if cancelled[0]: + return + _cleanup() + + worker.finished.connect(on_finished, Qt.QueuedConnection) + worker.failed.connect(on_failed, Qt.QueuedConnection) + worker.cancelled.connect(on_cancelled, Qt.QueuedConnection) self._bg_threads.append(thread) self._bg_workers.append(worker) thread.start() @@ -3906,6 +3949,18 @@ class MainWindow(QMainWindow): self._reload_experiments() def _reload_experiments(self) -> None: + # 保护:如果被后台线程误调用,立即切回主线程执行 + try: + if QThread.currentThread() != self.thread(): + try: + self.reload_experiments_signal.emit() + except Exception: + pass + return + except Exception: + # 线程检查失败时继续尝试执行(但正常情况下不会发生) + pass + # 更新配置对话框中的实验表格(如果存在) if hasattr(self, "exp_table") and self.exp_table is not None: try: @@ -4687,9 +4742,11 @@ class MainWindow(QMainWindow): db.close() self.logger.info(f"[SQL Server] 更新实验 {exp_id} 状态为: {status}") - - # 刷新列表显示 - self._reload_experiments() + # 刷新列表显示:可能从后台线程调用,必须通过信号切回主线程 + try: + self.reload_experiments_signal.emit() + except Exception: + pass except Exception as e: self.logger.error(f"[SQL Server] 更新状态失败: {e}", exc_info=True) @@ -6071,6 +6128,37 @@ def run_app() -> None: import sys app = QApplication(sys.argv) + + # 安装 Qt 消息处理器:当出现 “different thread” 等警告时打印 Python 调用栈与线程信息, + # 用于精确定位是哪段代码在后台线程操作了 UI。 + try: + import threading + import traceback + from PySide6.QtCore import qInstallMessageHandler + from logger import get_logger + + _qt_logger = get_logger("qt") + + def _qt_message_handler(msg_type, context, message): + try: + text = str(message) + # 只对高风险跨线程告警输出堆栈,避免日志爆炸 + if "different thread" in text or "Timers cannot be started from another thread" in text: + _qt_logger.error( + "QtThreadWarning: %s | thread=%s (%s)", + text, + threading.current_thread().name, + threading.get_ident(), + ) + _qt_logger.error("QtThreadWarning stack:\n%s", "".join(traceback.format_stack(limit=40))) + else: + _qt_logger.warning("Qt: %s", text) + except Exception: + pass + + qInstallMessageHandler(_qt_message_handler) + except Exception: + pass # 显示启动界面 from splash_screen import SplashScreen