diff --git a/backend/config.ini b/backend/config.ini
index 149d17f0..f84a9c3f 100644
--- a/backend/config.ini
+++ b/backend/config.ini
@@ -60,7 +60,7 @@ pressure_port = COM5
pressure_baudrate = 115200
[REMOTE]
-enable = False
+enable = True
port = COM6
baudrate = 115200
timeout = 0.1
diff --git a/backend/database.py b/backend/database.py
index 30b5cecf..c2fc73ed 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -830,7 +830,7 @@ class DatabaseManager:
cursor.execute('''
SELECT * FROM detection_data
WHERE session_id = ?
- ORDER BY timestamp
+ ORDER BY timestamp desc
''', (session_id,))
data_rows = cursor.fetchall()
@@ -849,7 +849,7 @@ class DatabaseManager:
cursor.execute('''
SELECT * FROM detection_video
WHERE session_id = ?
- ORDER BY timestamp
+ ORDER BY timestamp desc
''', (session_id,))
video_rows = cursor.fetchall()
@@ -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/device_coordinator.py b/backend/devices/device_coordinator.py
index 6f66d0c6..7c569fb4 100644
--- a/backend/devices/device_coordinator.py
+++ b/backend/devices/device_coordinator.py
@@ -291,9 +291,11 @@ class DeviceCoordinator:
from .remote_control_manager import RemoteControlManager
remote = RemoteControlManager(self.socketio, self.config_manager)
self.devices['remote'] = remote
- if remote.initialize():
- return True
- return False
+ if not remote.initialize():
+ return False
+ if hasattr(remote, 'start_streaming'):
+ return bool(remote.start_streaming())
+ return True
except Exception as e:
self.logger.error(f"初始化遥控器失败: {e}")
return False
@@ -743,6 +745,12 @@ class DeviceCoordinator:
except ImportError:
from femtobolt_manager import FemtoBoltManager
new_device = FemtoBoltManager(self.socketio, self.config_manager)
+ elif device_name == 'remote':
+ try:
+ from .remote_control_manager import RemoteControlManager
+ except ImportError:
+ from remote_control_manager import RemoteControlManager
+ new_device = RemoteControlManager(self.socketio, self.config_manager)
else:
raise ValueError(f"未知的设备类型: {device_name}")
@@ -777,6 +785,17 @@ class DeviceCoordinator:
init_time = (time.time() - init_start) * 1000
self.logger.info(f"{device_name} 设备初始化成功 (耗时: {init_time:.1f}ms)")
+ if device_name == 'remote' and hasattr(new_device, 'start_streaming'):
+ self.logger.info(f"正在启动 {device_name} 设备推流...")
+ try:
+ if not new_device.start_streaming():
+ self.logger.error(f"启动 {device_name} 设备推流失败")
+ return False
+ was_streaming = True
+ except Exception as e:
+ self.logger.error(f"启动 {device_name} 推流异常: {e}")
+ return False
+
# 设备初始化成功后,确保状态广播正确
# 此时设备应该已经通过initialize()方法中的set_connected(True)触发了状态变化通知
# 但为了确保状态一致性,我们再次确认状态
diff --git a/backend/devices/remote_control_manager.py b/backend/devices/remote_control_manager.py
index 68fb6538..cf0ff7cb 100644
--- a/backend/devices/remote_control_manager.py
+++ b/backend/devices/remote_control_manager.py
@@ -66,7 +66,7 @@ class RemoteControlManager(BaseDevice):
def initialize(self) -> bool:
try:
self.logger.info(f"初始化遥控器串口: {self.port}, {self.baudrate}bps, 8N1")
- self.set_connected(True)
+ self.set_connected(False)
self._device_info['initialized_at'] = time.time()
return True
except Exception as e:
@@ -88,6 +88,9 @@ class RemoteControlManager(BaseDevice):
stopbits=self.stopbits,
timeout=self.timeout,
)
+ self.set_connected(True)
+ self.update_heartbeat()
+ self.is_streaming = True
self._running = True
self._thread = threading.Thread(target=self._worker_loop, daemon=True)
self._thread.start()
@@ -110,6 +113,9 @@ class RemoteControlManager(BaseDevice):
self._thread.join(timeout=2.0)
if self._ser and self._ser.is_open:
self._ser.close()
+ self._ser = None
+ self.set_connected(False)
+ self.is_streaming = False
self.logger.info("遥控器串口监听已停止")
return True
except Exception as e:
@@ -248,6 +254,7 @@ class RemoteControlManager(BaseDevice):
time.sleep(0.05)
continue
chunk = self._ser.read(64)
+ self.update_heartbeat()
if chunk:
try:
hexstr = ' '.join(f'{b:02X}' for b in chunk)
@@ -262,5 +269,14 @@ class RemoteControlManager(BaseDevice):
self.logger.debug("遥控器串口暂无数据")
except Exception as e:
self.logger.error(f"遥控器串口读取异常: {e}")
+ try:
+ if self._ser and self._ser.is_open:
+ self._ser.close()
+ except Exception:
+ pass
+ self._ser = None
+ self.set_connected(False)
+ self.is_streaming = False
+ self._running = False
time.sleep(0.1)
self.logger.info("遥控器串口线程结束")
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/config_manager.py b/backend/devices/utils/config_manager.py
index 298bec7e..0feddb58 100644
--- a/backend/devices/utils/config_manager.py
+++ b/backend/devices/utils/config_manager.py
@@ -434,6 +434,8 @@ class ConfigManager:
self.set_config_value('DEVICES', 'imu_use_mock', str(config_data['use_mock']))
if 'mac_address' in config_data:
self.set_config_value('DEVICES', 'imu_mac_address', config_data['mac_address'])
+ if 'ble_name' in config_data:
+ self.set_config_value('DEVICES', 'imu_ble_name', config_data['ble_name'])
results['imu'] = {
'success': True,
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();
-});
+ });
+}
diff --git a/frontend/src/renderer/src/views/Detection.vue b/frontend/src/renderer/src/views/Detection.vue
index d2d58a3a..5e7ccc51 100644
--- a/frontend/src/renderer/src/views/Detection.vue
+++ b/frontend/src/renderer/src/views/Detection.vue
@@ -3,6 +3,7 @@
@@ -462,7 +444,7 @@
+ class="userinfo-edit-img" style="cursor: pointer;" @click="handleEditUserInfo">
-
-
@@ -612,14 +538,14 @@