修改了后台视频录制等功能

This commit is contained in:
root 2026-02-04 16:05:48 +08:00
parent 5059ad6158
commit cd35871476
7 changed files with 905 additions and 167 deletions

View File

@ -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('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()

View File

@ -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 = {}
@ -126,6 +139,130 @@ class CameraManager(BaseDevice):
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')
# 发送数据
@ -986,6 +1107,21 @@ class CameraManager(BaseDevice):
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
super().cleanup()

View File

@ -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,18 +110,8 @@ 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:
ffmpeg_path = self._resolve_ffmpeg_path()
if not ffmpeg_path:
result['message'] = '未配置有效的ffmpeg_path请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path'
return result
@ -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:

View File

@ -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,8 +398,14 @@ 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]]:
"""加载授权文件"""
try:
@ -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})"
# 检查有效期
@ -269,18 +542,7 @@ 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()
@ -309,6 +571,8 @@ class LicenseManager:
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}"

View File

@ -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
@ -378,6 +379,10 @@ class AppServer:
'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,
@ -1449,6 +1449,30 @@ class AppServer:
# 使用新的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:
db_updates = recording_response['database_updates']
@ -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)

View File

@ -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

View File

@ -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,9 +397,10 @@ function startLocalServer(callback) {
// 应用事件处理
// 关闭硬件加速以规避 GPU 进程异常导致的闪烁
// app.disableHardwareAcceleration();
app.whenReady().then(createWindow);
if (gotSingleInstanceLock) {
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
if (localServer) {
localServer.close();
@ -371,15 +409,16 @@ app.on('window-all-closed', () => {
stopBackendService();
app.quit();
}
});
});
app.on('activate', () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// 应用退出前清理资源
app.on('before-quit', () => {
// 应用退出前清理资源
app.on('before-quit', () => {
stopBackendService();
});
});
}