1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-08-02 22:19:37 +08:00

fix(bulletin): 修复公告栏系统多项逻辑缺陷与线程安全隐患

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:17:26 +08:00
parent a716e5b945
commit 6f32923865
5 changed files with 226 additions and 111 deletions
+27 -12
View File
@@ -130,7 +130,11 @@ class ALBulletinItemWidget(QWidget):
self.ItemWidgetLayout.addLayout(self.BulletinTitleLayout) self.ItemWidgetLayout.addLayout(self.BulletinTitleLayout)
self.BulletinInfoLayout = QHBoxLayout() self.BulletinInfoLayout = QHBoxLayout()
self.BulletinDateLabel = QLabel() self.BulletinDateLabel = QLabel()
date_time = datetime.fromisoformat(self.__bulletin.get("dateTime", "")) try:
raw_dt = self.__bulletin.get("dateTime", "")
date_time = datetime.fromisoformat(raw_dt) if raw_dt else datetime.now()
except (ValueError, TypeError):
date_time = datetime.now()
self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S")) self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S"))
self.BulletinDateLabel.setStyleSheet("color: #969696; font-size: 11px;") self.BulletinDateLabel.setStyleSheet("color: #969696; font-size: 11px;")
self.BulletinInfoLayout.addWidget(self.BulletinDateLabel) self.BulletinInfoLayout.addWidget(self.BulletinDateLabel)
@@ -258,7 +262,11 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog):
): ):
self.BulletinTitleLabel.setText(bulletin.get("title", "无标题")) self.BulletinTitleLabel.setText(bulletin.get("title", "无标题"))
date_time = datetime.fromisoformat(bulletin.get("dateTime", "")) try:
raw_dt = bulletin.get("dateTime", "")
date_time = datetime.fromisoformat(raw_dt) if raw_dt else datetime.now()
except (ValueError, TypeError):
date_time = datetime.now()
self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S")) self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S"))
self.BulletinAuthorLabel.setText(bulletin.get("author", "未知")) self.BulletinAuthorLabel.setText(bulletin.get("author", "未知"))
if bulletin.get("isEdited", False): if bulletin.get("isEdited", False):
@@ -306,12 +314,15 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog):
data: dict data: dict
): ):
if self.__fetch_worker: worker = self.sender()
self.__fetch_worker.wait(2000) if worker is not self.__fetch_worker:
self.__fetch_worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) return
self.__fetch_worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched)
self.__fetch_worker.deleteLater() worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError)
worker.wait(2000)
worker.deleteLater()
self.__fetch_worker = None self.__fetch_worker = None
bulletins = data.get("bulletins", []) bulletins = data.get("bulletins", [])
delete_ids = data.get("delete_ids", []) delete_ids = data.get("delete_ids", [])
merged = self.__bulletin_mgr.updateAndMergeBulletins(bulletins, delete_ids) merged = self.__bulletin_mgr.updateAndMergeBulletins(bulletins, delete_ids)
@@ -336,12 +347,15 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog):
error_message: str error_message: str
): ):
if self.__fetch_worker: worker = self.sender()
self.__fetch_worker.wait(2000) if worker is not self.__fetch_worker:
self.__fetch_worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) return
self.__fetch_worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched)
self.__fetch_worker.deleteLater() worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError)
worker.wait(2000)
worker.deleteLater()
self.__fetch_worker = None self.__fetch_worker = None
self.SyncButton.setEnabled(True) self.SyncButton.setEnabled(True)
self.SyncButton.setText("重试") self.SyncButton.setText("重试")
self.ALSyncStatusLabel.status = ALStatusLabel.Status.FAILURE self.ALSyncStatusLabel.status = ALStatusLabel.Status.FAILURE
@@ -376,6 +390,7 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog):
if bulletin.get("isNew", False): if bulletin.get("isNew", False):
bulletin["isNew"] = False bulletin["isNew"] = False
widget = self.BulletinListWidget.itemWidget(item) widget = self.BulletinListWidget.itemWidget(item)
if widget:
widget.markAsRead() widget.markAsRead()
item.setData(Qt.UserRole, bulletin) item.setData(Qt.UserRole, bulletin)
self.__bulletin_mgr.markBulletinAsRead(bulletin["id"]) self.__bulletin_mgr.markBulletinAsRead(bulletin["id"])
+48 -30
View File
@@ -42,12 +42,14 @@ class ALBulletinPoller(QObject):
self.__timer.timeout.connect(self.__poll) self.__timer.timeout.connect(self.__poll)
self.__worker = None self.__worker = None
self.__dialog_open = False self.__dialog_open = False
self.__stopped = False
self.__mgr = bulletinInstance() self.__mgr = bulletinInstance()
def start( def start(
self self
): ):
self.__stopped = False
interval_ms = self.__mgr.syncInterval()*60*1000 interval_ms = self.__mgr.syncInterval()*60*1000
self.__timer.start(interval_ms) self.__timer.start(interval_ms)
@@ -55,6 +57,7 @@ class ALBulletinPoller(QObject):
self self
): ):
self.__stopped = True
self.__timer.stop() self.__timer.stop()
self.__cleanupWorker() self.__cleanupWorker()
@@ -88,6 +91,31 @@ class ALBulletinPoller(QObject):
self.__dialog_open = open self.__dialog_open = open
def __disconnectWorker(
self,
worker: ALBulletinFetchWorker
):
try:
worker.fetchWorkerIsFinished.disconnect(self.__onFetched)
except (TypeError, RuntimeError):
pass
try:
worker.fetchWorkerFinishedWithError.disconnect(self.__onError)
except (TypeError, RuntimeError):
pass
def __cleanupWorker(
self
):
if self.__worker is None:
return
self.__disconnectWorker(self.__worker)
self.__worker.wait(2000)
self.__worker.deleteLater()
self.__worker = None
def __doFetch( def __doFetch(
self self
): ):
@@ -117,21 +145,28 @@ class ALBulletinPoller(QObject):
data: dict data: dict
): ):
if self.__worker is None: worker = self.sender()
if worker is not self.__worker:
return return
self.__worker.fetchWorkerIsFinished.disconnect(self.__onFetched) self.__disconnectWorker(worker)
self.__worker.fetchWorkerFinishedWithError.disconnect(self.__onError) worker.wait(2000)
self.__worker.wait(2000) worker.deleteLater()
self.__worker.deleteLater()
self.__worker = None self.__worker = None
old_ids = {b["id"] for b in self.__mgr.bulletins()} old_ids = {str(b.get("id", "")) for b in self.__mgr.bulletins()}
bulletins = data.get("bulletins", []) bulletins = data.get("bulletins", [])
delete_ids = data.get("delete_ids", []) delete_ids = data.get("delete_ids", [])
self.__mgr.updateAndMergeBulletins(bulletins, delete_ids) self.__mgr.updateAndMergeBulletins(bulletins, delete_ids)
self.__mgr.setLastSyncTime(datetime.now().astimezone().isoformat()) self.__mgr.setLastSyncTime(datetime.now().astimezone().isoformat())
new_ids = {b["id"] for b in bulletins if b["id"] not in old_ids} delete_id_set = {str(d) for d in delete_ids}
new_ids = {
str(b.get("id", ""))
for b in bulletins
if str(b.get("id", "")) and str(b.get("id", "")) not in old_ids
}
new_ids -= delete_id_set
if new_ids: if new_ids:
self.newBulletinsDetected.emit(len(new_ids)) self.newBulletinsDetected.emit(len(new_ids))
if not self.__stopped:
interval_ms = self.__mgr.syncInterval()*60*1000 interval_ms = self.__mgr.syncInterval()*60*1000
self.__timer.start(interval_ms) self.__timer.start(interval_ms)
@@ -141,30 +176,13 @@ class ALBulletinPoller(QObject):
error_message: str error_message: str
): ):
if self.__worker is None: worker = self.sender()
if worker is not self.__worker:
return return
self.__worker.fetchWorkerIsFinished.disconnect(self.__onFetched) self.__disconnectWorker(worker)
self.__worker.fetchWorkerFinishedWithError.disconnect(self.__onError) worker.wait(2000)
self.__worker.wait(2000) worker.deleteLater()
self.__worker.deleteLater()
self.__worker = None self.__worker = None
if not self.__stopped:
interval_ms = self.__mgr.syncInterval()*60*1000 interval_ms = self.__mgr.syncInterval()*60*1000
self.__timer.start(interval_ms) self.__timer.start(interval_ms)
def __cleanupWorker(
self
):
if self.__worker is None:
return
try:
self.__worker.fetchWorkerIsFinished.disconnect()
except (TypeError, RuntimeError):
pass
try:
self.__worker.fetchWorkerFinishedWithError.disconnect()
except (TypeError, RuntimeError):
pass
self.__worker.wait(2000)
self.__worker.deleteLater()
self.__worker = None
+48 -16
View File
@@ -458,33 +458,65 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget):
self.BulletinTestStatusLabel.setText("请先输入服务器地址。") self.BulletinTestStatusLabel.setText("请先输入服务器地址。")
self.BulletinTestStatusLabel.setStyleSheet("color: red;") self.BulletinTestStatusLabel.setStyleSheet("color: red;")
return return
if hasattr(self, '__bulletin_test_worker') and self.__bulletin_test_worker is not None:
return
self.BulletinTestButton.setEnabled(False) self.BulletinTestButton.setEnabled(False)
self.BulletinTestStatusLabel.setText("正在测试连接...") self.BulletinTestStatusLabel.setText("正在测试连接...")
self.BulletinTestStatusLabel.setStyleSheet("") self.BulletinTestStatusLabel.setStyleSheet("")
try: self.__bulletin_test_t0 = time.monotonic()
from gui.ALBulletinDialog import ALBulletinFetchWorker
api_url = url.rstrip("/") + "/bulletins" api_url = url.rstrip("/") + "/bulletins"
t0 = time.monotonic() self.__bulletin_test_worker = ALBulletinFetchWorker(
response = requests.get(api_url, timeout=5) self, api_url, {"date": "", "time": "", "range_hour": "1"}
elapsed_ms = (time.monotonic() - t0) * 1000 )
response.raise_for_status() self.__bulletin_test_worker.fetchWorkerIsFinished.connect(
data = response.json() self.__onBulletinTestFetched
if data.get("code") == 200: )
self.__bulletin_test_worker.fetchWorkerFinishedWithError.connect(
self.__onBulletinTestError
)
self.__bulletin_test_worker.start()
@Slot(dict)
def __onBulletinTestFetched(
self,
data: dict
):
self.__bulletin_test_worker.fetchWorkerIsFinished.disconnect(
self.__onBulletinTestFetched
)
self.__bulletin_test_worker.fetchWorkerFinishedWithError.disconnect(
self.__onBulletinTestError
)
self.__bulletin_test_worker.deleteLater()
self.__bulletin_test_worker = None
elapsed_ms = (time.monotonic() - self.__bulletin_test_t0) * 1000
self.BulletinTestStatusLabel.setText( self.BulletinTestStatusLabel.setText(
f"连接成功!响应延迟 {elapsed_ms:.0f} ms" f"连接成功!响应延迟 {elapsed_ms:.0f} ms"
) )
self.BulletinTestStatusLabel.setStyleSheet("color: green;") self.BulletinTestStatusLabel.setStyleSheet("color: green;")
else:
self.BulletinTestStatusLabel.setText(
f"服务器返回异常: [{data.get('code', '?')}] {data.get('msg', '未知错误')}"
)
self.BulletinTestStatusLabel.setStyleSheet("color: red;")
except Exception as e:
self.BulletinTestStatusLabel.setText(f"连接失败:{e}")
self.BulletinTestStatusLabel.setStyleSheet("color: red;")
finally:
self.BulletinTestButton.setEnabled(True) self.BulletinTestButton.setEnabled(True)
QTimer.singleShot(3000, self.clearBulletinTestStatus) QTimer.singleShot(3000, self.clearBulletinTestStatus)
@Slot(str)
def __onBulletinTestError(
self,
error_message: str
):
self.__bulletin_test_worker.fetchWorkerIsFinished.disconnect(
self.__onBulletinTestFetched
)
self.__bulletin_test_worker.fetchWorkerFinishedWithError.disconnect(
self.__onBulletinTestError
)
self.__bulletin_test_worker.deleteLater()
self.__bulletin_test_worker = None
self.BulletinTestStatusLabel.setText(f"连接失败:{error_message}")
self.BulletinTestStatusLabel.setStyleSheet("color: red;")
self.BulletinTestButton.setEnabled(True)
@Slot() @Slot()
def onCancelButtonClicked( def onCancelButtonClicked(
self self
+71 -21
View File
@@ -9,7 +9,7 @@ See the LICENSE file for details.
""" """
import threading import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from interfaces.ConfigProvider import ( from interfaces.ConfigProvider import (
@@ -118,13 +118,18 @@ class BulletinManager:
""" """
Get auto-sync interval in minutes. Get auto-sync interval in minutes.
Values below 1 are clamped to 5 minutes. Values below 1 are clamped upward. Non-integer or missing
values fall back to the default of 10 minutes.
Returns: Returns:
int: Sync interval (minutes), minimum 1. int: Sync interval (minutes), minimum 1.
""" """
interval = self.__cfg.get(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, 10) interval = self.__cfg.get(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, 10)
try:
interval = int(interval)
except (ValueError, TypeError):
return 10
if interval < 1: if interval < 1:
return 5 return 5
return interval return interval
@@ -142,6 +147,26 @@ class BulletinManager:
base = self.serverUrl().rstrip("/") base = self.serverUrl().rstrip("/")
return f"{base}/bulletins" return f"{base}/bulletins"
@staticmethod
def _ensureAware(
dt_value: datetime
) -> datetime:
if dt_value.tzinfo is None:
return dt_value.replace(tzinfo=timezone.utc)
return dt_value
@staticmethod
def _parseBulletinDateTime(
b: dict
) -> datetime:
raw = b.get("dateTime", "")
if not raw:
raise ValueError("empty dateTime")
dt_value = datetime.fromisoformat(raw)
return BulletinManager._ensureAware(dt_value)
def isFirstSync( def isFirstSync(
self self
) -> bool: ) -> bool:
@@ -152,6 +177,7 @@ class BulletinManager:
bool: True if no cached bulletins or no last sync time. bool: True if no cached bulletins or no last sync time.
""" """
with self.__lock:
last = self.lastSyncTime() last = self.lastSyncTime()
bulletins = self.bulletins() bulletins = self.bulletins()
return last is None or not bulletins return last is None or not bulletins
@@ -169,12 +195,15 @@ class BulletinManager:
bool: True if a full sync should be performed. bool: True if a full sync should be performed.
""" """
with self.__lock:
last = self.lastSyncTime() last = self.lastSyncTime()
if last is None: if last is None:
return True return True
try: try:
last_dt = datetime.fromisoformat(last) last_dt = datetime.fromisoformat(last)
return (datetime.now().astimezone() - last_dt) > timedelta(hours=1) last_dt = self._ensureAware(last_dt)
now = datetime.now(timezone.utc).astimezone()
return (now - last_dt) > timedelta(hours=1)
except (ValueError, TypeError): except (ValueError, TypeError):
return True return True
@@ -184,25 +213,35 @@ class BulletinManager:
""" """
Calculate the date / time / range_hour query parameters. Calculate the date / time / range_hour query parameters.
Falls back to a first-sync (7-day window) on any parsing error.
Returns: Returns:
dict: Keys "date", "time", "range_hour" for the API request. dict: Keys "date", "time", "range_hour" for the API request.
""" """
now = datetime.now(timezone.utc).astimezone()
try:
if self.isFirstSync(): if self.isFirstSync():
start_date = datetime.now() - timedelta(days=7) start_date = now - timedelta(days=7)
range_hour = str(24 * (7 + 1)) range_hour = str(24 * 8)
elif self.shouldFullSync(): elif self.shouldFullSync():
with self.__lock:
bulletins = self.bulletins() bulletins = self.bulletins()
earliest = min(bulletins, key=lambda x: x.get("dateTime", "")) earliest = min(bulletins, key=self._parseBulletinDateTime)
start_date = datetime.fromisoformat(earliest["dateTime"]) start_date = self._parseBulletinDateTime(earliest)
diff = datetime.now().astimezone() - start_date diff = now - start_date
range_hour = str(int(diff.total_seconds() / 3600) + 1) range_hour = str(int(diff.total_seconds() / 3600) + 1)
else: else:
with self.__lock:
bulletins = self.bulletins() bulletins = self.bulletins()
latest = max(bulletins, key=lambda x: x.get("dateTime", "")) latest = max(bulletins, key=self._parseBulletinDateTime)
start_date = datetime.fromisoformat(latest["dateTime"]) start_date = self._parseBulletinDateTime(latest)
diff = datetime.now().astimezone() - start_date diff = now - start_date
range_hour = str(int(diff.total_seconds() / 3600) + 1) range_hour = str(int(diff.total_seconds() / 3600) + 1)
except (ValueError, TypeError, KeyError):
start_date = now - timedelta(days=7)
range_hour = str(24 * 8)
return { return {
"date": start_date.strftime("%Y-%m-%d"), "date": start_date.strftime("%Y-%m-%d"),
"time": start_date.strftime("%H:%M:%S"), "time": start_date.strftime("%H:%M:%S"),
@@ -219,7 +258,7 @@ class BulletinManager:
New bulletins are added with isNew=True. Existing bulletins New bulletins are added with isNew=True. Existing bulletins
keep their current isNew state. Entries listed in delete_ids keep their current isNew state. Entries listed in delete_ids
are removed. are removed. Bulletins missing an "id" field are skipped.
Args: Args:
new_bulletins (list[dict]): Incoming bulletin list. new_bulletins (list[dict]): Incoming bulletin list.
@@ -230,22 +269,33 @@ class BulletinManager:
""" """
with self.__lock: with self.__lock:
delete_set = set(delete_ids) delete_set = set(str(d) for d in delete_ids)
bulletins_dict = {b["id"]: b for b in self.bulletins()} bulletins_dict = {}
for b in self.bulletins():
bid = b.get("id")
if bid is not None:
bulletins_dict[str(bid)] = b
for bulletin in new_bulletins: for bulletin in new_bulletins:
bid = bulletin["id"] bid = bulletin.get("id")
if bid is None:
continue
bid = str(bid)
if bid in delete_set: if bid in delete_set:
bulletins_dict.pop(bid, None) bulletins_dict.pop(bid, None)
continue continue
if bid not in bulletins_dict: bulletin["isNew"] = (
bulletin["isNew"] = True True
else: if bid not in bulletins_dict
bulletin["isNew"] = bulletins_dict[bid].get("isNew", True) else bulletins_dict[bid].get("isNew", True)
)
bulletins_dict[bid] = bulletin bulletins_dict[bid] = bulletin
for bid in delete_set: for bid in delete_set:
bulletins_dict.pop(bid, None) bulletins_dict.pop(bid, None)
result = list(bulletins_dict.values()) result = list(bulletins_dict.values())
result.sort(key=lambda x: int(x["id"])) result.sort(key=lambda x: str(x.get("id", "")))
self.setBulletins(result) self.setBulletins(result)
return result return result
@@ -263,7 +313,7 @@ class BulletinManager:
with self.__lock: with self.__lock:
bulletins = self.bulletins() bulletins = self.bulletins()
for b in bulletins: for b in bulletins:
if b["id"] == bulletin_id: if str(b.get("id", "")) == str(bulletin_id):
b["isNew"] = False b["isNew"] = False
self.setBulletins(bulletins) self.setBulletins(bulletins)
break break
+1 -1
View File
@@ -110,7 +110,7 @@ class ConfigManager:
config_data = JSONReader(config_path).data() config_data = JSONReader(config_path).data()
self.__config_data[config_type.value] = config_data self.__config_data[config_type.value] = config_data
return return
except: except Exception:
pass pass
self.__config_data[config_type.value] = ConfigTemplate(config_type).template() self.__config_data[config_type.value] = ConfigTemplate(config_type).template()
JSONWriter(config_path, self.__config_data[config_type.value]) JSONWriter(config_path, self.__config_data[config_type.value])