diff --git a/src/gui/ALBulletinDialog.py b/src/gui/ALBulletinDialog.py index c252681..9bdcf02 100644 --- a/src/gui/ALBulletinDialog.py +++ b/src/gui/ALBulletinDialog.py @@ -130,7 +130,11 @@ class ALBulletinItemWidget(QWidget): self.ItemWidgetLayout.addLayout(self.BulletinTitleLayout) self.BulletinInfoLayout = QHBoxLayout() 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.setStyleSheet("color: #969696; font-size: 11px;") self.BulletinInfoLayout.addWidget(self.BulletinDateLabel) @@ -258,7 +262,11 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog): ): 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.BulletinAuthorLabel.setText(bulletin.get("author", "未知")) if bulletin.get("isEdited", False): @@ -306,12 +314,15 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog): data: dict ): - if self.__fetch_worker: - self.__fetch_worker.wait(2000) - self.__fetch_worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) - self.__fetch_worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) - self.__fetch_worker.deleteLater() - self.__fetch_worker = None + worker = self.sender() + if worker is not self.__fetch_worker: + return + worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) + worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) + worker.wait(2000) + worker.deleteLater() + self.__fetch_worker = None + bulletins = data.get("bulletins", []) delete_ids = data.get("delete_ids", []) merged = self.__bulletin_mgr.updateAndMergeBulletins(bulletins, delete_ids) @@ -336,12 +347,15 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog): error_message: str ): - if self.__fetch_worker: - self.__fetch_worker.wait(2000) - self.__fetch_worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) - self.__fetch_worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) - self.__fetch_worker.deleteLater() - self.__fetch_worker = None + worker = self.sender() + if worker is not self.__fetch_worker: + return + worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) + worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) + worker.wait(2000) + worker.deleteLater() + self.__fetch_worker = None + self.SyncButton.setEnabled(True) self.SyncButton.setText("重试") self.ALSyncStatusLabel.status = ALStatusLabel.Status.FAILURE @@ -376,7 +390,8 @@ class ALBulletinDialog(QDialog, Ui_ALBulletinDialog): if bulletin.get("isNew", False): bulletin["isNew"] = False widget = self.BulletinListWidget.itemWidget(item) - widget.markAsRead() + if widget: + widget.markAsRead() item.setData(Qt.UserRole, bulletin) self.__bulletin_mgr.markBulletinAsRead(bulletin["id"]) self.showBulletin(bulletin) diff --git a/src/gui/ALBulletinPoller.py b/src/gui/ALBulletinPoller.py index fcf05e6..5a328c3 100644 --- a/src/gui/ALBulletinPoller.py +++ b/src/gui/ALBulletinPoller.py @@ -42,12 +42,14 @@ class ALBulletinPoller(QObject): self.__timer.timeout.connect(self.__poll) self.__worker = None self.__dialog_open = False + self.__stopped = False self.__mgr = bulletinInstance() def start( self ): + self.__stopped = False interval_ms = self.__mgr.syncInterval()*60*1000 self.__timer.start(interval_ms) @@ -55,6 +57,7 @@ class ALBulletinPoller(QObject): self ): + self.__stopped = True self.__timer.stop() self.__cleanupWorker() @@ -88,6 +91,31 @@ class ALBulletinPoller(QObject): 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( self ): @@ -117,23 +145,30 @@ class ALBulletinPoller(QObject): data: dict ): - if self.__worker is None: + worker = self.sender() + if worker is not self.__worker: return - self.__worker.fetchWorkerIsFinished.disconnect(self.__onFetched) - self.__worker.fetchWorkerFinishedWithError.disconnect(self.__onError) - self.__worker.wait(2000) - self.__worker.deleteLater() + self.__disconnectWorker(worker) + worker.wait(2000) + worker.deleteLater() 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", []) delete_ids = data.get("delete_ids", []) self.__mgr.updateAndMergeBulletins(bulletins, delete_ids) 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: self.newBulletinsDetected.emit(len(new_ids)) - interval_ms = self.__mgr.syncInterval()*60*1000 - self.__timer.start(interval_ms) + if not self.__stopped: + interval_ms = self.__mgr.syncInterval()*60*1000 + self.__timer.start(interval_ms) @Slot(str) def __onError( @@ -141,30 +176,13 @@ class ALBulletinPoller(QObject): error_message: str ): - if self.__worker is None: + worker = self.sender() + if worker is not self.__worker: return - self.__worker.fetchWorkerIsFinished.disconnect(self.__onFetched) - self.__worker.fetchWorkerFinishedWithError.disconnect(self.__onError) - self.__worker.wait(2000) - self.__worker.deleteLater() - self.__worker = None - interval_ms = self.__mgr.syncInterval()*60*1000 - 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.__disconnectWorker(worker) + worker.wait(2000) + worker.deleteLater() self.__worker = None + if not self.__stopped: + interval_ms = self.__mgr.syncInterval()*60*1000 + self.__timer.start(interval_ms) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 05bf166..aef6584 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -458,32 +458,64 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self.BulletinTestStatusLabel.setText("请先输入服务器地址。") self.BulletinTestStatusLabel.setStyleSheet("color: red;") return + if hasattr(self, '__bulletin_test_worker') and self.__bulletin_test_worker is not None: + return self.BulletinTestButton.setEnabled(False) self.BulletinTestStatusLabel.setText("正在测试连接...") self.BulletinTestStatusLabel.setStyleSheet("") - try: - api_url = url.rstrip("/") + "/bulletins" - t0 = time.monotonic() - response = requests.get(api_url, timeout=5) - elapsed_ms = (time.monotonic() - t0) * 1000 - response.raise_for_status() - data = response.json() - if data.get("code") == 200: - self.BulletinTestStatusLabel.setText( - f"连接成功!响应延迟 {elapsed_ms:.0f} ms" - ) - 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) - QTimer.singleShot(3000, self.clearBulletinTestStatus) + self.__bulletin_test_t0 = time.monotonic() + from gui.ALBulletinDialog import ALBulletinFetchWorker + api_url = url.rstrip("/") + "/bulletins" + self.__bulletin_test_worker = ALBulletinFetchWorker( + self, api_url, {"date": "", "time": "", "range_hour": "1"} + ) + self.__bulletin_test_worker.fetchWorkerIsFinished.connect( + self.__onBulletinTestFetched + ) + 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( + f"连接成功!响应延迟 {elapsed_ms:.0f} ms" + ) + self.BulletinTestStatusLabel.setStyleSheet("color: green;") + self.BulletinTestButton.setEnabled(True) + 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() def onCancelButtonClicked( diff --git a/src/managers/bulletin/BulletinManager.py b/src/managers/bulletin/BulletinManager.py index 316ff67..49a75c2 100644 --- a/src/managers/bulletin/BulletinManager.py +++ b/src/managers/bulletin/BulletinManager.py @@ -9,7 +9,7 @@ See the LICENSE file for details. """ import threading -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from interfaces.ConfigProvider import ( @@ -118,13 +118,18 @@ class BulletinManager: """ 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: int: Sync interval (minutes), minimum 1. """ interval = self.__cfg.get(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, 10) + try: + interval = int(interval) + except (ValueError, TypeError): + return 10 if interval < 1: return 5 return interval @@ -142,6 +147,26 @@ class BulletinManager: base = self.serverUrl().rstrip("/") 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( self ) -> bool: @@ -152,9 +177,10 @@ class BulletinManager: bool: True if no cached bulletins or no last sync time. """ - last = self.lastSyncTime() - bulletins = self.bulletins() - return last is None or not bulletins + with self.__lock: + last = self.lastSyncTime() + bulletins = self.bulletins() + return last is None or not bulletins def shouldFullSync( self @@ -169,14 +195,17 @@ class BulletinManager: bool: True if a full sync should be performed. """ - last = self.lastSyncTime() - if last is None: - return True - try: - last_dt = datetime.fromisoformat(last) - return (datetime.now().astimezone() - last_dt) > timedelta(hours=1) - except (ValueError, TypeError): - return True + with self.__lock: + last = self.lastSyncTime() + if last is None: + return True + try: + last_dt = datetime.fromisoformat(last) + last_dt = self._ensureAware(last_dt) + now = datetime.now(timezone.utc).astimezone() + return (now - last_dt) > timedelta(hours=1) + except (ValueError, TypeError): + return True def getSyncDateTimeAndRange( self @@ -184,25 +213,35 @@ class BulletinManager: """ Calculate the date / time / range_hour query parameters. + Falls back to a first-sync (7-day window) on any parsing error. + Returns: dict: Keys "date", "time", "range_hour" for the API request. """ - if self.isFirstSync(): - start_date = datetime.now() - timedelta(days=7) - range_hour = str(24 * (7 + 1)) - elif self.shouldFullSync(): - bulletins = self.bulletins() - earliest = min(bulletins, key=lambda x: x.get("dateTime", "")) - start_date = datetime.fromisoformat(earliest["dateTime"]) - diff = datetime.now().astimezone() - start_date - range_hour = str(int(diff.total_seconds() / 3600) + 1) - else: - bulletins = self.bulletins() - latest = max(bulletins, key=lambda x: x.get("dateTime", "")) - start_date = datetime.fromisoformat(latest["dateTime"]) - diff = datetime.now().astimezone() - start_date - range_hour = str(int(diff.total_seconds() / 3600) + 1) + now = datetime.now(timezone.utc).astimezone() + try: + if self.isFirstSync(): + start_date = now - timedelta(days=7) + range_hour = str(24 * 8) + elif self.shouldFullSync(): + with self.__lock: + bulletins = self.bulletins() + earliest = min(bulletins, key=self._parseBulletinDateTime) + start_date = self._parseBulletinDateTime(earliest) + diff = now - start_date + range_hour = str(int(diff.total_seconds() / 3600) + 1) + else: + with self.__lock: + bulletins = self.bulletins() + latest = max(bulletins, key=self._parseBulletinDateTime) + start_date = self._parseBulletinDateTime(latest) + diff = now - start_date + 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 { "date": start_date.strftime("%Y-%m-%d"), "time": start_date.strftime("%H:%M:%S"), @@ -219,7 +258,7 @@ class BulletinManager: New bulletins are added with isNew=True. Existing bulletins keep their current isNew state. Entries listed in delete_ids - are removed. + are removed. Bulletins missing an "id" field are skipped. Args: new_bulletins (list[dict]): Incoming bulletin list. @@ -230,22 +269,33 @@ class BulletinManager: """ with self.__lock: - delete_set = set(delete_ids) - bulletins_dict = {b["id"]: b for b in self.bulletins()} + delete_set = set(str(d) for d in delete_ids) + 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: - bid = bulletin["id"] + bid = bulletin.get("id") + if bid is None: + continue + bid = str(bid) if bid in delete_set: bulletins_dict.pop(bid, None) continue - if bid not in bulletins_dict: - bulletin["isNew"] = True - else: - bulletin["isNew"] = bulletins_dict[bid].get("isNew", True) + bulletin["isNew"] = ( + True + if bid not in bulletins_dict + else bulletins_dict[bid].get("isNew", True) + ) bulletins_dict[bid] = bulletin + for bid in delete_set: bulletins_dict.pop(bid, None) + 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) return result @@ -263,7 +313,7 @@ class BulletinManager: with self.__lock: bulletins = self.bulletins() for b in bulletins: - if b["id"] == bulletin_id: + if str(b.get("id", "")) == str(bulletin_id): b["isNew"] = False self.setBulletins(bulletins) break diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py index 7010d46..c26b7bf 100644 --- a/src/managers/config/ConfigManager.py +++ b/src/managers/config/ConfigManager.py @@ -110,7 +110,7 @@ class ConfigManager: config_data = JSONReader(config_path).data() self.__config_data[config_type.value] = config_data return - except: + except Exception: pass self.__config_data[config_type.value] = ConfigTemplate(config_type).template() JSONWriter(config_path, self.__config_data[config_type.value])