From e42aa86fb06c0ea6e94cbefda37ea4f67edf1278 Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 17:05:03 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix(=E6=8A=BD=E5=A5=96=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=E6=8A=BD=E5=A5=96=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E4=B8=AD=E5=AD=A6=E7=94=9FID=E6=9C=AA=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保抽奖结果中的学生ID能够正确从候选数据中提取并传递到奖品信息中 同时修正抽奖结果显示时学生ID的获取逻辑 --- app/common/lottery/lottery_manager.py | 27 ++++++++++-- requirements-linux.txt | 55 ------------------------- requirements-windows.txt | 59 --------------------------- 3 files changed, 23 insertions(+), 118 deletions(-) delete mode 100644 requirements-linux.txt delete mode 100644 requirements-windows.txt diff --git a/app/common/lottery/lottery_manager.py b/app/common/lottery/lottery_manager.py index f328a311..edab1527 100644 --- a/app/common/lottery/lottery_manager.py +++ b/app/common/lottery/lottery_manager.py @@ -450,8 +450,10 @@ def get_random_items(self, count): group_name = "" student_name = "" + student_id = 0 if self.current_group_index == 1: - raw_group = system_random.choice(candidates).get("name", "") + selected_candidate = system_random.choice(candidates) + raw_group = selected_candidate.get("name", "") include_group = show_random in (0, 1, 2, 5, 6, 7, 8, 9) include_name = show_random in (0, 1, 2, 3, 4, 7, 8, 9, 10, 11) @@ -464,13 +466,23 @@ def get_random_items(self, count): if group_members: selected_member = system_random.choice(group_members) student_name = (selected_member or {}).get("name", "") + try: + student_id = int((selected_member or {}).get("id", 0) or 0) + except Exception: + student_id = 0 if not student_name: student_name = raw_group else: - student_name = system_random.choice(candidates).get("name", "") + selected_candidate = system_random.choice(candidates) + student_name = selected_candidate.get("name", "") + try: + student_id = int(selected_candidate.get("id", 0) or 0) + except Exception: + student_id = 0 prize_copy["ipc_group_name"] = str(group_name or "") prize_copy["ipc_student_name"] = str(student_name or "") + prize_copy["student_id"] = student_id if group_name or student_name: prize_copy["name"] = self._format_prize_student_text( prize_name, group_name, student_name, show_random @@ -1125,9 +1137,13 @@ def draw_random(widget): for p in prizes or []: if not isinstance(p, dict): continue + try: + sid = int(p.get("student_id", 0) or 0) + except Exception: + sid = 0 ipc_selected_students.append( { - "student_id": 0, + "student_id": sid, "student_name": str(p.get("ipc_student_name", "") or ""), "display_text": str( p.get("ipc_display_text", p.get("name", "")) or "" @@ -1139,7 +1155,10 @@ def draw_random(widget): ), } ) - selected_prizes = [(p["id"], p["name"], p.get("exist", True)) for p in prizes] + selected_prizes = [ + (p.get("student_id", 0) or 0, p["name"], p.get("exist", True)) + for p in prizes + ] display_result_animated( widget, diff --git a/requirements-linux.txt b/requirements-linux.txt deleted file mode 100644 index 5a6b12da..00000000 --- a/requirements-linux.txt +++ /dev/null @@ -1,55 +0,0 @@ -# Linux 依赖配置 - 支持 Python 3.8.10 - -# === GUI框架 === -PySide6==6.7.1 -PySide6-Qt6==6.7.3 -PySide6-Fluent-Widgets==1.9.1 -PySide6-Frameless-Window==0.7.4 -darkdetect==0.8.0 - -# === 核心库 === -asyncio~=3.4.3 -loguru==0.7.3 -colorama==0.4.6 -packaging==25.0 - -# === 数据处理 === -numpy~=1.24.4 -pandas~=2.0.3 -pillow~=10.4.0 -openpyxl==3.1.5 -xlrd>=2.0.1 - -# === 网络与通信 === -requests==2.32.4 -edge-tts==7.0.2 - -# === 音频处理 === -pyttsx3==2.98 -sounddevice==0.5.2 -soundfile==0.13.1 - -# === 系统工具 === -psutil~=7.0.0 -keyboard==0.13.5 - -# === 加密与安全 === -pyotp==2.9.0 -pycryptodome==3.23.0 - -# === 二维码处理 === -pyqrcode~=1.2.1 -pypng~=0.20220715.0 -colorthief==0.2.1 - -# === Linux特定依赖 === -# Linux音频控制替代pycaw -pulsectl==24.8.0; platform_system == "Linux" - -# === 其他依赖 === -sip~=6.8.6 -jinja2~=3.1.6 -pyyaml>=6.0.1 - -# === 通知工具 === -plyer>=2.1.0 diff --git a/requirements-windows.txt b/requirements-windows.txt deleted file mode 100644 index 5bd1e2d3..00000000 --- a/requirements-windows.txt +++ /dev/null @@ -1,59 +0,0 @@ -# Windows 依赖配置 - 支持 Python 3.8.10 - -# === GUI框架 === -PySide6==6.7.1 -PySide6-Qt6==6.7.3 -PySide6-Fluent-Widgets==1.9.1 -PySide6-Frameless-Window==0.7.4 -darkdetect==0.8.0 - -# === 核心库 === -asyncio~=3.4.3 -loguru==0.7.3 -colorama==0.4.6 -packaging==25.0 - -# === 数据处理 === -numpy~=1.24.4 -pandas~=2.0.3 -pillow~=10.4.0 -openpyxl==3.1.5 -xlrd>=2.0.1 - -# === 网络与通信 === -requests==2.32.4 -edge-tts==7.0.2 - -# === 音频处理 === -pyttsx3==2.98 -sounddevice==0.5.2 -soundfile==0.13.1 - -# === 系统工具 === -psutil~=7.0.0 -keyboard==0.13.5 - -# === 加密与安全 === -pyotp==2.9.0 -pycryptodome==3.23.0 - -# === 二维码处理 === -pyqrcode~=1.2.1 -pypng~=0.20220715.0 -colorthief==0.2.1 - -# === Windows特定依赖 === -pywin32==310; platform_system == "Windows" -win32_setctime==1.2.0; platform_system == "Windows" -winshell==0.6; platform_system == "Windows" -comtypes==1.4.10 -wmi==1.5.1 -pycaw==20240210; platform_system == "Windows" - -# === 其他依赖 === -sip~=6.8.6 -jinja2~=3.1.6 -pyyaml>=6.0.1 - -# === 通知工具 === -plyer>=2.1.0 From 3251e05a168c488a22cc2ba4442baf921c508180 Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 17:39:44 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(=E5=8D=95=E5=AE=9E=E4=BE=8B):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=85=B1=E4=BA=AB=E5=86=85=E5=AD=98=E5=92=8C?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=9A=84=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当无法附加到共享内存时,增加服务器状态检测和资源清理逻辑 修复本地服务器启动失败时未清理残留socket的问题 --- app/core/single_instance.py | 48 +++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/app/core/single_instance.py b/app/core/single_instance.py index e216b7ac..4b43d69e 100644 --- a/app/core/single_instance.py +++ b/app/core/single_instance.py @@ -20,13 +20,47 @@ def check_single_instance() -> Tuple[Optional[QSharedMemory], bool]: _activate_existing_instance() return shared_memory, False else: - logger.exception("无法附加到共享内存") - return shared_memory, False + logger.warning("无法附加到共享内存,尝试检测服务器状态") + if _check_server_alive(): + logger.info("检测到活跃的服务器,已有实例正在运行") + _activate_existing_instance() + return shared_memory, False + else: + logger.warning("服务器无响应,清理残留资源后重新启动") + _cleanup_stale_resources() + if shared_memory.create(1): + logger.info("单实例检查通过(清理后重新创建)") + return shared_memory, True + else: + logger.exception("清理后仍无法创建共享内存") + return shared_memory, False logger.info("单实例检查通过,可以安全启动程序") return shared_memory, True +def _check_server_alive() -> bool: + """检查本地服务器是否有响应 + + Returns: + bool: 服务器是否有响应 + """ + local_socket = QLocalSocket() + local_socket.connectToServer(SHARED_MEMORY_KEY) + connected = local_socket.waitForConnected(500) + if connected: + local_socket.disconnectFromServer() + return True + return False + + +def _cleanup_stale_resources() -> None: + """清理残留的单实例资源 + """ + QLocalServer.removeServer(SHARED_MEMORY_KEY) + logger.debug("已清理残留的socket资源") + + def _activate_existing_instance() -> bool: """激活已有实例 @@ -74,8 +108,14 @@ def setup_local_server( """ server = QLocalServer() if not server.listen(SHARED_MEMORY_KEY): - logger.exception(f"无法启动本地服务器: {server.errorString()}") - return None + error_string = server.errorString() + logger.warning(f"本地服务器启动失败,尝试清理残留socket: {error_string}") + + QLocalServer.removeServer(SHARED_MEMORY_KEY) + + if not server.listen(SHARED_MEMORY_KEY): + logger.exception(f"无法启动本地服务器: {server.errorString()}") + return None server.newConnection.connect( lambda: _handle_new_connection(server, main_window, float_window, url_handler) From db5c6cf7661a303f2639a087a54935b150fac26a Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 18:06:08 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(=E5=8D=95=E5=AE=9E=E4=BE=8B):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0POSIX=E7=B3=BB=E7=BB=9F=E4=B8=8B=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=86=85=E5=AD=98=E6=AE=8B=E7=95=99=E6=B8=85=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在POSIX系统上,QSharedMemory在进程崩溃后不会自动清理残留的共享内存段 添加_cleanup_stale_shared_memory函数来检测并清理这些残留资源 --- app/core/single_instance.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/core/single_instance.py b/app/core/single_instance.py index 4b43d69e..1fa7cd83 100644 --- a/app/core/single_instance.py +++ b/app/core/single_instance.py @@ -60,6 +60,21 @@ def _cleanup_stale_resources() -> None: QLocalServer.removeServer(SHARED_MEMORY_KEY) logger.debug("已清理残留的socket资源") + _cleanup_stale_shared_memory() + + +def _cleanup_stale_shared_memory() -> None: + """清理残留的共享内存段(POSIX系统需要) + + 在POSIX系统上,QSharedMemory在进程崩溃后不会自动清理。 + 需要先attach再detach来触发清理。 + """ + stale_memory = QSharedMemory(SHARED_MEMORY_KEY) + if stale_memory.attach(): + logger.debug("检测到残留的共享内存段,正在清理") + stale_memory.detach() + logger.debug("已清理残留的共享内存段") + def _activate_existing_instance() -> bool: """激活已有实例 From fdcf8a55d8600e780489418f838580e8f324f5f2 Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 18:23:27 +0800 Subject: [PATCH 4/9] =?UTF-8?q?style:=20=E7=A7=BB=E9=99=A4=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=AD=97=E7=AC=A6=E4=B8=B2=E4=B8=AD=E7=9A=84=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=A9=BA=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/single_instance.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/core/single_instance.py b/app/core/single_instance.py index 1fa7cd83..69185c93 100644 --- a/app/core/single_instance.py +++ b/app/core/single_instance.py @@ -55,8 +55,7 @@ def _check_server_alive() -> bool: def _cleanup_stale_resources() -> None: - """清理残留的单实例资源 - """ + """清理残留的单实例资源""" QLocalServer.removeServer(SHARED_MEMORY_KEY) logger.debug("已清理残留的socket资源") From 4b5342b7516883882e0f549bd221e6331e41d272 Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 18:38:56 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E8=BF=9E=E6=8E=A5=E6=A3=80=E6=9F=A5=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=92=8C=E5=A5=96=E5=93=81ID=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复服务器无响应检查的描述不准确问题,改为更明确的"服务器不可连接" 改进奖品ID处理逻辑,增加异常捕获和多种ID字段尝试 --- app/common/lottery/lottery_manager.py | 20 +++++++++++++++----- app/core/single_instance.py | 6 +++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/common/lottery/lottery_manager.py b/app/common/lottery/lottery_manager.py index edab1527..ea3c72c5 100644 --- a/app/common/lottery/lottery_manager.py +++ b/app/common/lottery/lottery_manager.py @@ -467,7 +467,9 @@ def get_random_items(self, count): selected_member = system_random.choice(group_members) student_name = (selected_member or {}).get("name", "") try: - student_id = int((selected_member or {}).get("id", 0) or 0) + student_id = int( + (selected_member or {}).get("id", 0) or 0 + ) except Exception: student_id = 0 if not student_name: @@ -1155,10 +1157,18 @@ def draw_random(widget): ), } ) - selected_prizes = [ - (p.get("student_id", 0) or 0, p["name"], p.get("exist", True)) - for p in prizes - ] + selected_prizes = [] + for p in prizes: + try: + display_num = int(p.get("student_id", 0) or 0) + except Exception: + display_num = 0 + if not display_num: + try: + display_num = int(p.get("id", 0) or 0) + except Exception: + display_num = 0 + selected_prizes.append((display_num, p["name"], p.get("exist", True))) display_result_animated( widget, diff --git a/app/core/single_instance.py b/app/core/single_instance.py index 69185c93..ebc0bfc4 100644 --- a/app/core/single_instance.py +++ b/app/core/single_instance.py @@ -26,7 +26,7 @@ def check_single_instance() -> Tuple[Optional[QSharedMemory], bool]: _activate_existing_instance() return shared_memory, False else: - logger.warning("服务器无响应,清理残留资源后重新启动") + logger.warning("服务器不可连接,清理残留资源后重新启动") _cleanup_stale_resources() if shared_memory.create(1): logger.info("单实例检查通过(清理后重新创建)") @@ -40,10 +40,10 @@ def check_single_instance() -> Tuple[Optional[QSharedMemory], bool]: def _check_server_alive() -> bool: - """检查本地服务器是否有响应 + """检查本地服务器是否在监听(可连接) Returns: - bool: 服务器是否有响应 + bool: 服务器是否可连接 """ local_socket = QLocalSocket() local_socket.connectToServer(SHARED_MEMORY_KEY) From abbac567773b1831e416e155288d7354c5bb100c Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 18:46:35 +0800 Subject: [PATCH 6/9] =?UTF-8?q?refactor(music=5Fplayer):=20=E9=80=9A?= =?UTF-8?q?=E8=BF=87ruff=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=9A=84time=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/music/music_player.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/common/music/music_player.py b/app/common/music/music_player.py index c3b57e8b..723de2df 100644 --- a/app/common/music/music_player.py +++ b/app/common/music/music_player.py @@ -3,7 +3,6 @@ # ================================================== import threading -import time from typing import Optional import numpy as np from loguru import logger @@ -287,7 +286,10 @@ def _play_music_worker(self, music_path: str, loop: bool) -> None: if self._fade_out_remaining_time >= chunk_duration: # 整个块都在渐出范围内 fade_out_ratio = 1.0 - ( - (self._fade_out_duration - self._fade_out_remaining_time) + ( + self._fade_out_duration + - self._fade_out_remaining_time + ) / self._fade_out_duration ) chunk *= self._volume * fade_out_ratio @@ -299,10 +301,15 @@ def _play_music_worker(self, music_path: str, loop: bool) -> None: ) if fade_out_samples > 0: fade_out_ratio = 1.0 - ( - (self._fade_out_duration - self._fade_out_remaining_time) + ( + self._fade_out_duration + - self._fade_out_remaining_time + ) / self._fade_out_duration ) - chunk[:fade_out_samples] *= self._volume * fade_out_ratio + chunk[:fade_out_samples] *= ( + self._volume * fade_out_ratio + ) chunk[fade_out_samples:] = 0 else: chunk[:] = 0 From 93b1c1dcae2d1e800fbe2a7d9be95723f57ad91b Mon Sep 17 00:00:00 2001 From: Supercmd Date: Sat, 4 Apr 2026 18:58:38 +0800 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0CHANGELOG?= =?UTF-8?q?=E5=92=8CAGENTS=E6=96=87=E6=A1=A3=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一文档中的换行格式,移除多余的空格 --- CHANGELOG/v2.3.2/CHANGELOG.md | 12 ++++++------ CHANGELOG/v2.3.5/CHANGELOG.md | 2 +- app/view/AGENTS.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG/v2.3.2/CHANGELOG.md b/CHANGELOG/v2.3.2/CHANGELOG.md index 15b1f76a..c3e18a77 100644 --- a/CHANGELOG/v2.3.2/CHANGELOG.md +++ b/CHANGELOG/v2.3.2/CHANGELOG.md @@ -16,17 +16,17 @@ v2.3 - Shiroko (砂狼白子) release 3 **为何选择新架构?** -- ✅ **跨平台兼容性**更强 -- ✅ **界面流畅度**更高 -- ✅ **长期可维护性**更稳 +- ✅ **跨平台兼容性**更强 +- ✅ **界面流畅度**更高 +- ✅ **长期可维护性**更稳 新架构将助力我们更好地实现「简洁、公平、多功能」的核心目标,并在校园点名、抽奖、游戏、决策等场景中保持高效与易用。 **开发计划** -- 持续在 GitHub 更新进度与设计方案 -- 测试版本功能稳定后开放下载 -- 欢迎第一时间体验与验证 +- 持续在 GitHub 更新进度与设计方案 +- 测试版本功能稳定后开放下载 +- 欢迎第一时间体验与验证 感谢一直以来的支持与信任,让我们共同迎接这次全面焕新! diff --git a/CHANGELOG/v2.3.5/CHANGELOG.md b/CHANGELOG/v2.3.5/CHANGELOG.md index 80a8f2ba..1cea492b 100644 --- a/CHANGELOG/v2.3.5/CHANGELOG.md +++ b/CHANGELOG/v2.3.5/CHANGELOG.md @@ -16,7 +16,7 @@ v2.3 - Shiroko (砂狼白子) release 4 ## 🐛 修复问题 -- 修复 **主题页面** 无法显示的问题 +- 修复 **主题页面** 无法显示的问题 - 修复 **设置窗口** 重启后无法打开的问题 - 修复 **闪抽** 在随机抽取模式下,无法正常工作的问题 diff --git a/app/view/AGENTS.md b/app/view/AGENTS.md index 937cafc0..600388f0 100644 --- a/app/view/AGENTS.md +++ b/app/view/AGENTS.md @@ -72,11 +72,11 @@ class SomeSettingPage(QWidget): super().__init__(parent) self._init_ui() self._load_settings() - + def _init_ui(self): # Setup layout and widgets pass - + def _load_settings(self): # Load from settings_access pass From bd3b4b216fab6e408c3ecd21b00b9b80e4e0b220 Mon Sep 17 00:00:00 2001 From: Supercmd Date: Wed, 13 May 2026 22:53:35 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E5=86=99=E5=85=A5=E9=98=B2=E6=AD=A2=E9=87=8D=E5=90=AF=E6=97=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=A2=E5=A4=B1=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:重启(正常或异常)后再次启动可能弹出程序 OOBE,原有配置丢失。 原因:settings.json 等配置文件使用非原子写入(open + json.dump), 当系统在写入过程中崩溃或断电时,文件会被截断或损坏,导致应用 启动时将损坏的配置文件视为空配置,重新创建默认设置并触发 OOBE。 修复: 1. 在 path_utils.py 中新增 atomic_write_json() 和 atomic_write_bytes() 工具函数,采用 tempfile + os.replace() 原子写入模式,确保写入 过程中崩溃不会损坏目标文件 2. 将 settings_access.py、settings_default.py、config.py、 secure_store.py、file_utils.py 中所有关键配置文件的写入操作 替换为原子写入 3. 在 settings_default.py 的 manage_settings_file() 中增加备份恢复 机制:当 settings.json 损坏时,优先从最近的备份 zip 中恢复配置, 仅在无可用备份时才回退到默认值 Closes #231 --- app/common/history/file_utils.py | 5 +- app/common/safety/secure_store.py | 81 +++++++++++-------------------- app/tools/config.py | 7 ++- app/tools/path_utils.py | 68 ++++++++++++++++++++++++++ app/tools/settings_access.py | 5 +- app/tools/settings_default.py | 70 +++++++++++++++++++++----- 6 files changed, 162 insertions(+), 74 deletions(-) diff --git a/app/common/history/file_utils.py b/app/common/history/file_utils.py index d110123d..80e59302 100644 --- a/app/common/history/file_utils.py +++ b/app/common/history/file_utils.py @@ -9,7 +9,7 @@ from loguru import logger -from app.tools.path_utils import get_path +from app.tools.path_utils import get_path, atomic_write_json _history_cache_lock = threading.RLock() @@ -127,8 +127,7 @@ def save_history_data(history_type: str, file_name: str, data: Dict[str, Any]) - """ file_path = get_history_file_path(history_type, file_name) try: - with open(file_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=4) + atomic_write_json(file_path, data) with _history_cache_lock: _history_data_cache[file_path] = ( _get_file_signature(file_path), diff --git a/app/common/safety/secure_store.py b/app/common/safety/secure_store.py index 32e3ff07..78a76161 100644 --- a/app/common/safety/secure_store.py +++ b/app/common/safety/secure_store.py @@ -6,7 +6,7 @@ import ctypes import uuid from loguru import logger -from app.tools.path_utils import get_settings_path, ensure_dir +from app.tools.path_utils import get_settings_path, ensure_dir, atomic_write_bytes try: from Cryptodome.Cipher import AES @@ -143,37 +143,28 @@ def write_secrets(d: dict) -> None: comp = zlib.compress(raw, level=6) key = _platform_key() payload = _encrypt_payload(comp, key) - with open(p, "wb") as f: - f.write(b"SRV1" + payload) + atomic_write_bytes(p, b"SRV1" + payload) _set_hidden(str(p)) logger.debug(f"写入安全配置成功:{p}") - except PermissionError as e: - logger.warning( - f"写入安全配置失败:权限被拒绝,文件可能被占用或无写权限:{p}, 错误:{e}" - ) - # 尝试使用临时文件写入然后替换 + except Exception as e: + logger.warning(f"写入安全配置失败:{p}, 错误:{e}") try: - import tempfile - - with tempfile.NamedTemporaryFile( - mode="wb", delete=False, dir=os.path.dirname(p) - ) as tmp_file: - tmp_file.write(b"SRV1" + payload) - tmp_path = tmp_file.name - - # 替换原文件 - os.replace(tmp_path, p) + raw = json.dumps(d, ensure_ascii=False, indent=4).encode("utf-8") + comp = zlib.compress(raw, level=6) + key = _platform_key() + payload = _encrypt_payload(comp, key) + with open(p, "wb") as f: + f.write(b"SRV1" + payload) _set_hidden(str(p)) - logger.debug(f"使用临时文件写入安全配置成功:{p}") - except Exception as temp_e: - logger.warning(f"使用临时文件写入安全配置也失败:{temp_e}") - # 降级到明文JSON写入 + logger.debug(f"降级直接写入安全配置成功:{p}") + except Exception as e2: + logger.warning(f"降级写入安全配置也失败:{e2}") try: with open(p, "w", encoding="utf-8") as f: json.dump(d, f, ensure_ascii=False, indent=4) logger.warning(f"写入安全配置降级为明文JSON:{p}") - except Exception as e2: - logger.warning(f"降级写入明文JSON也失败:{e2}") + except Exception as e3: + logger.warning(f"降级写入明文JSON也失败:{e3}") def read_behind_scenes_settings() -> dict: @@ -230,39 +221,25 @@ def write_behind_scenes_settings(d: dict) -> None: comp = zlib.compress(raw, level=6) key = _platform_key() payload = _encrypt_payload(comp, key) - with open(p, "wb") as f: - f.write(b"SRV1" + payload) + atomic_write_bytes(p, b"SRV1" + payload) _set_hidden(str(p)) logger.debug(f"写入内幕设置成功:{p}") - except PermissionError as e: - logger.error( - f"写入内幕设置失败:权限被拒绝,文件可能被占用或无写权限:{p}, 错误:{e}" - ) + except Exception as e: + logger.error(f"写入内幕设置失败:{p}, 错误:{e}") try: - import tempfile - - with tempfile.NamedTemporaryFile( - mode="wb", delete=False, dir=os.path.dirname(p) - ) as tmp_file: - tmp_file.write(b"SRV1" + payload) - tmp_path = tmp_file.name - - os.replace(tmp_path, p) + raw = json.dumps(d, ensure_ascii=False, indent=4).encode("utf-8") + comp = zlib.compress(raw, level=6) + key = _platform_key() + payload = _encrypt_payload(comp, key) + with open(p, "wb") as f: + f.write(b"SRV1" + payload) _set_hidden(str(p)) - logger.debug(f"使用临时文件写入内幕设置成功:{p}") - except Exception as temp_e: - logger.error(f"使用临时文件写入内幕设置也失败:{temp_e}") + logger.debug(f"降级直接写入内幕设置成功:{p}") + except Exception as e2: + logger.error(f"降级写入内幕设置也失败:{e2}") try: with open(p, "w", encoding="utf-8") as f: json.dump(d, f, ensure_ascii=False, indent=4) logger.warning(f"写入内幕设置降级为明文JSON:{p}") - except Exception as e2: - logger.error(f"降级写入明文JSON也失败:{e2}") - except Exception as e: - logger.error(f"写入内幕设置失败:{p}, 错误:{e}") - try: - with open(p, "w", encoding="utf-8") as f: - json.dump(d, f, ensure_ascii=False, indent=4) - logger.warning(f"写入内幕设置降级为明文JSON:{p}") - except Exception as e2: - logger.exception(f"降级写入明文JSON也失败:{e2}") + except Exception as e3: + logger.exception(f"降级写入明文JSON也失败:{e3}") diff --git a/app/tools/config.py b/app/tools/config.py index 01297bce..2a0e9fba 100644 --- a/app/tools/config.py +++ b/app/tools/config.py @@ -29,6 +29,7 @@ get_data_path, get_settings_path, get_path, + atomic_write_json, ) from app.tools.personalised import get_theme_icon from app.tools.settings_access import readme_settings_async @@ -956,8 +957,7 @@ def import_settings(parent: Optional[QWidget] = None) -> None: if dialog.exec(): settings_path = get_settings_path() - with open(settings_path, "w", encoding="utf-8") as f: - json.dump(imported_settings, f, ensure_ascii=False, indent=4) + atomic_write_json(settings_path, imported_settings) success_dialog = MessageBox( get_any_position_value_async( @@ -2093,8 +2093,7 @@ def _save_drawn_records(file_path: str, drawn_records: dict) -> None: drawn_records: 已抽取的学生记录字典 """ try: - with open(file_path, "w", encoding="utf-8") as file: - json.dump(drawn_records, file, ensure_ascii=False, indent=2) + atomic_write_json(file_path, drawn_records, indent=2) except IOError as e: logger.exception(f"保存已抽取记录失败: {e}") diff --git a/app/tools/path_utils.py b/app/tools/path_utils.py index 645e715a..d26aad7d 100644 --- a/app/tools/path_utils.py +++ b/app/tools/path_utils.py @@ -20,8 +20,10 @@ # 导入模块 # ================================================== import os +import json import shutil import sys +import tempfile from pathlib import Path from typing import Union from loguru import logger @@ -534,3 +536,69 @@ def get_font_path(filename: str = DEFAULT_FONT_FILENAME_PRIMARY) -> Path: Path: 字体文件的绝对路径 """ return path_getter.get_font_path(filename) + + +def atomic_write_json( + target_path: Union[str, Path], + data: dict, + indent: int = 4, + ensure_ascii: bool = False, +) -> None: + """原子写入 JSON 文件,防止写入过程中崩溃导致数据丢失。 + + 先写入临时文件,再通过 os.replace() 原子替换目标文件。 + 在 POSIX 系统上 os.replace() 是原子的;在 Windows 上也是 + 近似原子的(NTFS 上 REPLACEFILE 操作为原子)。 + + Args: + target_path: 目标文件路径(相对或绝对) + data: 要写入的字典数据 + indent: JSON 缩进层级 + ensure_ascii: 是否转义非 ASCII 字符 + """ + absolute_path = path_manager.get_absolute_path(target_path) + ensure_dir(absolute_path.parent) + dir_path = str(absolute_path.parent) + + tmp_fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=indent, ensure_ascii=ensure_ascii) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(absolute_path)) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def atomic_write_bytes( + target_path: Union[str, Path], + data: bytes, +) -> None: + """原子写入二进制文件,防止写入过程中崩溃导致数据丢失。 + + Args: + target_path: 目标文件路径(相对或绝对) + data: 要写入的二进制数据 + """ + absolute_path = path_manager.get_absolute_path(target_path) + ensure_dir(absolute_path.parent) + dir_path = str(absolute_path.parent) + + tmp_fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(absolute_path)) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise diff --git a/app/tools/settings_access.py b/app/tools/settings_access.py index ce536524..59ff13bc 100644 --- a/app/tools/settings_access.py +++ b/app/tools/settings_access.py @@ -18,6 +18,7 @@ from app.tools.variable import * from app.tools.path_utils import * from app.tools.settings_default import * +from app.tools.path_utils import atomic_write_json _UNSET = object() @@ -374,9 +375,7 @@ def update_settings(first_level_key: str, second_level_key: str, value: Any): # 直接保存值,不保存嵌套结构 settings_data[first_level_key][second_level_key] = value - # 写入设置文件 - with open_file(settings_path, "w", encoding="utf-8") as f: - json.dump(settings_data, f, ensure_ascii=False, indent=4) + atomic_write_json(settings_path, settings_data) _replace_settings_cache(settings_data) if not ( diff --git a/app/tools/settings_default.py b/app/tools/settings_default.py index ae966459..1f7a3604 100644 --- a/app/tools/settings_default.py +++ b/app/tools/settings_default.py @@ -10,11 +10,13 @@ import platform import ctypes import uuid +import zipfile from loguru import logger from app.tools.variable import * from app.tools.path_utils import * from app.tools.settings_default_storage import * +from app.tools.path_utils import atomic_write_json Language = DEFAULT_LANGUAGE @@ -50,7 +52,50 @@ def get_default_setting(first_level_key: str, second_level_key: str): # ================================================== # 设置文件管理相关函数 # ================================================== -_DEVICE_UUID_FILE = "device_uuid.json" +_DEVICE_UUID_FILE = "device_uuid.json" + + +def _try_recover_settings_from_backup() -> dict | None: + """尝试从最近的备份中恢复 settings.json 内容。 + + 遍历备份目录中的 zip 文件(按修改时间倒序),查找包含 + config/settings.json 的备份,返回其中可解析的设置字典。 + 如果所有备份都无法恢复则返回 None。 + """ + try: + backup_dir = get_data_path("backup") + if not backup_dir.exists(): + return None + + zip_files = sorted( + backup_dir.glob("*.zip"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + for zip_path in zip_files: + try: + with zipfile.ZipFile(str(zip_path), "r") as zf: + for candidate in ( + "config/settings.json", + "settings.json", + ): + if candidate in zf.namelist(): + with zf.open(candidate) as member: + content = member.read().decode("utf-8") + if content and content.strip(): + recovered = json.loads(content) + if isinstance(recovered, dict): + logger.info( + f"从备份 {zip_path.name} 恢复设置成功" + ) + return recovered + except Exception: + continue + except Exception as e: + logger.warning(f"扫描备份恢复设置失败: {e}") + + return None def ensure_device_uuid(): @@ -89,8 +134,7 @@ def ensure_device_uuid(): new_uuid = str(uuid.uuid4()).lower() settings.setdefault("basic_settings", {})["offline_user_id"] = new_uuid try: - with open_file(settings_file, "w", encoding="utf-8") as f: - json.dump(settings, f, indent=4, ensure_ascii=False) + atomic_write_json(settings_file, settings) logger.info(f"已生成新的 offline_user_id: {new_uuid}") except Exception as e: logger.error(f"写入 offline_user_id 失败: {e}") @@ -134,27 +178,31 @@ def manage_settings_file(): second_level_value["default_value"] ) - with open_file(settings_file, "w", encoding="utf-8") as f: - json.dump(flat_settings, f, indent=4, ensure_ascii=False) + atomic_write_json(settings_file, flat_settings) return try: with open_file(settings_file, "r", encoding="utf-8") as f: current_settings = json.load(f) except Exception as e: - logger.warning(f"读取设置文件失败: {e},将重新创建默认设置文件") + logger.warning(f"读取设置文件失败: {e},尝试从备份恢复") + recovered = _try_recover_settings_from_backup() + if recovered is not None: + logger.info("从备份恢复设置成功,将使用恢复的设置") + atomic_write_json(settings_file, recovered) + return + + logger.warning("无可用备份,将创建默认设置文件") flat_settings = {} for first_level_key, first_level_value in default_settings.items(): flat_settings[first_level_key] = {} for second_level_key, second_level_value in first_level_value.items(): - # 如果默认值为 None,则不写入设置文件 if second_level_value["default_value"] is not None: flat_settings[first_level_key][second_level_key] = ( second_level_value["default_value"] ) - with open_file(settings_file, "w", encoding="utf-8") as f: - json.dump(flat_settings, f, indent=4, ensure_ascii=False) + atomic_write_json(settings_file, flat_settings) return # 检查并更新设置文件 @@ -232,9 +280,7 @@ def manage_settings_file(): del updated_settings[first_level_key][second_level_key] if settings_updated: - # logger.debug("设置文件已更新") - with open_file(settings_file, "w", encoding="utf-8") as f: - json.dump(updated_settings, f, indent=4, ensure_ascii=False) + atomic_write_json(settings_file, updated_settings) else: # logger.debug("设置文件已是最新,无需更新") pass From 1abcae9cfeeb191e64e098a49e3ec7fc0f965dc4 Mon Sep 17 00:00:00 2001 From: Supercmd Date: Wed, 13 May 2026 23:14:45 +0800 Subject: [PATCH 9/9] =?UTF-8?q?style:=20=E4=BF=AE=E5=A4=8D=20ruff=20format?= =?UTF-8?q?=20=E5=92=8C=20end-of-file-fixer=20=E6=A3=80=E6=9F=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tools/platform_report.py | 6 +- app/tools/settings_default.py | 84 +++++++++---------- app/tools/variable.py | 2 +- .../student/import_student_name.py | 6 +- data/device_uuid.json | 2 +- 5 files changed, 51 insertions(+), 49 deletions(-) diff --git a/app/tools/platform_report.py b/app/tools/platform_report.py index 28dda783..427aee79 100644 --- a/app/tools/platform_report.py +++ b/app/tools/platform_report.py @@ -9,7 +9,11 @@ import requests from loguru import logger -from app.tools.settings_access import get_int_setting, readme_settings_async, update_settings +from app.tools.settings_access import ( + get_int_setting, + readme_settings_async, + update_settings, +) from app.tools.variable import ( SECTL_API_BASE_URL, SECTL_ONLINE_REPORT_TIMEOUT_SECONDS, diff --git a/app/tools/settings_default.py b/app/tools/settings_default.py index 1f7a3604..48fffb99 100644 --- a/app/tools/settings_default.py +++ b/app/tools/settings_default.py @@ -52,49 +52,47 @@ def get_default_setting(first_level_key: str, second_level_key: str): # ================================================== # 设置文件管理相关函数 # ================================================== -_DEVICE_UUID_FILE = "device_uuid.json" - - -def _try_recover_settings_from_backup() -> dict | None: - """尝试从最近的备份中恢复 settings.json 内容。 - - 遍历备份目录中的 zip 文件(按修改时间倒序),查找包含 - config/settings.json 的备份,返回其中可解析的设置字典。 - 如果所有备份都无法恢复则返回 None。 - """ - try: - backup_dir = get_data_path("backup") - if not backup_dir.exists(): - return None - - zip_files = sorted( - backup_dir.glob("*.zip"), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - - for zip_path in zip_files: - try: - with zipfile.ZipFile(str(zip_path), "r") as zf: - for candidate in ( - "config/settings.json", - "settings.json", - ): - if candidate in zf.namelist(): - with zf.open(candidate) as member: - content = member.read().decode("utf-8") - if content and content.strip(): - recovered = json.loads(content) - if isinstance(recovered, dict): - logger.info( - f"从备份 {zip_path.name} 恢复设置成功" - ) - return recovered - except Exception: - continue - except Exception as e: - logger.warning(f"扫描备份恢复设置失败: {e}") - +_DEVICE_UUID_FILE = "device_uuid.json" + + +def _try_recover_settings_from_backup() -> dict | None: + """尝试从最近的备份中恢复 settings.json 内容。 + + 遍历备份目录中的 zip 文件(按修改时间倒序),查找包含 + config/settings.json 的备份,返回其中可解析的设置字典。 + 如果所有备份都无法恢复则返回 None。 + """ + try: + backup_dir = get_data_path("backup") + if not backup_dir.exists(): + return None + + zip_files = sorted( + backup_dir.glob("*.zip"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + for zip_path in zip_files: + try: + with zipfile.ZipFile(str(zip_path), "r") as zf: + for candidate in ( + "config/settings.json", + "settings.json", + ): + if candidate in zf.namelist(): + with zf.open(candidate) as member: + content = member.read().decode("utf-8") + if content and content.strip(): + recovered = json.loads(content) + if isinstance(recovered, dict): + logger.info(f"从备份 {zip_path.name} 恢复设置成功") + return recovered + except Exception: + continue + except Exception as e: + logger.warning(f"扫描备份恢复设置失败: {e}") + return None diff --git a/app/tools/variable.py b/app/tools/variable.py index 20d3a21c..0d28c90d 100644 --- a/app/tools/variable.py +++ b/app/tools/variable.py @@ -345,7 +345,7 @@ def _normalize_arch(machine: str) -> str: PROCESS_EXIT_WAIT_SECONDS = 1 # 进程退出等待时间(秒) # -------------------- SECTL 在线状态上报配置 -------------------- -SECTL_API_BASE_URL = "https://appwrite.sectl.cn" +SECTL_API_BASE_URL = "https://appwrite.sectl.cn" SECTL_PLATFORM_ID = "69c8cd6a0012dd3ea10a" SECTL_ONLINE_REPORT_INTERVAL_MS = 120000 SECTL_ONLINE_REPORT_TIMEOUT_SECONDS = 10 diff --git a/app/view/another_window/student/import_student_name.py b/app/view/another_window/student/import_student_name.py index a60addbb..15a2e4c6 100644 --- a/app/view/another_window/student/import_student_name.py +++ b/app/view/another_window/student/import_student_name.py @@ -448,9 +448,9 @@ def __load_file(self, file_path: str): raise ValueError( get_content_name_async("import_student_name", "unsupported_format") ) - - # 将列名转换为字符串,避免整数列名与UI层字符串不匹配 - data.columns = [str(col) for col in data.columns] + + # 将列名转换为字符串,避免整数列名与UI层字符串不匹配 + data.columns = [str(col) for col in data.columns] # 获取列名 columns = list(data.columns) diff --git a/data/device_uuid.json b/data/device_uuid.json index 4a2e45ab..5a7f6b5e 100644 --- a/data/device_uuid.json +++ b/data/device_uuid.json @@ -1 +1 @@ -{"device_uuid": "280f5a64-c02f-49d3-b35b-362febaaee3f"} \ No newline at end of file +{"device_uuid": "280f5a64-c02f-49d3-b35b-362febaaee3f"}