From cd35871476caece3f84963bd5deb6e251398b6bd Mon Sep 17 00:00:00 2001 From: root <13910913995@163.com> Date: Wed, 4 Feb 2026 16:05:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=BD=95=E5=88=B6=E7=AD=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 53 ++- backend/devices/camera_manager.py | 172 +++++++- backend/devices/screen_recorder.py | 130 +++++- backend/devices/utils/license_manager.py | 443 ++++++++++++++++----- backend/main.py | 127 +++++- backend/tests/test_license_manager_unit.py | 64 +++ frontend/src/renderer/main/main.js | 83 +++- 7 files changed, 905 insertions(+), 167 deletions(-) create mode 100644 backend/tests/test_license_manager_unit.py diff --git a/backend/database.py b/backend/database.py index 48ee85f9..c2fc73ed 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1103,12 +1103,15 @@ class DatabaseManager: cursor.execute(''' INSERT INTO detection_video ( - id, session_id, screen_video, timestamp - ) VALUES (?, ?, ?, ?) + id, session_id, screen_video, body_video, foot_video1, foot_video2, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?) ''', ( video_id, session_id, - video.get('screen_video_path'), + video.get('screen_video_path'), + video.get('body_video_path'), + video.get('foot_video1_path'), + video.get('foot_video2_path'), china_time )) @@ -1120,6 +1123,50 @@ class DatabaseManager: logger.error(f'保存检测视频失败: {e}') return False + def update_detection_video_latest(self, session_id: str, video: Dict[str, Any]) -> bool: + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute(''' + SELECT id, screen_video, body_video, foot_video1, foot_video2 + FROM detection_video + WHERE session_id = ? + ORDER BY timestamp DESC + LIMIT 1 + ''', (session_id,)) + row = cursor.fetchone() + if not row: + return self.save_detection_video(session_id, video) + + row_dict = dict(row) + video_id = row_dict.get('id') + + screen_video = video.get('screen_video_path') + body_video = video.get('body_video_path') + foot_video1 = video.get('foot_video1_path') + foot_video2 = video.get('foot_video2_path') + + if not screen_video: + screen_video = row_dict.get('screen_video') + if not body_video: + body_video = row_dict.get('body_video') + if not foot_video1: + foot_video1 = row_dict.get('foot_video1') + if not foot_video2: + foot_video2 = row_dict.get('foot_video2') + + cursor.execute(''' + UPDATE detection_video + SET screen_video = ?, body_video = ?, foot_video1 = ?, foot_video2 = ? + WHERE id = ? + ''', (screen_video, body_video, foot_video1, foot_video2, video_id)) + conn.commit() + return True + except Exception as e: + conn.rollback() + logger.error(f'更新检测视频失败: {e}') + return False + def delete_detection_video(self, video_ids: Union[str, List[str]]) -> bool: """删除检测视频记录(支持单个或多个ID)""" conn = self.get_connection() diff --git a/backend/devices/camera_manager.py b/backend/devices/camera_manager.py index 47e9f4b6..e7b27761 100644 --- a/backend/devices/camera_manager.py +++ b/backend/devices/camera_manager.py @@ -14,6 +14,7 @@ from typing import Optional, Dict, Any import logging import queue import gc +import os try: from .base_device import BaseDevice @@ -110,7 +111,19 @@ class CameraManager(BaseDevice): } # 全局帧队列(用于录制) - self.frame_queue = queue.Queue(maxsize=10) # 最大长度10,自动丢弃旧帧 + self.frame_queue = queue.Queue(maxsize=10) + + self._recording_enabled = False + self._recording_session_id = None + self._recording_frames_dir = None + self._recording_target_fps = None + self._recording_last_ts = 0.0 + self._recording_index = 0 + self._recording_written = 0 + self._recording_drop = 0 + self._recording_queue = queue.Queue(maxsize=300) + self._recording_thread = None + self._recording_stop_event = threading.Event() # 属性缓存机制 - 避免重复设置相同属性值 self._property_cache = {} @@ -125,6 +138,130 @@ class CameraManager(BaseDevice): pass self.logger.info(f"相机管理器初始化完成 - 设备索引: {self.device_index}") + + def start_jpeg_recording(self, session_id: str, frames_dir: str, record_fps: Optional[int] = None) -> Dict[str, Any]: + try: + if not session_id: + return {'success': False, 'message': '缺少session_id'} + if not frames_dir: + return {'success': False, 'message': '缺少frames_dir'} + + try: + os.makedirs(frames_dir, exist_ok=True) + except Exception as e: + return {'success': False, 'message': f'创建录制目录失败: {e}'} + + if record_fps is None: + record_fps = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'fps', fallback='10')) + record_fps = max(1, int(record_fps)) + + self._recording_session_id = session_id + self._recording_frames_dir = frames_dir + self._recording_target_fps = record_fps + self._recording_last_ts = 0.0 + self._recording_index = 0 + self._recording_written = 0 + self._recording_drop = 0 + self._recording_stop_event.clear() + self._recording_enabled = True + + if not self._recording_thread or not self._recording_thread.is_alive(): + self._recording_thread = threading.Thread( + target=self._recording_writer_loop, + name=f"{self.device_id}-JpegWriter", + daemon=True + ) + self._recording_thread.start() + + return {'success': True, 'message': '相机JPEG录制已启动', 'device_id': self.device_id, 'fps': record_fps, 'frames_dir': frames_dir} + except Exception as e: + return {'success': False, 'message': str(e)} + + def stop_jpeg_recording(self, session_id: Optional[str] = None) -> Dict[str, Any]: + try: + if session_id and self._recording_session_id and session_id != self._recording_session_id: + return {'success': False, 'message': 'session_id不匹配'} + + self._recording_enabled = False + self._recording_session_id = None + frames_dir = self._recording_frames_dir + fps = self._recording_target_fps + written = int(self._recording_written) + dropped = int(self._recording_drop) + + self._recording_frames_dir = None + self._recording_target_fps = None + self._recording_last_ts = 0.0 + + self._recording_stop_event.set() + if self._recording_thread and self._recording_thread.is_alive(): + self._recording_thread.join(timeout=2.0) + self._recording_thread = None + + while not self._recording_queue.empty(): + try: + self._recording_queue.get_nowait() + except queue.Empty: + break + + self._recording_stop_event.clear() + + return { + 'success': True, + 'message': '相机JPEG录制已停止', + 'device_id': self.device_id, + 'frames_dir': frames_dir, + 'fps': fps, + 'frames_written': written, + 'frames_dropped': dropped + } + except Exception as e: + return {'success': False, 'message': str(e)} + + def _recording_writer_loop(self): + while not self._recording_stop_event.is_set(): + try: + item = self._recording_queue.get(timeout=0.2) + except queue.Empty: + continue + + try: + frames_dir, idx, jpeg_bytes = item + if not frames_dir or not jpeg_bytes: + continue + filename = f"frame_{idx:06d}.jpg" + fpath = os.path.join(frames_dir, filename) + with open(fpath, 'wb') as f: + f.write(jpeg_bytes) + self._recording_written += 1 + except Exception: + self._recording_drop += 1 + finally: + try: + self._recording_queue.task_done() + except Exception: + pass + + def _maybe_enqueue_recording(self, timestamp: float, frame_bytes: bytes): + if not self._recording_enabled: + return + frames_dir = self._recording_frames_dir + target_fps = self._recording_target_fps + if not frames_dir or not target_fps or target_fps <= 0: + return + + if self._recording_last_ts > 0: + min_interval = 1.0 / float(target_fps) + if (timestamp - self._recording_last_ts) < min_interval: + return + + idx = self._recording_index + self._recording_index += 1 + self._recording_last_ts = timestamp + try: + self._recording_queue.put_nowait((frames_dir, idx, frame_bytes)) + except queue.Full: + self._recording_drop += 1 def _set_property_optimized(self, prop, value): """ @@ -649,23 +786,6 @@ class CameraManager(BaseDevice): # 更新心跳时间,防止连接监控线程判定为超时 self.update_heartbeat() - # 保存原始帧到队列(用于录制) - try: - self.frame_queue.put_nowait({ - 'frame': frame.copy(), - 'timestamp': time.time() - }) - except queue.Full: - # 队列满时丢弃最旧的帧,添加新帧 - try: - self.frame_queue.get_nowait() # 移除最旧的帧 - self.frame_queue.put_nowait({ - 'frame': frame.copy(), - 'timestamp': time.time() - }) - except queue.Empty: - pass # 队列为空,忽略 - # 处理帧(降采样以优化传输负载) processed_frame = self._process_frame(frame) @@ -740,6 +860,7 @@ class CameraManager(BaseDevice): # 转换为bytes再做base64,减少中间numpy对象的长生命周期 frame_bytes = buffer.tobytes() + self._maybe_enqueue_recording(time.time(), frame_bytes) frame_data = base64.b64encode(frame_bytes).decode('utf-8') # 发送数据 @@ -985,6 +1106,21 @@ class CameraManager(BaseDevice): self.frame_queue.get_nowait() except queue.Empty: break + + self._recording_enabled = False + self._recording_session_id = None + self._recording_frames_dir = None + self._recording_target_fps = None + self._recording_last_ts = 0.0 + self._recording_stop_event.set() + if self._recording_thread and self._recording_thread.is_alive(): + self._recording_thread.join(timeout=2.0) + self._recording_thread = None + while not self._recording_queue.empty(): + try: + self._recording_queue.get_nowait() + except queue.Empty: + break self.last_frame = None diff --git a/backend/devices/screen_recorder.py b/backend/devices/screen_recorder.py index 8ff0cda2..a19d2821 100644 --- a/backend/devices/screen_recorder.py +++ b/backend/devices/screen_recorder.py @@ -13,7 +13,8 @@ import signal import base64 from pathlib import Path from datetime import datetime -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Callable +import threading try: import pyautogui @@ -39,11 +40,38 @@ class RecordingManager: # FFmpeg进程管理 self._ffmpeg_processes = {} self._ffmpeg_meta = {} + self._transcode_threads = {} # 默认参数 self.screen_fps = 25 self.screen_size = self._get_screen_size() + def _resolve_ffmpeg_path(self) -> Optional[str]: + ffmpeg_path = None + if self.config_manager: + ffmpeg_path = ( + self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_path', fallback=None) or + self.config_manager.get_config_value('RECORDING', 'ffmpeg_path', fallback=None) + ) + if ffmpeg_path and os.path.isfile(str(ffmpeg_path)): + return str(ffmpeg_path) + base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__)) + alt_path = os.path.join(base_dir, 'ffmpeg', 'bin', 'ffmpeg.exe') + if os.path.isfile(alt_path): + return alt_path + return None + + def get_active_video_base(self, session_id: str) -> Optional[Dict[str, Any]]: + meta = self._ffmpeg_meta.get('screen') or {} + if meta.get('session_id') != session_id: + return None + return { + 'base_path': meta.get('base_path'), + 'file_dir': meta.get('file_dir'), + 'patient_id': meta.get('patient_id'), + 'session_id': meta.get('session_id') + } + def _get_screen_size(self): try: import pyautogui @@ -82,20 +110,10 @@ class RecordingManager: os.makedirs(base_path, exist_ok=True) screen_video_path = os.path.join(base_path, 'screen.mp4') target_fps = fps or self.screen_fps - ffmpeg_path = None - if self.config_manager: - ffmpeg_path = ( - self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_path', fallback=None) or - self.config_manager.get_config_value('RECORDING', 'ffmpeg_path', fallback=None) - ) - if not ffmpeg_path or not os.path.isfile(str(ffmpeg_path)): - base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__)) - alt_path = os.path.join(base_dir, 'ffmpeg', 'bin', 'ffmpeg.exe') - if os.path.isfile(alt_path): - ffmpeg_path = alt_path - else: - result['message'] = '未配置有效的ffmpeg_path,请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path' - return result + ffmpeg_path = self._resolve_ffmpeg_path() + if not ffmpeg_path: + result['message'] = '未配置有效的ffmpeg_path,请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path' + return result cmd = [ str(ffmpeg_path), @@ -136,7 +154,7 @@ class RecordingManager: creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) ) self._ffmpeg_processes['screen'] = proc - self._ffmpeg_meta['screen'] = {'base_path': base_path, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path} + self._ffmpeg_meta['screen'] = {'base_path': base_path, 'file_dir': file_dir, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path} result['success'] = True result['message'] = 'ffmpeg录制已启动' result['database_updates'] = { @@ -151,6 +169,86 @@ class RecordingManager: result['message'] = f'ffmpeg启动失败: {e}' return result + def transcode_jpeg_sequence_async( + self, + name: str, + frames_dir: str, + output_mp4_path: str, + fps: int, + on_done: Optional[Callable[[int], None]] = None + ) -> Dict[str, Any]: + try: + if not frames_dir or not os.path.isdir(frames_dir): + return {'success': False, 'message': 'frames_dir不存在'} + try: + os.makedirs(os.path.dirname(output_mp4_path), exist_ok=True) + except Exception: + pass + + ffmpeg_path = self._resolve_ffmpeg_path() + if not ffmpeg_path: + return {'success': False, 'message': 'ffmpeg_path无效'} + + codec = ( + self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_codec', fallback=None) or + self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_codec', fallback=None) or + 'libx264' + ) + preset = ( + self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_preset', fallback=None) or + self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_preset', fallback=None) or + ('p1' if codec == 'h264_nvenc' else 'ultrafast') + ) + threads = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_threads', fallback='1') or '1') + bframes = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_bframes', fallback='0') or '0') + gop = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_gop', fallback=str(max(1, int(fps * 2)))) or str(max(1, int(fps * 2)))) + + input_pattern = os.path.join(frames_dir, 'frame_%06d.jpg') + cmd = [ + str(ffmpeg_path), + '-y', + '-framerate', str(int(max(1, fps))), + '-start_number', '0', + '-i', input_pattern, + '-c:v', str(codec), + '-preset', str(preset), + '-bf', str(bframes), + '-g', str(gop), + '-pix_fmt', 'yuv420p', + '-threads', str(threads), + str(output_mp4_path) + ] + + def worker(): + code = 1 + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) + ) + self._ffmpeg_processes[name] = proc + code = proc.wait() + except Exception: + code = 1 + finally: + self._ffmpeg_processes.pop(name, None) + self._transcode_threads.pop(name, None) + if on_done: + try: + on_done(int(code)) + except Exception: + pass + + t = threading.Thread(target=worker, name=f"FFmpegTranscode-{name}", daemon=True) + self._transcode_threads[name] = t + t.start() + return {'success': True, 'message': '转码任务已启动', 'name': name, 'output': output_mp4_path} + except Exception as e: + return {'success': False, 'message': str(e)} + def stop_recording_ffmpeg(self, session_id: str = None) -> Dict[str, Any]: result = {'success': False, 'message': ''} try: diff --git a/backend/devices/utils/license_manager.py b/backend/devices/utils/license_manager.py index d1156622..a8a39ebf 100644 --- a/backend/devices/utils/license_manager.py +++ b/backend/devices/utils/license_manager.py @@ -16,6 +16,7 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.exceptions import InvalidSignature import base64 +import shutil logger = logging.getLogger(__name__) @@ -51,85 +52,345 @@ class LicenseManager: def __init__(self, config_manager=None): self.config_manager = config_manager self._machine_id = None + self._machine_id_candidates = None self._license_cache = None self._cache_timestamp = None + def _get_backend_base_dir(self) -> str: + return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + + def _get_persistent_license_dir(self) -> str: + system = platform.system() + if system == "Windows": + base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE") or "" + if not base: + base = os.path.expanduser("~\\AppData\\Local") + else: + base = os.path.expanduser("~/.config") + return os.path.join(base, "BodyCheck", "license") + + def _get_configured_license_paths(self) -> Tuple[str, str, int]: + license_path = "data/license.json" + public_key_path = "backend/license_pub.pem" + grace_days = 3 + if self.config_manager: + license_path = self.config_manager.get_config_value("LICENSE", "path", license_path) + public_key_path = self.config_manager.get_config_value("LICENSE", "public_key", public_key_path) + grace_days = int(self.config_manager.get_config_value("LICENSE", "grace_days", str(grace_days))) + + if not os.path.isabs(license_path): + license_path = os.path.join(self._get_backend_base_dir(), license_path) + if not os.path.isabs(public_key_path): + public_key_path = os.path.join(self._get_backend_base_dir(), public_key_path) + return license_path, public_key_path, grace_days + + def _get_persistent_license_paths(self) -> Tuple[str, str]: + pdir = self._get_persistent_license_dir() + return os.path.join(pdir, "license.json"), os.path.join(pdir, "license_public_key.pem") + + def _resolve_license_paths(self) -> Tuple[str, str, int]: + cfg_license_path, cfg_public_key_path, grace_days = self._get_configured_license_paths() + p_license_path, p_public_key_path = self._get_persistent_license_paths() + + effective_license_path = cfg_license_path + if not os.path.exists(effective_license_path) and os.path.exists(p_license_path): + effective_license_path = p_license_path + + effective_public_key_path = cfg_public_key_path + if not os.path.exists(effective_public_key_path) and os.path.exists(p_public_key_path): + effective_public_key_path = p_public_key_path + + return effective_license_path, effective_public_key_path, grace_days + + def _mirror_license_assets(self, license_path: Optional[str] = None, public_key_path: Optional[str] = None) -> None: + p_license_path, p_public_key_path = self._get_persistent_license_paths() + pdir = os.path.dirname(p_license_path) + try: + os.makedirs(pdir, exist_ok=True) + except Exception: + return + + if license_path and os.path.exists(license_path): + try: + shutil.copyfile(license_path, p_license_path) + except Exception: + pass + if public_key_path and os.path.exists(public_key_path): + try: + shutil.copyfile(public_key_path, p_public_key_path) + except Exception: + pass + + def install_license_file(self, source_license_path: str) -> Tuple[bool, str]: + try: + if not os.path.exists(source_license_path): + return False, "源授权文件不存在" + + cfg_license_path, cfg_public_key_path, _ = self._get_configured_license_paths() + os.makedirs(os.path.dirname(cfg_license_path), exist_ok=True) + shutil.copyfile(source_license_path, cfg_license_path) + self._mirror_license_assets(license_path=cfg_license_path, public_key_path=cfg_public_key_path) + return True, cfg_license_path + except Exception as e: + return False, str(e) + + def mirror_license_dir(self, source_dir: str) -> None: + if not source_dir or not os.path.isdir(source_dir): + return + license_candidate = os.path.join(source_dir, "license.json") + pub_candidates = [ + os.path.join(source_dir, "license_public_key.pem"), + os.path.join(source_dir, "license_pub.pem"), + os.path.join(source_dir, "public_key.pem"), + ] + pub_path = "" + for c in pub_candidates: + if os.path.exists(c): + pub_path = c + break + self._mirror_license_assets(license_path=license_candidate if os.path.exists(license_candidate) else None, public_key_path=pub_path or None) + + def _run_powershell(self, command: str, timeout: int = 10) -> str: + try: + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + command, + ], + capture_output=True, + text=True, + timeout=timeout, + ) + return (result.stdout or "").strip() + except Exception: + return "" + + def _get_windows_cpu_id(self) -> str: + out = self._run_powershell( + "(Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 -ExpandProperty ProcessorId)" + ) + return out.strip() + + def _get_windows_baseboard_serial(self) -> str: + out = self._run_powershell( + "(Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1 -ExpandProperty SerialNumber)" + ) + serial = out.strip() + if serial and serial != "To be filled by O.E.M.": + return serial + return "" + + def _get_windows_disk_serials(self) -> list: + raw = self._run_powershell( + "$d=Get-CimInstance -ClassName Win32_DiskDrive | Select-Object SerialNumber,InterfaceType,PNPDeviceID,MediaType; $d | ConvertTo-Json -Compress" + ) + if not raw: + return [] + try: + data = json.loads(raw) + except Exception: + return [] + disks = data if isinstance(data, list) else ([data] if isinstance(data, dict) else []) + serials = [] + for d in disks: + serial = str((d.get("SerialNumber") or "")).strip() + iface = str((d.get("InterfaceType") or "")).strip().upper() + pnp = str((d.get("PNPDeviceID") or "")).strip().upper() + media = str((d.get("MediaType") or "")).strip().upper() + if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media: + serials.append(serial) + serials = sorted(set(serials)) + return serials + + def _get_windows_identifiers(self) -> Dict[str, Any]: + cpu_id = self._get_windows_cpu_id() + board_serial = self._get_windows_baseboard_serial() + disk_serials = self._get_windows_disk_serials() + + if not cpu_id: + try: + result = subprocess.run( + ["wmic", "cpu", "get", "ProcessorId", "/value"], + capture_output=True, + text=True, + timeout=10, + ) + for line in (result.stdout or "").split("\n"): + if "ProcessorId=" in line: + cpu_id = line.split("=", 1)[1].strip() + if cpu_id: + break + except Exception: + pass + + if not board_serial: + try: + result = subprocess.run( + ["wmic", "baseboard", "get", "SerialNumber", "/value"], + capture_output=True, + text=True, + timeout=10, + ) + for line in (result.stdout or "").split("\n"): + if "SerialNumber=" in line: + board_serial = line.split("=", 1)[1].strip() + if board_serial and board_serial != "To be filled by O.E.M.": + break + board_serial = "" + except Exception: + pass + + if not disk_serials: + try: + result = subprocess.run( + [ + "wmic", + "path", + "Win32_DiskDrive", + "get", + "SerialNumber,InterfaceType,PNPDeviceID,MediaType", + "/value", + ], + capture_output=True, + text=True, + timeout=10, + ) + block = {} + serials = [] + for line in (result.stdout or "").split("\n"): + line = line.strip() + if not line: + serial = (block.get("SerialNumber") or "").strip() + iface = (block.get("InterfaceType") or "").strip().upper() + pnp = (block.get("PNPDeviceID") or "").strip().upper() + media = (block.get("MediaType") or "").strip().upper() + if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media: + serials.append(serial) + block = {} + continue + if "=" in line: + k, v = line.split("=", 1) + block[k] = v + if block: + serial = (block.get("SerialNumber") or "").strip() + iface = (block.get("InterfaceType") or "").strip().upper() + pnp = (block.get("PNPDeviceID") or "").strip().upper() + media = (block.get("MediaType") or "").strip().upper() + if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media: + serials.append(serial) + disk_serials = sorted(set(serials)) + except Exception: + pass + + mac = "" + try: + import uuid + + mac = ":".join( + ["{:02x}".format((uuid.getnode() >> elements) & 0xFF) for elements in range(0, 2 * 6, 2)][::-1] + ) + except Exception: + mac = "" + + return { + "cpu_id": cpu_id.strip() if cpu_id else "", + "board_serial": board_serial.strip() if board_serial else "", + "disk_serials": disk_serials or [], + "mac": mac, + "node": platform.node(), + "processor": platform.processor(), + } + + def _hash_core_info(self, core_info: list) -> str: + combined_info = "|".join(sorted(core_info)) + return hashlib.sha256(combined_info.encode("utf-8")).hexdigest()[:16].upper() + + def _build_machine_id_candidates(self) -> list: + system = platform.system() + info: Dict[str, Any] = {} + if system == "Windows": + info = self._get_windows_identifiers() + else: + info = { + "cpu_id": "", + "board_serial": "", + "disk_serials": [], + "mac": "", + "node": platform.node(), + "processor": platform.processor(), + } + + cpu_id = info.get("cpu_id") or "" + board_serial = info.get("board_serial") or "" + disk_serials = info.get("disk_serials") or [] + mac = info.get("mac") or "" + + variants = [] + + full_core = [] + if cpu_id: + full_core.append(f"CPU:{cpu_id}") + if board_serial: + full_core.append(f"BOARD:{board_serial}") + for s in disk_serials: + full_core.append(f"DISK:{s}") + if full_core: + variants.append(full_core) + + no_disk = [] + if cpu_id: + no_disk.append(f"CPU:{cpu_id}") + if board_serial: + no_disk.append(f"BOARD:{board_serial}") + if no_disk: + variants.append(no_disk) + + if cpu_id: + variants.append([f"CPU:{cpu_id}"]) + if board_serial: + variants.append([f"BOARD:{board_serial}"]) + + if mac: + variants.append([f"MAC:{mac}"]) + if cpu_id and mac: + variants.append([f"CPU:{cpu_id}", f"MAC:{mac}"]) + + fallback_core = [] + node = (info.get("node") or "").strip() + proc = (info.get("processor") or "").strip() + if node: + fallback_core.append(f"NODE:{node}") + if proc: + fallback_core.append(f"PROCESSOR:{proc}") + if fallback_core: + variants.append(fallback_core) + + prefix = "W10-" if system == "Windows" else "FB-" + candidates = [] + for core in variants: + try: + mid = f"{prefix}{self._hash_core_info(core)}" + except Exception: + continue + if mid not in candidates: + candidates.append(mid) + return candidates + def get_machine_id(self) -> str: """生成机器硬件指纹""" if self._machine_id: return self._machine_id try: - core_info = [] - aux_info = [] - try: - if platform.system() == "Windows": - result = subprocess.run(['wmic', 'cpu', 'get', 'ProcessorId', '/value'], capture_output=True, text=True, timeout=10) - for line in result.stdout.split('\n'): - if 'ProcessorId=' in line: - cpu_id = line.split('=')[1].strip() - if cpu_id: - core_info.append(f"CPU:{cpu_id}") - break - except Exception as e: - logger.warning(f"获取CPU信息失败: {e}") - try: - if platform.system() == "Windows": - result = subprocess.run(['wmic', 'baseboard', 'get', 'SerialNumber', '/value'], capture_output=True, text=True, timeout=10) - for line in result.stdout.split('\n'): - if 'SerialNumber=' in line: - board_serial = line.split('=')[1].strip() - if board_serial and board_serial != "To be filled by O.E.M.": - core_info.append(f"BOARD:{board_serial}") - break - except Exception as e: - logger.warning(f"获取主板信息失败: {e}") - try: - if platform.system() == "Windows": - # 获取磁盘信息并过滤掉USB/可移动介质,收集所有内部磁盘序列号 - result = subprocess.run( - ['wmic', 'path', 'Win32_DiskDrive', 'get', 'SerialNumber,InterfaceType,PNPDeviceID,MediaType', '/value'], - capture_output=True, text=True, timeout=10 - ) - block = {} - for line in result.stdout.split('\n'): - line = line.strip() - if not line: - # 结束一个块 - serial = (block.get('SerialNumber') or '').strip() - iface = (block.get('InterfaceType') or '').strip().upper() - pnp = (block.get('PNPDeviceID') or '').strip().upper() - media = (block.get('MediaType') or '').strip().upper() - if serial and iface != 'USB' and not pnp.startswith('USBSTOR') and 'REMOVABLE' not in media: - core_info.append(f"DISK:{serial}") - block = {} - continue - if '=' in line: - k, v = line.split('=', 1) - block[k] = v - # 处理最后一个块 - if block: - serial = (block.get('SerialNumber') or '').strip() - iface = (block.get('InterfaceType') or '').strip().upper() - pnp = (block.get('PNPDeviceID') or '').strip().upper() - media = (block.get('MediaType') or '').strip().upper() - if serial and iface != 'USB' and not pnp.startswith('USBSTOR') and 'REMOVABLE' not in media: - core_info.append(f"DISK:{serial}") - except Exception as e: - logger.warning(f"获取磁盘信息失败: {e}") - try: - import uuid - mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff) for elements in range(0, 2 * 6, 2)][::-1]) - aux_info.append(f"MAC:{mac}") - except Exception as e: - logger.warning(f"获取MAC地址失败: {e}") - aux_info.append(f"OS:{platform.system()}") - aux_info.append(f"MACHINE:{platform.machine()}") - if len(core_info) < 1: - core_info.append(f"NODE:{platform.node()}") - core_info.append(f"PROCESSOR:{platform.processor()}") - combined_info = "|".join(sorted(core_info)) - machine_id = hashlib.sha256(combined_info.encode('utf-8')).hexdigest()[:16].upper() - self._machine_id = f"W10-{machine_id}" + candidates = self._build_machine_id_candidates() + if not candidates: + raise RuntimeError("无法生成机器指纹") + self._machine_id_candidates = candidates + self._machine_id = candidates[0] logger.info(f"生成机器指纹: {self._machine_id}") return self._machine_id except Exception as e: @@ -137,7 +398,13 @@ class LicenseManager: fallback_info = f"{platform.system()}-{platform.node()}-{platform.machine()}" fallback_id = hashlib.md5(fallback_info.encode('utf-8')).hexdigest()[:12].upper() self._machine_id = f"FB-{fallback_id}" + self._machine_id_candidates = [self._machine_id] return self._machine_id + + def get_machine_id_candidates(self) -> list: + if self._machine_id_candidates is None: + self.get_machine_id() + return list(self._machine_id_candidates or []) def load_license(self, license_path: str) -> Optional[Dict[str, Any]]: """加载授权文件""" @@ -231,7 +498,13 @@ class LicenseManager: # 检查机器绑定 license_machine_id = license_data.get('machine_id', '') - if license_machine_id != machine_id: + candidates = [] + if machine_id: + candidates.append(machine_id) + for mid in self.get_machine_id_candidates(): + if mid not in candidates: + candidates.append(mid) + if license_machine_id not in candidates: return False, f"授权文件与当前机器不匹配 (当前: {machine_id}, 授权: {license_machine_id})" # 检查有效期 @@ -268,19 +541,8 @@ class LicenseManager: # 获取配置 if not self.config_manager: return LicenseStatus(valid=False, message="配置管理器未初始化") - - license_path = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json') - public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem') - grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3')) - - # 转换为绝对路径 - if not os.path.isabs(license_path): - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录 - license_path = os.path.join(base_dir, license_path) - - if not os.path.isabs(public_key_path): - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录 - public_key_path = os.path.join(base_dir, public_key_path) + + license_path, public_key_path, grace_days = self._resolve_license_paths() # 获取机器指纹 machine_id = self.get_machine_id() @@ -308,6 +570,8 @@ class LicenseManager: self._license_cache = status self._cache_timestamp = datetime.now().timestamp() return status + + self._mirror_license_assets(license_path=license_path, public_key_path=public_key_path) # 检查有效性 is_valid, message = self.check_validity(license_data, machine_id, grace_days) @@ -362,14 +626,7 @@ class LicenseManager: if not self.config_manager: return False, "配置管理器未初始化" - # 解析公钥路径与宽限期 - public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem') - grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3')) - - # 转换为绝对路径(相对backend目录) - if not os.path.isabs(public_key_path): - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - public_key_path = os.path.join(base_dir, public_key_path) + _, public_key_path, grace_days = self._resolve_license_paths() if not os.path.exists(license_path): return False, f"授权文件不存在: {license_path}" diff --git a/backend/main.py b/backend/main.py index 0fb57305..6d981aac 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ import sys import json import time import threading +import shutil from datetime import datetime from flask import Flask, jsonify from flask import request as flask_request @@ -377,7 +378,11 @@ class AppServer: 'success': False, 'error': '授权管理器未初始化' }), 500 - + + if self.license_manager: + self.license_status = self.license_manager.get_license_status(force_reload=True) + self.app.license_status = self.license_status + return jsonify({ 'success': True, 'data': { @@ -548,22 +553,14 @@ class AppServer: if is_valid: # 覆盖系统授权文件为上传的文件 - try: - license_path_cfg = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json') if self.config_manager else 'data/license.json' - # 解析目标路径为绝对路径 - if not os.path.isabs(license_path_cfg): - base_dir = os.path.dirname(os.path.abspath(__file__)) - license_path_cfg = os.path.join(base_dir, license_path_cfg) - os.makedirs(os.path.dirname(license_path_cfg), exist_ok=True) - # 移动/覆盖授权文件 - import shutil - shutil.copyfile(temp_path, license_path_cfg) - except Exception as e: - self.logger.error(f'保存授权文件失败: {e}') - return jsonify({'success': False, 'error': f'保存授权文件失败: {str(e)}'}), 500 + ok, result = self.license_manager.install_license_file(temp_path) + if not ok: + self.logger.error(f'保存授权文件失败: {result}') + return jsonify({'success': False, 'error': f'保存授权文件失败: {result}'}), 500 # 更新授权状态(强制刷新) self.license_status = self.license_manager.get_license_status(force_reload=True) + self.app.license_status = self.license_status return jsonify({ 'success': True, @@ -631,8 +628,11 @@ class AppServer: return jsonify({'success': False, 'error': '压缩包包含非法路径'}), 400 zip_ref.extractall(target_dir) + self.license_manager.mirror_license_dir(target_dir) + # 刷新授权 self.license_status = self.license_manager.get_license_status(force_reload=True) + self.app.license_status = self.license_status return jsonify({ 'success': True, @@ -1448,6 +1448,30 @@ class AppServer: try: # 使用新的ffmpeg录制方法 recording_response = self.recording_manager.start_recording_ffmpeg(session_id, patient_id, screen_location) + + base_info = None + try: + base_info = self.recording_manager.get_active_video_base(session_id) + except Exception: + base_info = None + + if base_info and base_info.get('base_path'): + base_path = base_info.get('base_path') + try: + cam1 = self.device_coordinator.devices.get('camera1') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None + if cam1 and getattr(cam1, 'is_streaming', False): + cam1_frames_dir = os.path.join(base_path, 'camera1_frames') + cam1.start_jpeg_recording(session_id=session_id, frames_dir=cam1_frames_dir) + except Exception as e: + self.logger.error(f'启动camera1 JPEG录制失败: {e}') + + try: + cam2 = self.device_coordinator.devices.get('camera2') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None + if cam2 and getattr(cam2, 'is_streaming', False): + cam2_frames_dir = os.path.join(base_path, 'camera2_frames') + cam2.start_jpeg_recording(session_id=session_id, frames_dir=cam2_frames_dir) + except Exception as e: + self.logger.error(f'启动camera2 JPEG录制失败: {e}') # 处理录制管理器返回的数据库更新信息 if recording_response and recording_response.get('success') and 'database_updates' in recording_response: @@ -1456,7 +1480,10 @@ class AppServer: # 保存检测视频记录(映射到 detection_video 表字段) video_paths = db_updates.get('video_paths', {}) video_record = { - 'screen_video_path': video_paths.get('screen_video_path') + 'screen_video_path': video_paths.get('screen_video_path'), + 'body_video_path': None, + 'foot_video1_path': None, + 'foot_video2_path': None } try: @@ -1495,6 +1522,76 @@ class AppServer: except Exception as rec_e: self.logger.error(f'停止同步录制失败: {rec_e}', exc_info=True) raise + + base_info = None + try: + base_info = self.recording_manager.get_active_video_base(session_id) + except Exception: + base_info = None + + if base_info and base_info.get('base_path') and base_info.get('file_dir'): + base_path = base_info.get('base_path') + file_dir = base_info.get('file_dir') + + cam1 = self.device_coordinator.devices.get('camera1') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None + cam2 = self.device_coordinator.devices.get('camera2') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None + + if cam1: + try: + cam1_stop = cam1.stop_jpeg_recording(session_id=session_id) + cam1_frames_dir = cam1_stop.get('frames_dir') + cam1_fps = int(cam1_stop.get('fps') or 10) + if cam1_frames_dir and os.path.isdir(cam1_frames_dir): + out1 = os.path.join(base_path, 'foot1.mp4') + rel1 = os.path.relpath(out1, file_dir).replace('\\', '/') + def _done1(code: int): + if code == 0: + try: + self.db_manager.update_detection_video_latest(session_id, {'foot_video1_path': rel1}) + except Exception as e: + self.logger.error(f'更新foot_video1失败: {e}') + try: + shutil.rmtree(cam1_frames_dir, ignore_errors=True) + except Exception as e: + self.logger.error(f'删除camera1_frames失败: {e}') + self.recording_manager.transcode_jpeg_sequence_async( + name=f'{session_id}-camera1', + frames_dir=cam1_frames_dir, + output_mp4_path=out1, + fps=cam1_fps, + on_done=_done1 + ) + except Exception as e: + self.logger.error(f'停止/转码camera1录制失败: {e}') + + if cam2: + try: + cam2_stop = cam2.stop_jpeg_recording(session_id=session_id) + cam2_frames_dir = cam2_stop.get('frames_dir') + cam2_fps = int(cam2_stop.get('fps') or 10) + if cam2_frames_dir and os.path.isdir(cam2_frames_dir): + out2 = os.path.join(base_path, 'foot2.mp4') + rel2 = os.path.relpath(out2, file_dir).replace('\\', '/') + def _done2(code: int): + if code == 0: + try: + self.db_manager.update_detection_video_latest(session_id, {'foot_video2_path': rel2}) + except Exception as e: + self.logger.error(f'更新foot_video2失败: {e}') + try: + shutil.rmtree(cam2_frames_dir, ignore_errors=True) + except Exception as e: + self.logger.error(f'删除camera2_frames失败: {e}') + self.recording_manager.transcode_jpeg_sequence_async( + name=f'{session_id}-camera2', + frames_dir=cam2_frames_dir, + output_mp4_path=out2, + fps=cam2_fps, + on_done=_done2 + ) + except Exception as e: + self.logger.error(f'停止/转码camera2录制失败: {e}') + return jsonify({'success': True,'msg': '停止录制成功'}) except Exception as e: self.logger.error(f'停止检测失败: {e}', exc_info=True) diff --git a/backend/tests/test_license_manager_unit.py b/backend/tests/test_license_manager_unit.py new file mode 100644 index 00000000..da992ba7 --- /dev/null +++ b/backend/tests/test_license_manager_unit.py @@ -0,0 +1,64 @@ +import os +from datetime import datetime, timedelta, timezone + +import pytest + + +class DummyConfigManager: + def __init__(self, values): + self._values = values + + def get_config_value(self, section, key, fallback=None): + return self._values.get((section, key), fallback) + + +def test_check_validity_accepts_candidate_machine_id(monkeypatch): + from devices.utils.license_manager import LicenseManager + + lm = LicenseManager(config_manager=DummyConfigManager({})) + monkeypatch.setattr(lm, "get_machine_id_candidates", lambda: ["MID-PRIMARY", "MID-ALT"]) + + license_data = { + "product": "BodyBalanceEvaluation", + "license_id": "L1", + "license_type": "full", + "machine_id": "MID-ALT", + "issued_at": datetime.now(timezone.utc).isoformat(), + "expires_at": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat().replace("+00:00", "Z"), + "signature": "x", + "features": {"export": True}, + } + + ok, msg = lm.check_validity(license_data, machine_id="MID-PRIMARY", grace_days=0) + assert ok is True, msg + + +def test_resolve_license_paths_falls_back_to_persistent(monkeypatch, tmp_path): + from devices.utils.license_manager import LicenseManager + + programdata = tmp_path / "programdata" + persistent_dir = programdata / "BodyCheck" / "license" + persistent_dir.mkdir(parents=True) + (persistent_dir / "license.json").write_text('{"a":1}', encoding="utf-8") + (persistent_dir / "license_public_key.pem").write_text("PUB", encoding="utf-8") + + cfg_dir = tmp_path / "cfg" + cfg_license_path = str(cfg_dir / "license.json") + cfg_pub_path = str(cfg_dir / "license_public_key.pem") + + cfg = DummyConfigManager( + { + ("LICENSE", "path"): cfg_license_path, + ("LICENSE", "public_key"): cfg_pub_path, + ("LICENSE", "grace_days"): "3", + } + ) + lm = LicenseManager(config_manager=cfg) + + monkeypatch.setenv("PROGRAMDATA", str(programdata)) + monkeypatch.setattr("platform.system", lambda: "Windows") + + license_path, pub_path, grace_days = lm._resolve_license_paths() + assert license_path == str(persistent_dir / "license.json") + assert pub_path == str(persistent_dir / "license_public_key.pem") + assert grace_days == 3 diff --git a/frontend/src/renderer/main/main.js b/frontend/src/renderer/main/main.js index 98f68ba7..a7969c6c 100644 --- a/frontend/src/renderer/main/main.js +++ b/frontend/src/renderer/main/main.js @@ -3,7 +3,7 @@ const path = require('path'); const http = require('http'); const fs = require('fs'); const url = require('url'); -const { spawn } = require('child_process'); +const { spawn, exec, execSync } = require('child_process'); let mainWindow; let localServer; let backendProcess; @@ -11,6 +11,24 @@ let splashWindow; // app.disableHardwareAcceleration(); app.disableDomainBlockingFor3DAPIs(); console.log('Electron version:', process.versions.electron); + +const gotSingleInstanceLock = app.requestSingleInstanceLock(); +if (!gotSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + return; + } + if (app.isReady()) { + createWindow(); + } + }); +} ipcMain.handle('generate-report-pdf', async (event, payload) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) throw new Error('窗口未找到'); @@ -134,6 +152,21 @@ ipcMain.handle('generate-report-pdf', async (event, payload) => { }); function startBackendService() { + if (backendProcess) { + console.log('Backend service already started (tracked).'); + return; + } + + try { + const tasklistOut = execSync('tasklist /FI "IMAGENAME eq BodyBalanceBackend.exe" /NH', { windowsHide: true }).toString(); + if (tasklistOut && tasklistOut.toLowerCase().includes('bodybalancebackend.exe')) { + console.log('Backend service already running (tasklist). Skip spawning a new instance.'); + return; + } + } catch (e) { + console.log('Backend process detection failed, continue to spawn:', e && e.message ? e.message : e); + } + // 在打包后的应用中,使用process.resourcesPath获取resources目录 const resourcesPath = process.resourcesPath || path.join(__dirname, '../..'); const backendPath = path.join(resourcesPath, 'backend/BodyBalanceBackend/BodyBalanceBackend.exe'); @@ -199,7 +232,6 @@ function stopBackendService() { // 强制杀死所有BodyBalanceBackend.exe进程 try { - const { exec } = require('child_process'); exec('taskkill /f /im BodyBalanceBackend.exe', (error, stdout, stderr) => { if (error) { // 如果没有找到进程,taskkill会返回错误,这是正常的 @@ -300,6 +332,11 @@ function createWindow() { } function startLocalServer(callback) { + if (localServer) { + console.log('Local server already started on http://localhost:3000'); + callback(); + return; + } const staticPath = path.join(__dirname, '../dist/'); localServer = http.createServer((req, res) => { @@ -360,26 +397,28 @@ function startLocalServer(callback) { // 应用事件处理 // 关闭硬件加速以规避 GPU 进程异常导致的闪烁 // app.disableHardwareAcceleration(); -app.whenReady().then(createWindow); +if (gotSingleInstanceLock) { + app.whenReady().then(createWindow); -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - if (localServer) { - localServer.close(); + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + if (localServer) { + localServer.close(); + } + // 关闭后端服务 + stopBackendService(); + app.quit(); } - // 关闭后端服务 + }); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + + // 应用退出前清理资源 + app.on('before-quit', () => { stopBackendService(); - app.quit(); - } -}); - -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } -}); - -// 应用退出前清理资源 -app.on('before-quit', () => { - stopBackendService(); -}); + }); +}