[阶段3] FAM-Core 瘦身为管理后台+Oracle-Sync 同步引擎,FAM-UI 改读同步镜像 - 删除 scheduler/dispatcher/poller/event_receiver/video_server,新增 oracle_sync 每30分钟拉增量写 sync_* 镜像表;member_manager/chat_handler 改走同步数据;UI 移除帧图改为事件时间线;DDL 新增 sync_videos/events/people/cursor
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
FAM-Core 主应用 - Flask 单进程
|
||||
FAM-Core 主应用 - Flask 单进程(新架构 v2)
|
||||
|
||||
承载: Task-Scheduler / Dispatcher / Poller / Event-Receiver / Chat-Handler / Member-Manager / Video-Server
|
||||
承载: Oracle-Sync(每 30 分钟拉取增量镜像)+ Member-Manager + Chat-Handler
|
||||
|
||||
NAS 不再处理视频:无 Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server。
|
||||
所有视频分析在 Oracle 完成,NAS 仅作管理后台拉取展示,CPU 占用大幅降低。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
|
||||
from flask import Flask, jsonify
|
||||
|
||||
# 确保包路径
|
||||
@@ -14,83 +16,49 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from .config_loader import load_config
|
||||
from .logger import setup_logger
|
||||
from .scheduler.scheduler import TaskScheduler
|
||||
from .dispatcher.dispatcher import Dispatcher
|
||||
from .poller.poller import Poller
|
||||
from .event_receiver.event_receiver import event_bp
|
||||
from .oracle_sync import get_sync
|
||||
from .chat_handler.chat_handler import chat_bp
|
||||
from .member_manager.member_manager import member_bp
|
||||
from .video_server.video_server import video_bp
|
||||
|
||||
logger = setup_logger('fam-core.app')
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(event_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(member_bp)
|
||||
app.register_blueprint(video_bp)
|
||||
|
||||
# 健康检查
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
return jsonify({"status": "ok", "service": "fam-core"}), 200
|
||||
|
||||
# 初始化后台线程
|
||||
_scheduler = None
|
||||
_dispatcher = None
|
||||
_poller = None
|
||||
|
||||
# 初始化后台同步线程(NAS 唯一常驻线程)
|
||||
_sync = None
|
||||
try:
|
||||
_scheduler = TaskScheduler()
|
||||
_scheduler.start()
|
||||
logger.info("Task-Scheduler 已启动")
|
||||
_sync = get_sync()
|
||||
_sync.start()
|
||||
logger.info("Oracle-Sync 已启动")
|
||||
# 启动后立刻拉一次,前端无需等待首个周期即有数据
|
||||
try:
|
||||
_sync.trigger_now()
|
||||
logger.info("启动首次同步完成")
|
||||
except Exception as e:
|
||||
logger.warning(f"启动首次同步失败(后续周期会重试): {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Task-Scheduler 启动失败: {e}")
|
||||
|
||||
try:
|
||||
_dispatcher = Dispatcher()
|
||||
_dispatcher.start()
|
||||
logger.info("Dispatcher 已启动")
|
||||
except Exception as e:
|
||||
logger.error(f"Dispatcher 启动失败: {e}")
|
||||
|
||||
try:
|
||||
_poller = Poller()
|
||||
_poller.start()
|
||||
logger.info("Poller 已启动")
|
||||
except Exception as e:
|
||||
logger.error(f"Poller 启动失败: {e}")
|
||||
logger.error(f"Oracle-Sync 启动失败: {e}")
|
||||
|
||||
|
||||
@app.route('/api/status', methods=['GET'])
|
||||
def status():
|
||||
"""系统状态(检查线程实际存活)"""
|
||||
"""系统状态"""
|
||||
return jsonify({
|
||||
"scheduler_running": _scheduler.is_alive() if _scheduler else False,
|
||||
"dispatcher_running": _dispatcher.is_alive() if _dispatcher else False,
|
||||
"poller_running": _poller.is_alive() if _poller else False,
|
||||
"service": "fam-core",
|
||||
"sync": _sync.status() if _sync else {"running": False, "error": "未初始化"},
|
||||
}), 200
|
||||
|
||||
|
||||
def _watchdog_run():
|
||||
"""看门狗:每 60s 检查线程存活,崩溃自动重启"""
|
||||
logger.info("Watchdog 线程启动,检查间隔 60s")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
for comp, name in [(_scheduler, 'Scheduler'), (_dispatcher, 'Dispatcher'), (_poller, 'Poller')]:
|
||||
if comp and hasattr(comp, 'check_and_restart'):
|
||||
try:
|
||||
comp.check_and_restart()
|
||||
except Exception as e:
|
||||
logger.error(f"Watchdog 重启 {name} 失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
_watchdog_thread = threading.Thread(target=_watchdog_run, daemon=True, name='watchdog')
|
||||
_watchdog_thread.start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cfg = load_config()
|
||||
port = cfg.get('server', {}).get('port', 8000)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""
|
||||
Chat-Handler - Flask 蓝图,接收用户问答
|
||||
Chat-Handler - Flask 蓝图,接收用户问答(新架构 v2)
|
||||
|
||||
处理逻辑:
|
||||
1. 根据 queried_person 和 queried_date 查询 event_details
|
||||
2. 拼接上下文(每条明细一行)
|
||||
3. 若明细条数 > 50,按小时聚合成摘要
|
||||
4. POST Oracle Ollama 纯文本模式,调问答 Prompt
|
||||
5. 插入 chat_history
|
||||
6. 返回回答
|
||||
逻辑:
|
||||
1. 按 queried_person + queried_date 从 sync_events 拉取相关事件
|
||||
(person_list_json 含该名且 ts 落在日期内)
|
||||
2. 拼接上下文文本(每事件一行:时间 + 摄像头 + 描述 + 人物 + 是否关注)
|
||||
3. 调 Oracle FAM-Edge 问答编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||
4. 插入 chat_history
|
||||
5. 返回回答
|
||||
|
||||
注意: 上下文来自 Oracle 已分析好的事件摘要,不做本地视频处理。
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify
|
||||
from collections import defaultdict
|
||||
|
||||
from ..logger import setup_logger
|
||||
from ..config_loader import load_config
|
||||
@@ -21,9 +23,9 @@ logger = setup_logger('fam-core.chat_handler')
|
||||
|
||||
chat_bp = Blueprint('chat_handler', __name__)
|
||||
|
||||
CHAT_SYSTEM_PROMPT = """你是家庭监控助手。根据以下今日监控数据,回答用户问题。
|
||||
CHAT_SYSTEM_PROMPT = """你是家庭监控助手。根据以下监控数据,回答用户问题。
|
||||
|
||||
今日数据(按时间顺序,每条一行):
|
||||
监控数据(按时间顺序,每条一行):
|
||||
{context}
|
||||
|
||||
已知家庭成员: {members}
|
||||
@@ -33,41 +35,27 @@ CHAT_SYSTEM_PROMPT = """你是家庭监控助手。根据以下今日监控数
|
||||
要求:
|
||||
- 只基于上述数据回答,不要编造
|
||||
- 按时间顺序总结
|
||||
- 若有关注事件(跌倒、哭闹等),重点提示
|
||||
- 若有关注事件(跌倒、哭闹、陌生人等),重点提示
|
||||
- 若当天没有该人员的数据,明确说"今天没有观察到{person}"
|
||||
- 用自然语言回答,不要输出 JSON
|
||||
"""
|
||||
|
||||
|
||||
def _format_details(details):
|
||||
"""将 event_details 格式化为文本行"""
|
||||
def _format_events(rows):
|
||||
"""将 sync_events 查询行格式化为上下文文本"""
|
||||
lines = []
|
||||
for d in details:
|
||||
timestamp = d['frame_timestamp'].strftime('%H:%M') if hasattr(d['frame_timestamp'], 'strftime') else str(d['frame_timestamp'])
|
||||
camera = d.get('camera_name', '')
|
||||
person = d.get('person', '')
|
||||
action = d.get('action', '')
|
||||
clothing = d.get('clothing', '')
|
||||
attention = ' [关注事件]' if d.get('is_attention_event') else ''
|
||||
lines.append(f"[{timestamp} {camera}] {person} {action} ({clothing}){attention}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _aggregate_by_hour(details):
|
||||
"""当明细 > 50 条时,按小时聚合"""
|
||||
hourly = defaultdict(list)
|
||||
for d in details:
|
||||
ts = d['frame_timestamp']
|
||||
hour_key = ts.strftime('%Y-%m-%d %H:00') if hasattr(ts, 'strftime') else str(ts)
|
||||
hourly[hour_key].append(d)
|
||||
|
||||
lines = []
|
||||
for hour, items in sorted(hourly.items()):
|
||||
persons = set(i.get('person', '') for i in items)
|
||||
actions = set(i.get('action', '') for i in items)
|
||||
has_attention = any(i.get('is_attention_event') for i in items)
|
||||
attention = ' [含关注事件]' if has_attention else ''
|
||||
lines.append(f"[{hour}] {','.join(persons)}: {','.join(actions)}{attention}")
|
||||
for r in rows:
|
||||
ts = (r.get('ts') or '')[:16] # 'YYYY-MM-DD HH:MM'
|
||||
camera = r.get('camera_name') or ''
|
||||
desc = r.get('description') or ''
|
||||
try:
|
||||
persons = json.loads(r['person_list_json']) if isinstance(r['person_list_json'], str) else (r['person_list_json'] or [])
|
||||
except (ValueError, TypeError):
|
||||
persons = []
|
||||
persons = [str(p) for p in persons]
|
||||
attention = ' [关注事件]' if r.get('is_attention_event') else ''
|
||||
person_str = ','.join(persons) if persons else '无人'
|
||||
lines.append(f"[{ts} {camera}] {person_str}: {desc}{attention}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@@ -80,7 +68,6 @@ def _call_edge_qa(prompt: str) -> str:
|
||||
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
||||
|
||||
resp = requests.post(qa_url, json={"prompt": prompt}, timeout=timeout)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
answer = data.get('answer', '')
|
||||
@@ -108,38 +95,27 @@ def chat_ask():
|
||||
|
||||
logger.info(f"Chat: person={queried_person}, date={queried_date}, question={question}")
|
||||
|
||||
# 1. 查询 event_details
|
||||
details = db_layer.query_event_details(queried_person, queried_date)
|
||||
rows = db_layer.query_sync_events_for_person_date(queried_person, queried_date)
|
||||
|
||||
# 2. 拼接上下文
|
||||
if len(details) == 0:
|
||||
# 无数据
|
||||
if len(rows) == 0:
|
||||
answer = f"今天没有观察到{queried_person}。"
|
||||
context_summary = "查询 event_details 0 条"
|
||||
elif len(details) > 50:
|
||||
context = _aggregate_by_hour(details)
|
||||
context_summary = f"查询 event_details {len(details)} 条,按小时聚合为 {len(set(d['frame_timestamp'].strftime('%Y-%m-%d %H') for d in details))} 行"
|
||||
context_summary = "查询 sync_events 0 条"
|
||||
else:
|
||||
context = _format_details(details)
|
||||
context_summary = f"查询 event_details {len(details)} 条,时间范围 {details[0]['frame_timestamp']} - {details[-1]['frame_timestamp']}"
|
||||
|
||||
if len(details) > 0:
|
||||
# 构建完整 Prompt
|
||||
members = db_layer.get_known_members_context()
|
||||
context = _format_events(rows)
|
||||
context_summary = f"查询 sync_events {len(rows)} 条"
|
||||
members = db_layer.get_sync_known_members_context()
|
||||
prompt = CHAT_SYSTEM_PROMPT.format(
|
||||
context=context,
|
||||
members=members or f"{queried_person}",
|
||||
members=members or queried_person,
|
||||
question=question,
|
||||
person=queried_person
|
||||
)
|
||||
|
||||
try:
|
||||
answer = _call_edge_qa(prompt)
|
||||
except Exception as e:
|
||||
logger.error(f"问答编排调用失败: {e}")
|
||||
return jsonify({"error": f"AI 调用失败: {e}"}), 503
|
||||
|
||||
# 3. 写入 chat_history
|
||||
chat_id = db_layer.insert_chat_history(
|
||||
user_question=question,
|
||||
ai_answer=answer,
|
||||
@@ -163,7 +139,6 @@ def chat_history():
|
||||
limit = int(request.args.get('limit', 20))
|
||||
|
||||
history = db_layer.get_chat_history(limit=limit, date_filter=date, person_filter=person)
|
||||
# datetime 序列化
|
||||
for h in history:
|
||||
for k, v in h.items():
|
||||
if hasattr(v, 'isoformat'):
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
"""
|
||||
数据库访问层 - MariaDB 连接管理与 CRUD 操作
|
||||
使用 PyMySQL (纯 Python, ~45KB) 连接 MariaDB 服务器
|
||||
数据库访问层 - MariaDB 连接管理与同步镜像 CRUD
|
||||
|
||||
新架构 (2026-08-21 重构):
|
||||
NAS 不再处理视频,仅作为管理后台。
|
||||
Oracle (FAM-Edge) 处理整视频分析后存 SQLite,NAS 每 30 分钟拉增量,
|
||||
镜像到本地三张表:
|
||||
sync_videos : 视频会话(全局摘要 + 事件 JSON + 人物 JSON)
|
||||
sync_events : 视频拆出的时间点事件(描述 + 涉及人物 + 是否关注)
|
||||
sync_people : 规范人物表(label + canonical_name,Oracle 维护)
|
||||
sync_cursor : 同步游标(上次成功拉取到的 server_time)
|
||||
|
||||
本层只服务同步镜像 + 问答历史,旧 process_tasks/event_details/monitor_events/
|
||||
family_members 相关逻辑已全部移除(视频处理职责已迁移至 Oracle)。
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
@@ -41,283 +52,339 @@ def get_conn():
|
||||
|
||||
|
||||
# ============================================================
|
||||
# process_tasks 操作
|
||||
# 同步镜像:sync_videos
|
||||
# ============================================================
|
||||
|
||||
def create_task(video_path: str, video_url: str) -> int:
|
||||
"""创建新任务"""
|
||||
def upsert_sync_videos(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 videos 增量。rows 为 Oracle 端 dict 列表。"""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO process_tasks (video_path, video_url, status) VALUES (%s, %s, 'PENDING')",
|
||||
(video_path, video_url)
|
||||
)
|
||||
conn.commit()
|
||||
task_id = cursor.lastrowid
|
||||
logger.info(f"[task_id={task_id}] task created: {video_path}")
|
||||
return task_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_pending_tasks(limit=10) -> List[Dict]:
|
||||
"""获取待处理任务"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute(
|
||||
"SELECT * FROM process_tasks WHERE status = 'PENDING' ORDER BY created_at ASC LIMIT %s",
|
||||
(limit,)
|
||||
)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_tasks_by_status(status: str, limit=10) -> List[Dict]:
|
||||
"""按状态获取任务"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute(
|
||||
"SELECT * FROM process_tasks WHERE status = %s ORDER BY created_at ASC LIMIT %s",
|
||||
(status, limit)
|
||||
)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_task_status(task_id: int, status: str, error_message: str = None,
|
||||
failure_stage: str = None):
|
||||
"""更新任务状态"""
|
||||
valid_stages = {'download', 'extract', 'vlm_visual', 'vlm_fusion', 'callback', 'process'}
|
||||
if failure_stage and failure_stage not in valid_stages:
|
||||
failure_stage = 'callback'
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE process_tasks SET status=%s, error_message=%s, failure_stage=%s WHERE task_id=%s",
|
||||
(status, error_message, failure_stage, task_id)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def increment_retry(task_id: int, next_retry_at: datetime):
|
||||
"""递增重试次数"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE process_tasks SET retry_count=retry_count+1, next_retry_at=%s, status='PENDING' WHERE task_id=%s",
|
||||
(next_retry_at, task_id)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def reclaim_stale_processing(timeout_seconds: int) -> List[int]:
|
||||
"""回收僵尸 PROCESSING 任务:updated_at 早于 timeout_seconds 前的任务重置为 PENDING
|
||||
|
||||
场景:Dispatcher 推送过程中进程重启/Edge 重启导致 in-flight 请求丢失,
|
||||
任务停留在 PROCESSING 无人处理。重置后由常规重试机制接管。
|
||||
返回被回收的 task_id 列表。
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT task_id FROM process_tasks "
|
||||
"WHERE status='PROCESSING' AND updated_at < NOW() - INTERVAL %s SECOND",
|
||||
(timeout_seconds,)
|
||||
)
|
||||
task_ids = [row[0] for row in cursor.fetchall()]
|
||||
if task_ids:
|
||||
placeholders = ','.join(['%s'] * len(task_ids))
|
||||
cursor.execute(
|
||||
f"UPDATE process_tasks SET status='PENDING' WHERE task_id IN ({placeholders})",
|
||||
task_ids
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_videos
|
||||
(id, drive_file_id, filename, camera_name, duration_sec,
|
||||
event_start_time, status, summary_json, events_json,
|
||||
people_json, compute_provider, created_at, updated_at,
|
||||
processed_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
drive_file_id=VALUES(drive_file_id),
|
||||
filename=VALUES(filename),
|
||||
camera_name=VALUES(camera_name),
|
||||
duration_sec=VALUES(duration_sec),
|
||||
event_start_time=VALUES(event_start_time),
|
||||
status=VALUES(status),
|
||||
summary_json=VALUES(summary_json),
|
||||
events_json=VALUES(events_json),
|
||||
people_json=VALUES(people_json),
|
||||
compute_provider=VALUES(compute_provider),
|
||||
created_at=VALUES(created_at),
|
||||
updated_at=VALUES(updated_at),
|
||||
processed_at=VALUES(processed_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('drive_file_id'), r.get('filename'),
|
||||
r.get('camera_name'), r.get('duration_sec') or 0,
|
||||
r.get('event_start_time'), r.get('status'),
|
||||
r.get('summary_json'), r.get('events_json'), r.get('people_json'),
|
||||
r.get('compute_provider'), r.get('created_at'),
|
||||
r.get('updated_at'), r.get('processed_at'))
|
||||
)
|
||||
conn.commit()
|
||||
return task_ids
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_task(task_id: int) -> Optional[Dict]:
|
||||
"""获取单个任务"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute("SELECT * FROM process_tasks WHERE task_id = %s", (task_id,))
|
||||
return cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
def get_sync_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
|
||||
"""获取视频会话列表(已完成优先),支持日期筛选与分页。
|
||||
|
||||
|
||||
def get_video_url_exists(video_path: str) -> bool:
|
||||
"""检查视频是否已有对应任务(避免重复)"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM process_tasks WHERE video_path = %s",
|
||||
(video_path,)
|
||||
)
|
||||
return cursor.fetchone()[0] > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# monitor_events 操作
|
||||
# ============================================================
|
||||
|
||||
def _dt_or_none(value):
|
||||
"""datetime 字段归一化(MariaDB 严格模式兼容)
|
||||
|
||||
- 空串/None/'None'/'null' → NULL
|
||||
- ISO 8601(2026-08-20T01:06:44Z / 2026-08-20T01:06:44.123+00:00)→ '2026-08-20 01:06:44'
|
||||
- 已为标准格式则原样返回
|
||||
排序按 COALESCE(processed_at, updated_at, created_at) 降序。
|
||||
date_filter 形如 '2026-08-21',匹配 processed_at 前缀。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if s in ('', 'None', 'null', 'NaN'):
|
||||
return None
|
||||
# 归一化 ISO 8601 -> 'YYYY-MM-DD HH:MM:SS'
|
||||
s2 = s.replace('T', ' ').replace('Z', '').replace('z', '')
|
||||
if '+' in s2[10:]:
|
||||
s2 = s2[:s2.index('+')]
|
||||
if '.' in s2:
|
||||
s2 = s2[:s2.index('.')]
|
||||
try:
|
||||
dt = datetime.strptime(s2, '%Y-%m-%d %H:%M:%S')
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def insert_event(task_id: int, event_start_time: str, event_end_time: str,
|
||||
camera_name: str, global_summary: str, entities_json: list,
|
||||
compute_provider: list) -> int:
|
||||
"""插入事件聚合记录"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
# event_start/end_time 为 NOT NULL 列: 空值兜底
|
||||
# end 缺失 → 用 start;start 也缺失 → 用当前时间
|
||||
dt_start = _dt_or_none(event_start_time)
|
||||
dt_end = _dt_or_none(event_end_time)
|
||||
if not dt_end:
|
||||
dt_end = dt_start
|
||||
if not dt_start:
|
||||
from datetime import datetime as _dt
|
||||
dt_start = dt_end = _dt.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute(
|
||||
"""INSERT INTO monitor_events
|
||||
(task_id, event_start_time, event_end_time, camera_name,
|
||||
global_summary, entities_json, compute_provider)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
||||
(task_id, dt_start, dt_end, camera_name,
|
||||
global_summary, json.dumps(entities_json, ensure_ascii=False),
|
||||
json.dumps(compute_provider, ensure_ascii=False))
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# event_details 操作
|
||||
# ============================================================
|
||||
|
||||
def insert_event_detail(event_id: int, task_id: int, frame_index: int,
|
||||
frame_timestamp: str, camera_name: str,
|
||||
person: str, action: str, clothing: str,
|
||||
is_attention_event: bool, source_providers: list):
|
||||
"""插入事件明细"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
# frame_timestamp 为 NOT NULL 列: 空值兜底为当前时间
|
||||
dt_ts = _dt_or_none(frame_timestamp)
|
||||
if not dt_ts:
|
||||
from datetime import datetime as _dt
|
||||
dt_ts = _dt.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute(
|
||||
"""INSERT INTO event_details
|
||||
(event_id, task_id, frame_index, frame_timestamp, camera_name,
|
||||
person, action, clothing, is_attention_event, source_providers)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(event_id, task_id, frame_index, dt_ts, camera_name,
|
||||
person, action, clothing, is_attention_event,
|
||||
json.dumps(source_providers, ensure_ascii=False))
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_event_details(person: str, queried_date: str) -> List[Dict]:
|
||||
"""查询某人在某天的事件明细"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute(
|
||||
"""SELECT frame_timestamp, camera_name, person, action, clothing,
|
||||
is_attention_event
|
||||
FROM event_details
|
||||
WHERE person = %s AND DATE(frame_timestamp) = %s
|
||||
ORDER BY frame_timestamp ASC""",
|
||||
(person, queried_date)
|
||||
)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recent_events(limit=20, offset=0, date_filter=None) -> List[Dict]:
|
||||
"""获取事件列表(分页 + 日期筛选)"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
if date_filter:
|
||||
cursor.execute(
|
||||
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
|
||||
me.camera_name, me.global_summary, me.compute_provider,
|
||||
me.created_at,
|
||||
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
|
||||
FROM monitor_events me
|
||||
WHERE DATE(me.event_start_time) = %s
|
||||
ORDER BY me.event_start_time DESC
|
||||
cur.execute(
|
||||
"""SELECT id, filename, camera_name, event_start_time, status,
|
||||
summary_json, events_json, people_json, compute_provider,
|
||||
processed_at, updated_at,
|
||||
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = sync_videos.id) AS event_count
|
||||
FROM sync_videos
|
||||
WHERE status='done' AND processed_at LIKE %s
|
||||
ORDER BY COALESCE(processed_at, updated_at, created_at) DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
(date_filter, limit, offset)
|
||||
)
|
||||
(f'{date_filter}%', limit, offset))
|
||||
else:
|
||||
cursor.execute(
|
||||
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
|
||||
me.camera_name, me.global_summary, me.compute_provider,
|
||||
me.created_at,
|
||||
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
|
||||
FROM monitor_events me
|
||||
ORDER BY me.event_start_time DESC
|
||||
cur.execute(
|
||||
"""SELECT id, filename, camera_name, event_start_time, status,
|
||||
summary_json, events_json, people_json, compute_provider,
|
||||
processed_at, updated_at,
|
||||
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = sync_videos.id) AS event_count
|
||||
FROM sync_videos
|
||||
WHERE status='done'
|
||||
ORDER BY COALESCE(processed_at, updated_at, created_at) DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
(limit, offset)
|
||||
)
|
||||
return cursor.fetchall()
|
||||
(limit, offset))
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_video(video_id: int) -> Optional[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute("SELECT * FROM sync_videos WHERE id=%s", (video_id,))
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# chat_history 操作
|
||||
# 同步镜像:sync_events
|
||||
# ============================================================
|
||||
|
||||
def upsert_sync_events(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 events 增量。"""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_events
|
||||
(id, video_id, ts, description, person_list_json,
|
||||
is_attention_event, updated_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
video_id=VALUES(video_id),
|
||||
ts=VALUES(ts),
|
||||
description=VALUES(description),
|
||||
person_list_json=VALUES(person_list_json),
|
||||
is_attention_event=VALUES(is_attention_event),
|
||||
updated_at=VALUES(updated_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('video_id'), r.get('ts'), r.get('description'),
|
||||
r.get('person_list_json'), 1 if r.get('is_attention_event') else 0,
|
||||
r.get('updated_at'))
|
||||
)
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_events_for_video(video_id: int) -> List[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"""SELECT id, video_id, ts, description, person_list_json,
|
||||
is_attention_event, updated_at
|
||||
FROM sync_events WHERE video_id=%s ORDER BY ts ASC""",
|
||||
(video_id,))
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_sync_events_for_person_date(person: str, date_str: str) -> List[Dict]:
|
||||
"""问答上下文:某人在某天的事件。
|
||||
|
||||
说明: Oracle 事件 ts 为视频内相对时间点(如 00:01:23),不是绝对日期,
|
||||
因此按所属视频的 processed_at 日期过滤,再按 person_list_json 命中人名。
|
||||
person 可为真名或抽象标签(Oracle 回灌上下文用真名,但历史标签也保留)。
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
|
||||
v.camera_name, v.filename, v.event_start_time, v.processed_at
|
||||
FROM sync_events e
|
||||
JOIN sync_videos v ON e.video_id = v.id
|
||||
WHERE v.processed_at LIKE %s AND e.person_list_json LIKE %s
|
||||
ORDER BY v.processed_at ASC, e.ts ASC""",
|
||||
(f'{date_str}%', f'%{person}%'))
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步镜像:sync_people
|
||||
# ============================================================
|
||||
|
||||
def upsert_sync_people(rows: List[Dict]) -> int:
|
||||
"""批量 upsert Oracle 传来的 people 增量。"""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_people
|
||||
(id, label, canonical_name, first_seen, appearances,
|
||||
source, updated_at, synced_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
label=VALUES(label),
|
||||
canonical_name=VALUES(canonical_name),
|
||||
first_seen=VALUES(first_seen),
|
||||
appearances=VALUES(appearances),
|
||||
source=VALUES(source),
|
||||
updated_at=VALUES(updated_at),
|
||||
synced_at=NOW()""",
|
||||
(r.get('id'), r.get('label'), r.get('canonical_name'),
|
||||
r.get('first_seen'), r.get('appearances') or 0,
|
||||
r.get('source'), r.get('updated_at')))
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_people() -> List[Dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"SELECT id, label, canonical_name, first_seen, appearances, source, updated_at "
|
||||
"FROM sync_people ORDER BY id ASC")
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_named_members() -> List[str]:
|
||||
"""已命名成员的真名列表(供 UI 下拉 / 快捷选择)。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"SELECT DISTINCT canonical_name FROM sync_people "
|
||||
"WHERE canonical_name IS NOT NULL AND canonical_name != '' "
|
||||
"ORDER BY canonical_name ASC")
|
||||
return [r['canonical_name'] for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_sync_known_members_context() -> str:
|
||||
"""获取人物清单文本,注入问答 Prompt,让模型用真名指代。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute(
|
||||
"SELECT label, canonical_name FROM sync_people ORDER BY id ASC")
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
return ""
|
||||
parts = []
|
||||
for r in rows:
|
||||
name = r['canonical_name'] or r['label']
|
||||
if r['canonical_name'] and r['canonical_name'] != r['label']:
|
||||
parts.append(f"- {name}(标识:{r['label']})")
|
||||
else:
|
||||
parts.append(f"- {name}")
|
||||
return "\n".join(parts)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步游标
|
||||
# ============================================================
|
||||
|
||||
def get_sync_cursor() -> str:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM sync_cursor WHERE key='last_since'")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else ''
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_sync_cursor(value: str):
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO sync_cursor (key, value) VALUES ('last_since', %s)
|
||||
ON DUPLICATE KEY UPDATE value=VALUES(value)""",
|
||||
(value,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 统计
|
||||
# ============================================================
|
||||
|
||||
def get_sync_stats(date_str: str = None) -> Dict:
|
||||
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
|
||||
|
||||
人物数通过对 sync_events.person_list_json LIKE 统计(MariaDB 不支持 JSON 数组展开)。
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
# 视频/事件/关注数
|
||||
if date_str:
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS videos,
|
||||
(SELECT COUNT(*) FROM sync_events se
|
||||
JOIN sync_videos sv ON se.video_id=sv.id
|
||||
WHERE sv.processed_at LIKE %s) AS events,
|
||||
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM sync_events se
|
||||
JOIN sync_videos sv ON se.video_id=sv.id
|
||||
WHERE sv.processed_at LIKE %s) AS attention
|
||||
FROM sync_videos sv WHERE sv.processed_at LIKE %s""",
|
||||
(f'{date_str}%', f'{date_str}%', f'{date_str}%'))
|
||||
else:
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
(SELECT COUNT(*) FROM sync_videos WHERE status='done') AS videos,
|
||||
(SELECT COUNT(*) FROM sync_events) AS events,
|
||||
(SELECT COALESCE(SUM(is_attention_event),0) FROM sync_events) AS attention""")
|
||||
stat = cur.fetchone() or {}
|
||||
|
||||
# 人物数(distinct label 命中 sync_events)
|
||||
cur.execute("SELECT id, label, canonical_name FROM sync_people")
|
||||
people = cur.fetchall()
|
||||
# 基于 person_list_json 命中计数:逐 label 统计命中事件数
|
||||
person_hits = 0
|
||||
for p in people:
|
||||
name = p['canonical_name'] or p['label']
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) c FROM sync_events WHERE person_list_json LIKE %s",
|
||||
(f'%{name}%',))
|
||||
if cur.fetchone()['c'] > 0:
|
||||
person_hits += 1
|
||||
stat['people'] = person_hits
|
||||
return stat
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# chat_history(保留:问答历史)
|
||||
# ============================================================
|
||||
|
||||
def insert_chat_history(user_question: str, ai_answer: str,
|
||||
@@ -326,15 +393,15 @@ def insert_chat_history(user_question: str, ai_answer: str,
|
||||
"""插入对话记录"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO chat_history
|
||||
(user_question, ai_answer, context_summary, queried_date, queried_person)
|
||||
VALUES (%s, %s, %s, %s, %s)""",
|
||||
(user_question, ai_answer, context_summary, queried_date, queried_person)
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -343,7 +410,7 @@ def get_chat_history(limit=20, date_filter=None, person_filter=None) -> List[Dic
|
||||
"""获取对话历史"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
conditions = []
|
||||
params = []
|
||||
if date_filter:
|
||||
@@ -353,327 +420,11 @@ def get_chat_history(limit=20, date_filter=None, person_filter=None) -> List[Dic
|
||||
conditions.append("queried_person = %s")
|
||||
params.append(person_filter)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.extend([limit])
|
||||
cursor.execute(
|
||||
params.append(limit)
|
||||
cur.execute(
|
||||
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s",
|
||||
params
|
||||
)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# family_members 操作
|
||||
# ============================================================
|
||||
|
||||
def upsert_family_member(abstract_label: str, feature_description: str,
|
||||
first_seen_at: str):
|
||||
"""upsert 家庭成员(abstract_label 唯一)"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO family_members (abstract_label, feature_description, first_seen_at)
|
||||
VALUES (%s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE abstract_label = abstract_label""",
|
||||
(abstract_label, feature_description, first_seen_at)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_unnamed_members() -> List[Dict]:
|
||||
"""获取未命名成员列表"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute(
|
||||
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at,
|
||||
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count
|
||||
FROM family_members fm
|
||||
WHERE fm.real_name IS NULL AND fm.is_active = TRUE
|
||||
ORDER BY fm.first_seen_at ASC"""
|
||||
)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_members(include_named=True, include_unnamed=True) -> List[Dict]:
|
||||
"""获取所有成员"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
conditions = []
|
||||
if include_named and include_unnamed:
|
||||
pass # 全部
|
||||
elif include_named:
|
||||
conditions.append("real_name IS NOT NULL")
|
||||
elif include_unnamed:
|
||||
conditions.append("real_name IS NULL")
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
cursor.execute(
|
||||
f"""SELECT * FROM family_members {where}
|
||||
ORDER BY first_seen_at ASC"""
|
||||
)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict:
|
||||
"""命名/重命名成员 + 批量回溯更新历史记录
|
||||
|
||||
- 标签未入库(如 AI 新输出的 人物1)自动注册
|
||||
- 已命名成员可重命名(旧真名一并回溯替换)
|
||||
- person 为多人组合字符串('张三, 汤圆'),用 REPLACE 替换其中目标
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. 查现有记录,拿到旧真名
|
||||
cursor.execute(
|
||||
"SELECT member_id, real_name FROM family_members WHERE abstract_label = %s",
|
||||
(abstract_label,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
old_name = None
|
||||
if row:
|
||||
old_name = row[1]
|
||||
else:
|
||||
cursor.execute(
|
||||
"""INSERT INTO family_members (abstract_label, feature_description, first_seen_at)
|
||||
VALUES (%s, %s, NOW())""",
|
||||
(abstract_label, f'由命名操作自动注册: {real_name}')
|
||||
)
|
||||
|
||||
# 2. 更新 family_members
|
||||
cursor.execute(
|
||||
"""UPDATE family_members
|
||||
SET real_name = %s, named_at = NOW(), named_by = %s
|
||||
WHERE abstract_label = %s""",
|
||||
(real_name, named_by, abstract_label)
|
||||
)
|
||||
|
||||
# 3. 批量回溯更新 event_details
|
||||
# 目标出现的三种形态: 独占整字段 / 多人组合内 / AI 直呼旧真名
|
||||
updated_details_count = 0
|
||||
for old in {abstract_label, old_name} - {None}:
|
||||
if old == real_name:
|
||||
continue
|
||||
cursor.execute(
|
||||
"UPDATE event_details SET person = %s WHERE person = %s",
|
||||
(real_name, old)
|
||||
)
|
||||
updated_details_count += cursor.rowcount
|
||||
cursor.execute(
|
||||
"""UPDATE event_details
|
||||
SET person = REPLACE(person, %s, %s)
|
||||
WHERE person LIKE %s AND person <> %s""",
|
||||
(old, real_name, f'%{old}%', real_name)
|
||||
)
|
||||
updated_details_count += cursor.rowcount
|
||||
|
||||
# 4. 批量回溯更新 monitor_events.entities_json
|
||||
# MariaDB 10.11 不支持 MySQL 的 $[*] 通配符 JSON 路径,
|
||||
# 改用 Python 层解析 + 逐行更新
|
||||
import json as _json
|
||||
updated_events_count = 0
|
||||
targets = {abstract_label, old_name} - {None}
|
||||
if targets and targets != {real_name}:
|
||||
like_conds = ' OR '.join(['entities_json LIKE %s'] * len(targets))
|
||||
like_args = [f'%{t}%' for t in targets]
|
||||
cursor.execute(
|
||||
f"SELECT event_id, entities_json FROM monitor_events WHERE {like_conds}",
|
||||
tuple(like_args)
|
||||
)
|
||||
for eid, entities_raw in cursor.fetchall():
|
||||
if not entities_raw:
|
||||
continue
|
||||
try:
|
||||
entities = _json.loads(entities_raw) if isinstance(entities_raw, str) else entities_raw
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
changed = False
|
||||
if isinstance(entities, list):
|
||||
for ent in entities:
|
||||
if isinstance(ent, dict) and ent.get('person') in targets:
|
||||
ent['person'] = real_name
|
||||
changed = True
|
||||
if changed:
|
||||
cursor.execute(
|
||||
"UPDATE monitor_events SET entities_json = %s WHERE event_id = %s",
|
||||
(_json.dumps(entities, ensure_ascii=False), eid)
|
||||
)
|
||||
updated_events_count += 1
|
||||
|
||||
conn.commit()
|
||||
return {
|
||||
"abstract_label": abstract_label,
|
||||
"real_name": real_name,
|
||||
"renamed_from": old_name,
|
||||
"updated_event_details_count": updated_details_count,
|
||||
"updated_monitor_events_count": updated_events_count
|
||||
}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def merge_member(source_key: str, target_key: str, named_by: str = '管理员') -> Dict:
|
||||
"""合并人物: source 并入 target(用户判断两帧是同一人时)
|
||||
|
||||
source_key/target_key 可为 abstract_label 或 real_name。
|
||||
- event_details.person: 独占/组合字符串内的 source 一律替换为 target 显示名
|
||||
- monitor_events.entities_json: person 字段替换
|
||||
- family_members: source 行置 is_active=0(保留历史),target 未入库则注册
|
||||
"""
|
||||
if source_key == target_key:
|
||||
return {"error": "source 与 target 不能相同"}
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
|
||||
def resolve(key):
|
||||
cursor.execute(
|
||||
"""SELECT member_id, abstract_label, real_name FROM family_members
|
||||
WHERE is_active = TRUE AND (abstract_label = %s OR real_name = %s)
|
||||
ORDER BY real_name IS NULL LIMIT 1""",
|
||||
(key, key))
|
||||
return cursor.fetchone()
|
||||
|
||||
src = resolve(source_key)
|
||||
tgt = resolve(target_key)
|
||||
if not src:
|
||||
return {"error": f"人物 {source_key} 不存在"}
|
||||
if not tgt:
|
||||
# target 是未入库的裸标签(如 人物1),注册后作为目标
|
||||
cursor.execute(
|
||||
"""INSERT INTO family_members (abstract_label, feature_description, first_seen_at)
|
||||
VALUES (%s, %s, NOW())""",
|
||||
(target_key, f'合并操作自动注册: {source_key} 并入'))
|
||||
tgt = {'abstract_label': target_key, 'real_name': None}
|
||||
|
||||
target_display = tgt['real_name'] or tgt['abstract_label']
|
||||
# source 的所有称呼: 抽象标签 + 旧真名(多人组合里两种都可能出现)
|
||||
source_names = {src['abstract_label']}
|
||||
if src['real_name']:
|
||||
source_names.add(src['real_name'])
|
||||
|
||||
updated_details = 0
|
||||
for name in source_names:
|
||||
if name == target_display:
|
||||
continue
|
||||
cursor.execute(
|
||||
"UPDATE event_details SET person = %s WHERE person = %s",
|
||||
(target_display, name))
|
||||
updated_details += cursor.rowcount
|
||||
cursor.execute(
|
||||
"""UPDATE event_details
|
||||
SET person = REPLACE(person, %s, %s)
|
||||
WHERE person LIKE %s AND person <> %s""",
|
||||
(name, target_display, f'%{name}%', target_display))
|
||||
updated_details += cursor.rowcount
|
||||
|
||||
# entities_json 逐行替换
|
||||
import json as _json
|
||||
updated_events = 0
|
||||
like_conds = ' OR '.join(['entities_json LIKE %s'] * len(source_names))
|
||||
cursor.execute(
|
||||
f"SELECT event_id, entities_json FROM monitor_events WHERE {like_conds}",
|
||||
tuple(f'%{n}%' for n in source_names))
|
||||
for row in cursor.fetchall():
|
||||
raw = row['entities_json']
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
entities = _json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
changed = False
|
||||
if isinstance(entities, list):
|
||||
for ent in entities:
|
||||
if isinstance(ent, dict) and ent.get('person') in source_names:
|
||||
ent['person'] = target_display
|
||||
changed = True
|
||||
if changed:
|
||||
cursor.execute(
|
||||
"UPDATE monitor_events SET entities_json = %s WHERE event_id = %s",
|
||||
(_json.dumps(entities, ensure_ascii=False), row['event_id']))
|
||||
updated_events += 1
|
||||
|
||||
# source 行停用
|
||||
cursor.execute(
|
||||
"UPDATE family_members SET is_active = 0, updated_at = NOW() WHERE member_id = %s",
|
||||
(src['member_id'],))
|
||||
|
||||
conn.commit()
|
||||
return {
|
||||
"source": source_key,
|
||||
"target": target_display,
|
||||
"merged_names": sorted(source_names),
|
||||
"updated_event_details_count": updated_details,
|
||||
"updated_monitor_events_count": updated_events
|
||||
}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_known_members_context() -> str:
|
||||
"""获取已命名+未命名成员清单,用于注入 VLM Prompt"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute(
|
||||
"SELECT abstract_label, real_name, feature_description FROM family_members WHERE is_active = TRUE"
|
||||
)
|
||||
members = cursor.fetchall()
|
||||
if not members:
|
||||
return ""
|
||||
parts = []
|
||||
for m in members:
|
||||
name = m['real_name'] if m['real_name'] else m['abstract_label']
|
||||
feat = m['feature_description'] or ''
|
||||
status = f"(real_name={m['real_name']})" if m['real_name'] else f"(abstract_label={m['abstract_label']}, 未命名)"
|
||||
parts.append(f"{name}: {feat} {status}")
|
||||
return "; ".join(parts)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# compute_provider 统计
|
||||
# ============================================================
|
||||
|
||||
def get_compute_provider_stats() -> List[Dict]:
|
||||
"""获取 compute_provider 分布统计"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cursor = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cursor.execute(
|
||||
"""SELECT
|
||||
JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider,
|
||||
COUNT(*) AS count
|
||||
FROM monitor_events,
|
||||
JSON_TABLE(compute_provider, '$[*]'
|
||||
COLUMNS(item VARCHAR(50) PATH '$'
|
||||
)) AS jt
|
||||
GROUP BY provider
|
||||
ORDER BY count DESC"""
|
||||
)
|
||||
return cursor.fetchall()
|
||||
except Exception:
|
||||
# MariaDB 旧版不支持 JSON_TABLE,降级方案
|
||||
cursor.execute("SELECT compute_provider, COUNT(*) AS count FROM monitor_events GROUP BY compute_provider")
|
||||
return cursor.fetchall()
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Dispatcher 包"""
|
||||
from .dispatcher import Dispatcher
|
||||
|
||||
__all__ = ["Dispatcher"]
|
||||
@@ -1,517 +0,0 @@
|
||||
"""
|
||||
Dispatcher - 30s 轮询 PENDING 任务,上传视频至 Edge 异步队列
|
||||
|
||||
流程(异步队列模式 + NAS 预压缩 + 分块断点续传):
|
||||
1. 读取任务对应的本地视频文件
|
||||
2. 大文件 (>20MB): NAS 端 FFmpeg 预压缩 (480p/CRF28, ~36x 压缩比)
|
||||
(根因: NAS→Oracle 跨境上行带宽仅 ~0.5-0.9MB/s,360MB 原始上传需 10-20min 且频繁超时)
|
||||
3a. 压缩后小文件 (<=20MB): 直接 multipart 上传至 /enqueue
|
||||
3b. 仍超阈值: 分块上传 (5MB/块) 至 /chunk,支持断点续传,最后调 /assemble 合并入队
|
||||
4. Edge 保存视频 + 入 SQLite 队列,返回 202
|
||||
5. Dispatcher 标记任务为 PROCESSING(已派发,等待 Poller 拉取结果)
|
||||
6. Poller 线程定期从 Edge /api/edge/results 拉取结果,写库后标记 SUCCESS
|
||||
|
||||
退避重试: min(30 * (retry_count + 1), 300) 秒
|
||||
分块级重试: 每块最多重试 3 次
|
||||
看门狗: 线程崩溃后自动重启
|
||||
"""
|
||||
import os
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..logger import setup_logger, log_task
|
||||
from ..config_loader import load_config
|
||||
from .. import db_layer
|
||||
|
||||
logger = setup_logger('fam-core.dispatcher')
|
||||
|
||||
CHUNK_SIZE = 5 * 1024 * 1024 # 5MB per chunk (reliable at ~1Mbps upload)
|
||||
CHUNK_THRESHOLD = 20 * 1024 * 1024 # files > 20MB use chunked upload
|
||||
COMPRESS_THRESHOLD = 20 * 1024 * 1024 # files > 20MB get pre-compressed before upload
|
||||
COMPRESS_DIR = '/tmp/fam_compressed'
|
||||
COMPRESS_CACHE_TTL = 24 * 3600 # 压缩缓存保留 24h(供上传失败重试复用)
|
||||
MAX_CHUNK_RETRIES = 3
|
||||
|
||||
# Synology 系统 ffmpeg 被裁剪(无 h264 编解码),CodecPack 的 ffmpeg41 带 libx264
|
||||
FFMPEG_CANDIDATES = [
|
||||
'/var/packages/CodecPack/target/bin/ffmpeg41',
|
||||
'/usr/local/bin/ffmpeg',
|
||||
]
|
||||
|
||||
|
||||
def _safe_remove(path):
|
||||
"""忽略不存在/清理失败的删除"""
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
"""任务下发器,30s 轮询(异步队列 + 分块断点续传)"""
|
||||
|
||||
def __init__(self):
|
||||
cfg = load_config()
|
||||
self.poll_interval = cfg.get('dispatcher', {}).get('poll_interval', 30)
|
||||
self.edge_url = cfg.get('dispatcher', {}).get('edge_url',
|
||||
'http://localhost:5000/api/edge/video/enqueue')
|
||||
self.max_retries = cfg.get('dispatcher', {}).get('max_retries', 3)
|
||||
self.camera_name = cfg.get('scheduler', {}).get('camera_name', '默认摄像头')
|
||||
self.stale_timeout = cfg.get('dispatcher', {}).get('stale_timeout', 600)
|
||||
self.compress_timeout = cfg.get('dispatcher', {}).get('compress_timeout', 3600)
|
||||
self.ffmpeg = self._find_ffmpeg(cfg)
|
||||
# 推导 Edge base URL
|
||||
self.edge_base = self.edge_url.rsplit('/api/edge/video/enqueue', 1)[0]
|
||||
self.chunk_url = f"{self.edge_base}/api/edge/video/chunk"
|
||||
self.chunks_query_url = f"{self.edge_base}/api/edge/video/chunks"
|
||||
self.assemble_url = f"{self.edge_base}/api/edge/video/assemble"
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
@staticmethod
|
||||
def _find_ffmpeg(cfg):
|
||||
"""查找可用 ffmpeg:配置优先,其次 CodecPack(带 libx264),最后 PATH"""
|
||||
configured = cfg.get('dispatcher', {}).get('ffmpeg_path')
|
||||
candidates = ([configured] if configured else []) + FFMPEG_CANDIDATES
|
||||
for path in candidates:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
def _calculate_backoff(self, retry_count):
|
||||
"""退避策略: min(30 * (retry_count + 1), 300)"""
|
||||
return min(30 * (retry_count + 1), 300)
|
||||
|
||||
def _should_retry(self, task):
|
||||
"""检查任务是否可以重试"""
|
||||
if task['retry_count'] >= task['max_retries']:
|
||||
return False
|
||||
if task['next_retry_at']:
|
||||
now = datetime.now()
|
||||
if now < task['next_retry_at']:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _probe_duration(self, video_path):
|
||||
"""用 ffmpeg 解析视频时长(NAS 无独立 ffprobe),失败返回 0"""
|
||||
if not self.ffmpeg:
|
||||
return 0
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[self.ffmpeg, '-i', video_path],
|
||||
capture_output=True, timeout=30)
|
||||
m = re.search(r'Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)',
|
||||
r.stderr.decode('utf-8', 'ignore'))
|
||||
if m:
|
||||
return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _build_payload(self, task):
|
||||
"""构建推送元数据,注入已知成员清单与事件时间"""
|
||||
video_path = task['video_path']
|
||||
payload = {
|
||||
"task_id": str(task['task_id']),
|
||||
"camera_name": self.camera_name,
|
||||
"known_members_context": db_layer.get_known_members_context(),
|
||||
"event_start_time": "",
|
||||
"event_end_time": "",
|
||||
}
|
||||
try:
|
||||
mtime = os.path.getmtime(video_path)
|
||||
# mtime 是录制结束时刻,开始时间 = 结束时间 - 视频时长
|
||||
duration = self._probe_duration(video_path)
|
||||
start_dt = datetime.fromtimestamp(mtime - duration)
|
||||
payload["event_start_time"] = start_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except OSError:
|
||||
pass
|
||||
return payload
|
||||
|
||||
def _compress_video(self, task_id, video_path):
|
||||
"""NAS 端预压缩: 480p/CRF28/veryfast,静态监控场景实测 ~36x 压缩比
|
||||
|
||||
成功返回压缩文件路径;失败返回 None(回退原始文件分块上传)。
|
||||
压缩产物缓存在 /tmp/fam_compressed/task_{id}/,重试时源文件未变则复用。
|
||||
"""
|
||||
if not self.ffmpeg:
|
||||
logger.warning(f"[task_id={task_id}] ffmpeg 不可用,跳过预压缩")
|
||||
return None
|
||||
|
||||
self._cleanup_compress_cache()
|
||||
|
||||
out_dir = os.path.join(COMPRESS_DIR, f'task_{task_id}')
|
||||
out_path = os.path.join(out_dir, os.path.basename(video_path))
|
||||
|
||||
# 缓存复用:源文件未变且压缩产物有效
|
||||
try:
|
||||
if (os.path.isfile(out_path)
|
||||
and os.path.getsize(out_path) > 0
|
||||
and os.path.getmtime(out_path) >= os.path.getmtime(video_path)):
|
||||
logger.info(f"[task_id={task_id}] 复用压缩缓存: {out_path}")
|
||||
return out_path
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
src_size = os.path.getsize(video_path)
|
||||
start = time.time()
|
||||
|
||||
# 写临时文件(带 PID 防多进程冲突),成功后原子 rename —
|
||||
# 服务被 kill 时 ffmpeg 成为孤儿继续写 tmp,缓存目录中
|
||||
# 只会出现完整产物,杜绝半成品被复用(曾导致上传损坏视频)
|
||||
tmp_path = f"{out_path}.{os.getpid()}.tmp"
|
||||
|
||||
cmd = [
|
||||
self.ffmpeg, '-y',
|
||||
'-i', video_path,
|
||||
# 第二级 scale 向下取偶(h264 要求偶数尺寸;force_divisible_by 需 ffmpeg>=4.3)
|
||||
'-vf', ('scale=854:480:force_original_aspect_ratio=decrease,'
|
||||
'scale=trunc(iw/2)*2:trunc(ih/2)*2'),
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '28',
|
||||
'-an',
|
||||
'-f', 'mp4', # .tmp 扩展名无法推断 muxer,必须显式指定
|
||||
tmp_path,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
timeout=self.compress_timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_safe_remove(tmp_path)
|
||||
logger.error(f"[task_id={task_id}] 压缩超时 ({self.compress_timeout}s),回退原始上传")
|
||||
return None
|
||||
except OSError as e:
|
||||
logger.error(f"[task_id={task_id}] 启动 ffmpeg 失败: {e}")
|
||||
return None
|
||||
|
||||
if result.returncode != 0 or not os.path.isfile(tmp_path) or os.path.getsize(tmp_path) == 0:
|
||||
_safe_remove(tmp_path)
|
||||
stderr_tail = (result.stderr or '')[-300:]
|
||||
logger.error(f"[task_id={task_id}] 压缩失败 (rc={result.returncode}): {stderr_tail}")
|
||||
return None
|
||||
|
||||
os.replace(tmp_path, out_path)
|
||||
|
||||
dst_size = os.path.getsize(out_path)
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"[task_id={task_id}] 预压缩完成: {src_size/1048576:.1f}MB → "
|
||||
f"{dst_size/1048576:.1f}MB ({src_size/max(dst_size,1):.1f}x),耗时 {elapsed:.0f}s")
|
||||
return out_path
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_compress_cache():
|
||||
"""清理超过 TTL 的压缩缓存目录 + 孤儿 .tmp 残留(tmpfs 空间有限)"""
|
||||
try:
|
||||
if not os.path.isdir(COMPRESS_DIR):
|
||||
return
|
||||
cutoff = time.time() - COMPRESS_CACHE_TTL
|
||||
for entry in os.listdir(COMPRESS_DIR):
|
||||
path = os.path.join(COMPRESS_DIR, entry)
|
||||
try:
|
||||
if os.path.isdir(path):
|
||||
if os.path.getmtime(path) < cutoff:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
continue
|
||||
# 清理超过 1h 的 .tmp 残留(孤儿 ffmpeg 产物)
|
||||
for fn in os.listdir(path):
|
||||
if fn.endswith('.tmp') and \
|
||||
os.path.getmtime(os.path.join(path, fn)) < time.time() - 3600:
|
||||
_safe_remove(os.path.join(path, fn))
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _dispatch_one(self, task):
|
||||
"""上传视频至 Edge 异步队列(大文件先预压缩,自动选择直接/分块模式)"""
|
||||
task_id = task['task_id']
|
||||
video_path = task['video_path']
|
||||
|
||||
if not video_path or not os.path.isfile(video_path):
|
||||
db_layer.update_task_status(
|
||||
task_id, 'FAILED',
|
||||
error_message=f"视频文件不存在: {video_path}",
|
||||
failure_stage='callback'
|
||||
)
|
||||
logger.error(f"[task_id={task_id}] 视频文件不存在,标记 FAILED: {video_path}")
|
||||
return
|
||||
|
||||
file_size = os.path.getsize(video_path)
|
||||
size_mb = file_size / (1024 * 1024)
|
||||
db_layer.update_task_status(task_id, 'PROCESSING')
|
||||
|
||||
# 跨境上行带宽受限(实测 ~0.5-0.9MB/s),大文件先预压缩再上传
|
||||
upload_path = video_path
|
||||
if file_size > COMPRESS_THRESHOLD:
|
||||
compressed = self._compress_video(task_id, video_path)
|
||||
if compressed:
|
||||
upload_path = compressed
|
||||
file_size = os.path.getsize(upload_path)
|
||||
size_mb = file_size / (1024 * 1024)
|
||||
# 压缩耗时较长,重置 stale 计时基准(reclaim 按 updated_at 判断)
|
||||
db_layer.update_task_status(task_id, 'PROCESSING')
|
||||
else:
|
||||
logger.warning(f"[task_id={task_id}] 压缩失败,回退原始文件上传 ({size_mb:.1f}MB)")
|
||||
|
||||
payload = self._build_payload(task)
|
||||
|
||||
if file_size > CHUNK_THRESHOLD:
|
||||
logger.info(f"[task_id={task_id}] 大文件分块上传: {size_mb:.1f}MB, "
|
||||
f"{math.ceil(file_size / CHUNK_SIZE)} 块")
|
||||
self._dispatch_chunked(task, payload, upload_path, file_size)
|
||||
else:
|
||||
log_task(logger, task_id, 'dispatcher',
|
||||
f'直接上传: {self.edge_url} ({size_mb:.1f}MB)')
|
||||
self._dispatch_direct(task, payload, upload_path)
|
||||
|
||||
def _dispatch_direct(self, task, payload, video_path):
|
||||
"""小文件直接上传至 /enqueue
|
||||
|
||||
注意 timeout 第一参数: urllib3 发送 multipart body 期间 socket
|
||||
timeout 取的是 connect timeout 值(实测传 60s 则 60s 整超时),
|
||||
而非 read timeout——跨境 1.4MB/s 下 17MB 需 ~12s,必须给足。
|
||||
"""
|
||||
task_id = task['task_id']
|
||||
try:
|
||||
with open(video_path, 'rb') as fh:
|
||||
resp = requests.post(
|
||||
self.edge_url,
|
||||
data=payload,
|
||||
files={'video': (os.path.basename(video_path), fh, 'video/mp4')},
|
||||
timeout=(120, 300)
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[task_id={task_id}] 上传失败: {e}")
|
||||
self._schedule_retry(task)
|
||||
return
|
||||
|
||||
if resp.status_code == 202:
|
||||
try:
|
||||
data = resp.json()
|
||||
queue_id = data.get('queue_id', '?')
|
||||
logger.info(f"[task_id={task_id}] 已入 Edge 队列 (queue_id={queue_id}),等待 Poller 拉取结果")
|
||||
except ValueError:
|
||||
logger.info(f"[task_id={task_id}] 已入 Edge 队列,等待 Poller 拉取结果")
|
||||
return
|
||||
|
||||
if resp.status_code == 429:
|
||||
logger.warning(f"[task_id={task_id}] Edge 队列满 (429),回到 PENDING 稍后重试")
|
||||
db_layer.update_task_status(task_id, 'PENDING')
|
||||
return
|
||||
|
||||
logger.error(f"[task_id={task_id}] Edge 返回异常状态码: {resp.status_code}")
|
||||
self._schedule_retry(task)
|
||||
|
||||
def _query_uploaded_chunks(self, task_id, expected_total=None):
|
||||
"""查询 Edge 端已上传分块列表
|
||||
|
||||
返回 (uploaded_set, edge_total_chunks)。
|
||||
如果 expected_total 与 edge_total 不匹配(chunk_size 变更),
|
||||
返回空集让 Edge 自动清理旧分块。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
self.chunks_query_url,
|
||||
params={'task_id': task_id},
|
||||
timeout=(30, 15)
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
uploaded = set(data.get('uploaded_chunks', []))
|
||||
edge_total = data.get('total_chunks', 0)
|
||||
if expected_total and edge_total and edge_total != expected_total:
|
||||
logger.warning(f"[task_id={task_id}] Edge total_chunks={edge_total} "
|
||||
f"≠ expected={expected_total}(chunk_size 已变更),从头上传")
|
||||
return set(), edge_total
|
||||
return uploaded, edge_total
|
||||
logger.warning(f"[task_id={task_id}] 查询已上传分块返回 {resp.status_code},将全量重传")
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"[task_id={task_id}] 查询已上传分块失败(将全量重传): {e}")
|
||||
return set(), 0
|
||||
|
||||
def _dispatch_chunked(self, task, payload, video_path, file_size):
|
||||
"""大文件分块上传 + 断点续传
|
||||
|
||||
1. 查询 Edge 端已上传分块(断点续传)
|
||||
2. 上传缺失分块(每块最多重试 3 次,失败后查询 Edge 确认是否实际收到)
|
||||
3. 全部分块上传后调用 /assemble 合并入队
|
||||
"""
|
||||
task_id = task['task_id']
|
||||
total_chunks = math.ceil(file_size / CHUNK_SIZE)
|
||||
filename = os.path.basename(video_path)
|
||||
|
||||
# 1. 查询已上传分块(断点续传)
|
||||
uploaded_set, edge_total = self._query_uploaded_chunks(task_id, expected_total=total_chunks)
|
||||
if uploaded_set:
|
||||
logger.info(f"[task_id={task_id}] 断点续传: 已有 {len(uploaded_set)}/{total_chunks} 块")
|
||||
|
||||
# 2. 上传缺失分块
|
||||
try:
|
||||
with open(video_path, 'rb') as fh:
|
||||
for idx in range(total_chunks):
|
||||
if idx in uploaded_set:
|
||||
continue
|
||||
|
||||
chunk_data = fh.read(CHUNK_SIZE)
|
||||
if not chunk_data:
|
||||
break
|
||||
|
||||
success = False
|
||||
for attempt in range(MAX_CHUNK_RETRIES):
|
||||
try:
|
||||
cresp = requests.post(
|
||||
self.chunk_url,
|
||||
data={
|
||||
'task_id': str(task_id),
|
||||
'chunk_index': str(idx),
|
||||
'total_chunks': str(total_chunks),
|
||||
'filename': filename,
|
||||
},
|
||||
files={'chunk': (f'chunk_{idx}', io.BytesIO(chunk_data))},
|
||||
timeout=(60, 180)
|
||||
)
|
||||
if cresp.status_code == 200:
|
||||
success = True
|
||||
break
|
||||
logger.warning(f"[task_id={task_id}] 分块 {idx} 返回 {cresp.status_code}(尝试 {attempt+1}/{MAX_CHUNK_RETRIES})")
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"[task_id={task_id}] 分块 {idx} 上传失败(尝试 {attempt+1}/{MAX_CHUNK_RETRIES}): {e}")
|
||||
|
||||
if attempt < MAX_CHUNK_RETRIES - 1:
|
||||
time.sleep(5 * (attempt + 1))
|
||||
|
||||
if not success:
|
||||
uploaded_now, _ = self._query_uploaded_chunks(task_id)
|
||||
if idx in uploaded_now:
|
||||
logger.info(f"[task_id={task_id}] 分块 {idx} 虽超时但 Edge 已收到,继续下一块")
|
||||
uploaded_set.add(idx)
|
||||
continue
|
||||
logger.error(f"[task_id={task_id}] 分块 {idx} 确认未收到,安排文件级重试")
|
||||
self._schedule_retry(task)
|
||||
return
|
||||
|
||||
uploaded_set.add(idx)
|
||||
if (idx + 1) % 5 == 0 or idx == total_chunks - 1:
|
||||
logger.info(f"[task_id={task_id}] 分块进度: {idx + 1}/{total_chunks}")
|
||||
except IOError as e:
|
||||
logger.error(f"[task_id={task_id}] 读取视频文件失败: {e}")
|
||||
self._schedule_retry(task)
|
||||
return
|
||||
|
||||
# 3. 合并 + 入队
|
||||
try:
|
||||
aresp = requests.post(
|
||||
self.assemble_url,
|
||||
data={
|
||||
'task_id': str(task_id),
|
||||
'camera_name': payload.get('camera_name', ''),
|
||||
'event_start_time': payload.get('event_start_time', ''),
|
||||
'known_members_context': payload.get('known_members_context', ''),
|
||||
},
|
||||
timeout=(10, 60)
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[task_id={task_id}] 合并请求失败: {e}")
|
||||
self._schedule_retry(task)
|
||||
return
|
||||
|
||||
if aresp.status_code == 202:
|
||||
try:
|
||||
data = aresp.json()
|
||||
queue_id = data.get('queue_id', '?')
|
||||
asm_size = data.get('size_mb', '?')
|
||||
logger.info(f"[task_id={task_id}] 分块合并入队成功 (queue_id={queue_id}, {asm_size}MB),等待 Poller 拉取结果")
|
||||
except ValueError:
|
||||
logger.info(f"[task_id={task_id}] 分块合并入队成功,等待 Poller 拉取结果")
|
||||
return
|
||||
|
||||
logger.error(f"[task_id={task_id}] 合并端点返回 {aresp.status_code}: {aresp.text[:200]}")
|
||||
self._schedule_retry(task)
|
||||
|
||||
def _schedule_retry(self, task):
|
||||
"""调度重试"""
|
||||
task_id = task['task_id']
|
||||
if task['retry_count'] >= self.max_retries:
|
||||
db_layer.update_task_status(
|
||||
task_id, 'FAILED',
|
||||
error_message=f"超过最大重试次数 {self.max_retries}",
|
||||
failure_stage='callback'
|
||||
)
|
||||
logger.error(f"[task_id={task_id}] 超过最大重试次数,标记为 FAILED")
|
||||
return
|
||||
|
||||
backoff = self._calculate_backoff(task['retry_count'])
|
||||
next_retry = datetime.now() + timedelta(seconds=backoff)
|
||||
db_layer.increment_retry(task_id, next_retry)
|
||||
logger.info(f"[task_id={task_id}] 安排重试 #{task['retry_count']+1},{backoff}s 后执行 (at {next_retry})")
|
||||
|
||||
def _poll_once(self):
|
||||
"""执行一次轮询"""
|
||||
# 回收僵尸任务:PROCESSING 超过 stale_timeout 说明 Edge 丢失了任务
|
||||
try:
|
||||
stale_ids = db_layer.reclaim_stale_processing(self.stale_timeout)
|
||||
for tid in stale_ids:
|
||||
logger.warning(f"[task_id={tid}] PROCESSING 超时 {self.stale_timeout}s,回收为 PENDING 重试")
|
||||
except Exception as e:
|
||||
logger.error(f"僵尸任务回收失败: {e}", exc_info=True)
|
||||
|
||||
tasks = db_layer.get_pending_tasks(limit=1)
|
||||
for task in tasks:
|
||||
if task['retry_count'] >= task['max_retries']:
|
||||
db_layer.update_task_status(
|
||||
task['task_id'], 'FAILED',
|
||||
error_message=f"超过最大重试次数 {task['max_retries']}",
|
||||
failure_stage='callback'
|
||||
)
|
||||
logger.warning(f"[task_id={task['task_id']}] retry_count={task['retry_count']} >= max_retries={task['max_retries']},标记 FAILED")
|
||||
continue
|
||||
if self._should_retry(task):
|
||||
try:
|
||||
self._dispatch_one(task)
|
||||
except Exception as e:
|
||||
logger.error(f"[task_id={task['task_id']}] dispatch 异常: {e}", exc_info=True)
|
||||
|
||||
def _run(self):
|
||||
"""线程主循环"""
|
||||
logger.info(f"Dispatcher 启动 (enqueue + 分块模式),轮询间隔 {self.poll_interval}s")
|
||||
while self._running:
|
||||
try:
|
||||
self._poll_once()
|
||||
except Exception as e:
|
||||
logger.error(f"轮询异常: {e}", exc_info=True)
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def start(self):
|
||||
"""启动下发线程"""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='dispatcher')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self):
|
||||
"""线程是否存活"""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def check_and_restart(self):
|
||||
"""看门狗:线程崩溃后自动重启"""
|
||||
if self._running and not self.is_alive():
|
||||
logger.warning("Dispatcher 线程已死亡,正在重启...")
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='dispatcher')
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""停止下发线程"""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Event-Receiver 包"""
|
||||
from .event_receiver import event_bp
|
||||
|
||||
__all__ = ["event_bp"]
|
||||
@@ -1,172 +0,0 @@
|
||||
"""
|
||||
Event-Receiver - Flask 蓝图,接收 Edge 回调,写库
|
||||
|
||||
处理逻辑:
|
||||
1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details
|
||||
2. frame_details 携带的关键帧 base64 落盘到 fam-ui 静态目录(供时间轴展示)
|
||||
3. 对未命名的 abstract_label 自动 upsert 到 family_members
|
||||
4. 更新 process_tasks 状态为 SUCCESS
|
||||
5. 失败回调: 更新任务状态为 FAILED,记录 failure_stage
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from ..logger import setup_logger
|
||||
from ..config_loader import load_config
|
||||
from .. import db_layer
|
||||
|
||||
logger = setup_logger('fam-core.event_receiver')
|
||||
|
||||
event_bp = Blueprint('event_receiver', __name__)
|
||||
|
||||
# 关键帧落盘目录(fam-ui 读取展示;fam-core 与 fam-ui 同机部署)
|
||||
_cfg = load_config()
|
||||
FRAME_IMAGE_DIR = _cfg.get('storage', {}).get(
|
||||
'frame_image_dir',
|
||||
'/volume1/web/sentinel-home-ai/fam-ui/static/frames')
|
||||
|
||||
# 匹配 "人物A" / "人物B" 等 abstract_label
|
||||
_ABSTRACT_LABEL_PATTERN = re.compile(r'^人物[A-Z]$')
|
||||
|
||||
|
||||
def _is_abstract_label(person: str) -> bool:
|
||||
"""判断是否为未命名的 abstract_label"""
|
||||
return bool(_ABSTRACT_LABEL_PATTERN.match(person))
|
||||
|
||||
|
||||
def _save_frame_images(event_id: int, frame_details: list) -> int:
|
||||
"""把 frame_details 中的 base64 关键帧落盘,返回成功张数
|
||||
|
||||
同时写 meta.json(frame_index -> face_count),UI 据此挑选有人像的帧做头像。
|
||||
"""
|
||||
saved = 0
|
||||
face_counts = {}
|
||||
for frame in frame_details:
|
||||
img_b64 = frame.pop('frame_image', None)
|
||||
faces = frame.pop('face_count', None)
|
||||
if not img_b64:
|
||||
continue
|
||||
idx = frame.get('frame_index', 0)
|
||||
try:
|
||||
out_dir = os.path.join(FRAME_IMAGE_DIR, f'event_{event_id}')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, f'frame_{idx}.jpg')
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(base64.b64decode(img_b64))
|
||||
if faces is not None:
|
||||
face_counts[str(idx)] = int(faces)
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[event_id={event_id}] 关键帧落盘失败 frame_{idx}: {e}")
|
||||
if face_counts:
|
||||
try:
|
||||
import json as _json
|
||||
with open(os.path.join(out_dir, 'meta.json'), 'w') as f:
|
||||
_json.dump(face_counts, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"[event_id={event_id}] meta.json 写入失败: {e}")
|
||||
if saved:
|
||||
logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}")
|
||||
return saved
|
||||
|
||||
|
||||
def _upsert_abstract_members(frame_details: list):
|
||||
"""对未命名的 abstract_label 自动 upsert 到 family_members"""
|
||||
seen = {}
|
||||
for frame in frame_details:
|
||||
person = frame.get('person', '')
|
||||
if _is_abstract_label(person):
|
||||
clothing = frame.get('clothing', '')
|
||||
action = frame.get('action', '')
|
||||
feature = f"{clothing}、{action}" if clothing and action else clothing or action
|
||||
timestamp = frame.get('frame_timestamp', '')
|
||||
if person not in seen:
|
||||
seen[person] = (feature, timestamp)
|
||||
|
||||
for label, (feature, ts) in seen.items():
|
||||
db_layer.upsert_family_member(label, feature, ts)
|
||||
logger.info(f"upsert family_member: {label} (feature={feature})")
|
||||
|
||||
|
||||
def apply_success_event(task_id, data: dict) -> int:
|
||||
"""将成功结果写库,返回 event_id
|
||||
|
||||
供两条路径复用:
|
||||
- webhook 回调路由 (拉取模式)
|
||||
- Dispatcher 收到推送模式同步响应后直接落库
|
||||
"""
|
||||
# 1. 插入 monitor_events
|
||||
event_id = db_layer.insert_event(
|
||||
task_id=task_id,
|
||||
event_start_time=data['event_start_time'],
|
||||
event_end_time=data['event_end_time'],
|
||||
camera_name=data.get('camera_name', ''),
|
||||
global_summary=data.get('global_summary', ''),
|
||||
entities_json=data.get('entities_json', []),
|
||||
compute_provider=data.get('compute_provider', [])
|
||||
)
|
||||
|
||||
# 2. 关键帧图片落盘(先落盘再入库:pop 掉 base64 后 insert,避免大字段进 DB)
|
||||
frame_details = data.get('frame_details', [])
|
||||
try:
|
||||
_save_frame_images(event_id, frame_details)
|
||||
except Exception as e:
|
||||
logger.warning(f"[event_id={event_id}] 关键帧落盘异常(不影响入库): {e}")
|
||||
|
||||
# 3. 遍历 frame_details 逐条插入
|
||||
for frame in frame_details:
|
||||
db_layer.insert_event_detail(
|
||||
event_id=event_id,
|
||||
task_id=task_id,
|
||||
frame_index=frame.get('frame_index', 0),
|
||||
frame_timestamp=frame.get('frame_timestamp', ''),
|
||||
camera_name=frame.get('camera_name', data.get('camera_name', '')),
|
||||
person=frame.get('person', '未知'),
|
||||
action=frame.get('action', ''),
|
||||
clothing=frame.get('clothing', ''),
|
||||
is_attention_event=frame.get('is_attention_event', False),
|
||||
source_providers=frame.get('source_providers', [])
|
||||
)
|
||||
|
||||
# 3. 对未命名的 abstract_label 自动 upsert
|
||||
_upsert_abstract_members(frame_details)
|
||||
|
||||
# 4. 更新任务状态
|
||||
db_layer.update_task_status(task_id, 'SUCCESS')
|
||||
logger.info(f"[task_id={task_id}] 事件处理完成: event_id={event_id}, frame_details={len(frame_details)}条")
|
||||
return event_id
|
||||
|
||||
|
||||
@event_bp.route('/api/core/callback/event', methods=['POST'])
|
||||
def receive_event():
|
||||
"""接收 Edge 回调"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
task_id = data.get('task_id')
|
||||
status = data.get('status')
|
||||
logger.info(f"[task_id={task_id}] 收到回调: status={status}")
|
||||
|
||||
if status == 'success':
|
||||
try:
|
||||
event_id = apply_success_event(task_id, data)
|
||||
return jsonify({"status": "ok", "event_id": event_id}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[task_id={task_id}] 处理回调失败: {e}", exc_info=True)
|
||||
db_layer.update_task_status(task_id, 'FAILED', error_message=str(e), failure_stage='callback')
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
elif status == 'failed':
|
||||
failure_stage = data.get('failure_stage', '')
|
||||
error_message = data.get('error_message', '')
|
||||
db_layer.update_task_status(task_id, 'FAILED', error_message=error_message, failure_stage=failure_stage)
|
||||
logger.error(f"[task_id={task_id}] 任务失败: stage={failure_stage}, error={error_message}")
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
else:
|
||||
return jsonify({"error": f"Unknown status: {status}"}), 400
|
||||
@@ -1,14 +1,20 @@
|
||||
"""
|
||||
Member-Manager - Flask 蓝图,成员命名管理
|
||||
Member-Manager - Flask 蓝图,人物命名管理(新架构 v2)
|
||||
|
||||
1. GET /api/member/unnamed - 列出未命名人物
|
||||
2. POST /api/member/name - 命名人物 + 批量回溯更新
|
||||
3. GET /api/member/list - 列出所有成员
|
||||
Oracle 是人物规范的唯一真源。NAS 命名操作:
|
||||
1. POST /api/member/name 命名/重命名某 label -> 回推 Oracle + 立即拉回
|
||||
2. POST /api/member/merge 将两个 label 合并为同一身份(统一 canonical_name)
|
||||
3. GET /api/member/list 列出所有人物(label + canonical_name)
|
||||
4. GET /api/member/unnamed 列出未命名人物(canonical_name 为空)
|
||||
|
||||
命名流程: 调 Oracle /api/oracle/people/correct 设置 manual 规范名 ->
|
||||
立即 trigger_now() 拉回最新 people 镜像 -> 前端刷新即见结果。
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from ..logger import setup_logger
|
||||
from .. import db_layer
|
||||
from ..oracle_sync import get_sync
|
||||
|
||||
logger = setup_logger('fam-core.member_manager')
|
||||
|
||||
@@ -17,86 +23,122 @@ member_bp = Blueprint('member_manager', __name__)
|
||||
|
||||
@member_bp.route('/api/member/unnamed', methods=['GET'])
|
||||
def list_unnamed():
|
||||
"""列出未命名人物"""
|
||||
members = db_layer.get_unnamed_members()
|
||||
# datetime 序列化
|
||||
"""列出未命名人物(canonical_name 为空)"""
|
||||
members = db_layer.get_sync_people()
|
||||
result = []
|
||||
for m in members:
|
||||
result.append({
|
||||
"abstract_label": m['abstract_label'],
|
||||
"feature_description": m['feature_description'],
|
||||
"first_seen_at": m['first_seen_at'].isoformat() if hasattr(m['first_seen_at'], 'isoformat') else str(m['first_seen_at']),
|
||||
"event_count": m['event_count']
|
||||
})
|
||||
canonical = m.get('canonical_name')
|
||||
if not canonical:
|
||||
result.append({
|
||||
"label": m['label'],
|
||||
"appearances": m.get('appearances', 0),
|
||||
"first_seen": m.get('first_seen'),
|
||||
})
|
||||
return jsonify({"unnamed_members": result}), 200
|
||||
|
||||
|
||||
@member_bp.route('/api/member/name', methods=['POST'])
|
||||
def name_member():
|
||||
"""命名人物 + 批量回溯更新历史记录"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
abstract_label = data.get('abstract_label')
|
||||
real_name = data.get('real_name')
|
||||
named_by = data.get('named_by', '管理员')
|
||||
|
||||
if not abstract_label or not real_name:
|
||||
return jsonify({"error": "缺少必填字段: abstract_label, real_name"}), 400
|
||||
|
||||
logger.info(f"命名: {abstract_label} -> {real_name}")
|
||||
|
||||
try:
|
||||
result = db_layer.name_member(abstract_label, real_name, named_by)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 404
|
||||
return jsonify(result), 200
|
||||
except Exception as e:
|
||||
logger.error(f"命名失败: {e}", exc_info=True)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@member_bp.route('/api/member/merge', methods=['POST'])
|
||||
def merge_member():
|
||||
"""合并人物(用户判断两个标签是同一人时,source 并入 target)"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
source_key = data.get('source')
|
||||
target_key = data.get('target')
|
||||
if not source_key or not target_key:
|
||||
return jsonify({"error": "缺少必填字段: source, target"}), 400
|
||||
|
||||
logger.info(f"合并人物: {source_key} -> {target_key}")
|
||||
try:
|
||||
result = db_layer.merge_member(source_key, target_key)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 404
|
||||
return jsonify(result), 200
|
||||
except Exception as e:
|
||||
logger.error(f"合并失败: {e}", exc_info=True)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@member_bp.route('/api/member/list', methods=['GET'])
|
||||
def list_members():
|
||||
"""列出所有成员"""
|
||||
include_named = request.args.get('include_named', 'true').lower() == 'true'
|
||||
include_unnamed = request.args.get('include_unnamed', 'true').lower() == 'true'
|
||||
|
||||
members = db_layer.get_all_members(include_named, include_unnamed)
|
||||
"""列出所有人物(按 canonical_name 或 label 展示)"""
|
||||
members = db_layer.get_sync_people()
|
||||
result = []
|
||||
for m in members:
|
||||
canonical = m.get('canonical_name')
|
||||
result.append({
|
||||
"member_id": m['member_id'],
|
||||
"abstract_label": m['abstract_label'],
|
||||
"real_name": m['real_name'],
|
||||
"feature_description": m['feature_description'],
|
||||
"first_seen_at": m['first_seen_at'].isoformat() if hasattr(m['first_seen_at'], 'isoformat') else str(m['first_seen_at']),
|
||||
"named_at": m['named_at'].isoformat() if m.get('named_at') and hasattr(m['named_at'], 'isoformat') else None,
|
||||
"named_by": m.get('named_by'),
|
||||
"is_active": m['is_active']
|
||||
"label": m['label'],
|
||||
"canonical_name": canonical,
|
||||
"display_name": canonical or m['label'],
|
||||
"is_named": bool(canonical),
|
||||
"appearances": m.get('appearances', 0),
|
||||
"source": m.get('source'),
|
||||
"first_seen": m.get('first_seen'),
|
||||
})
|
||||
return jsonify({"members": result}), 200
|
||||
|
||||
|
||||
@member_bp.route('/api/member/name', methods=['POST'])
|
||||
def name_member():
|
||||
"""命名人物(回推 Oracle + 立即拉回本地镜像)
|
||||
|
||||
请求: {"label": "人物A", "canonical_name": "张三"}
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
label = (data.get('label') or '').strip()
|
||||
canonical_name = (data.get('canonical_name') or '').strip()
|
||||
if not label or not canonical_name:
|
||||
return jsonify({"error": "缺少必填字段: label, canonical_name"}), 400
|
||||
|
||||
logger.info(f"命名: {label} -> {canonical_name}(回推 Oracle)")
|
||||
ok, err = get_sync().push_name_correct(label, canonical_name)
|
||||
if not ok:
|
||||
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
|
||||
|
||||
# 立即拉回最新 people 镜像,前端无需等待下一个 30 分钟周期
|
||||
try:
|
||||
get_sync().trigger_now()
|
||||
except Exception as e:
|
||||
logger.warning(f"命名后即时拉回失败(下一个周期会自动同步): {e}")
|
||||
|
||||
members = db_layer.get_sync_people()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"label": label,
|
||||
"canonical_name": canonical_name,
|
||||
"members": [{
|
||||
"label": m['label'],
|
||||
"canonical_name": m.get('canonical_name'),
|
||||
"display_name": m.get('canonical_name') or m['label'],
|
||||
} for m in members]
|
||||
}), 200
|
||||
|
||||
|
||||
@member_bp.route('/api/member/merge', methods=['POST'])
|
||||
def merge_member():
|
||||
"""合并人物:将 source 并入 target 身份(统一 canonical_name)
|
||||
|
||||
若 target 已命名 -> 用其 canonical_name;否则用 target label 作为规范名。
|
||||
请求: {"source": "人物B", "target": "张三" 或 "人物A"}
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
source = (data.get('source') or '').strip()
|
||||
target = (data.get('target') or '').strip()
|
||||
if not source or not target:
|
||||
return jsonify({"error": "缺少必填字段: source, target"}), 400
|
||||
if source == target:
|
||||
return jsonify({"error": "source 与 target 不能相同"}), 400
|
||||
|
||||
# 解析 target 的规范名
|
||||
members = {m['label']: m for m in db_layer.get_sync_people()}
|
||||
target_row = members.get(target)
|
||||
if target_row and target_row.get('canonical_name'):
|
||||
canonical = target_row['canonical_name']
|
||||
else:
|
||||
canonical = target # target 未命名 -> 以 label 作为规范名
|
||||
|
||||
logger.info(f"合并: {source} -> {canonical}(回推 Oracle)")
|
||||
ok, err = get_sync().push_name_correct(source, canonical)
|
||||
if not ok:
|
||||
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
|
||||
|
||||
try:
|
||||
get_sync().trigger_now()
|
||||
except Exception as e:
|
||||
logger.warning(f"合并后即时拉回失败(下一个周期会自动同步): {e}")
|
||||
|
||||
members = db_layer.get_sync_people()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"source": source,
|
||||
"target_canonical": canonical,
|
||||
"members": [{
|
||||
"label": m['label'],
|
||||
"canonical_name": m.get('canonical_name'),
|
||||
"display_name": m.get('canonical_name') or m['label'],
|
||||
} for m in members]
|
||||
}), 200
|
||||
|
||||
170
fam-core/src/fam_core/oracle_sync.py
Normal file
170
fam-core/src/fam_core/oracle_sync.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Oracle-Sync - NAS 端唯一后台线程
|
||||
|
||||
职责:
|
||||
1. 每 interval_sec(默认 1800s = 30 分钟)从甲骨文 FAM-Edge 拉取增量:
|
||||
GET {base_url}/api/oracle/sync?since=<cursor>&token=<token>
|
||||
返回 {videos, events, people, server_time},写入本地 MariaDB 镜像表
|
||||
(sync_videos / sync_events / sync_people),并推进 sync_cursor。
|
||||
2. 接收命名校正回推: POST {base_url}/api/oracle/people/correct
|
||||
{label, canonical_name, token} —— 手动命名(manual 优先,不被 LLM 覆盖)。
|
||||
|
||||
数据流向(新架构 v2):
|
||||
Google 硬盘 --rclone--> 甲骨文本地 --> 整视频分析 --> Oracle SQLite
|
||||
--> [本线程每 30 分钟拉增量] --> NAS MariaDB 镜像 --> fam-ui 读取展示
|
||||
|
||||
NAS 不再处理任何视频,CPU 占用显著降低。
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
from . import db_layer
|
||||
|
||||
logger = setup_logger('fam-core.oracle_sync')
|
||||
|
||||
_SYNC_INSTANCE = None
|
||||
|
||||
|
||||
def get_sync():
|
||||
"""模块级单例(app.py 启动时创建并 start,其余模块经此获取)"""
|
||||
global _SYNC_INSTANCE
|
||||
if _SYNC_INSTANCE is None:
|
||||
_SYNC_INSTANCE = OracleSync()
|
||||
return _SYNC_INSTANCE
|
||||
|
||||
|
||||
class OracleSync:
|
||||
def __init__(self):
|
||||
cfg = load_config().get('oracle_sync', {})
|
||||
self.base_url = cfg.get('base_url', 'http://129.146.203.203:5000').rstrip('/')
|
||||
self.token = cfg.get('token', '')
|
||||
self.interval_sec = int(cfg.get('interval_sec', 1800))
|
||||
self.timeout = int(cfg.get('timeout', 120))
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_sync_at = None
|
||||
self._last_error = None
|
||||
self._last_count = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _pull_once(self) -> bool:
|
||||
"""执行一次增量拉取。返回是否成功。"""
|
||||
since = db_layer.get_sync_cursor() or ''
|
||||
params = {'since': since, 'token': self.token}
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self.base_url}/api/oracle/sync",
|
||||
params=params, timeout=(10, self.timeout))
|
||||
except requests.RequestException as e:
|
||||
self._last_error = f"请求失败: {e}"
|
||||
logger.error(f"拉取同步失败: {e}")
|
||||
return False
|
||||
|
||||
if resp.status_code == 401:
|
||||
self._last_error = "token 校验失败"
|
||||
logger.error("同步 token 校验失败 (401),请检查 oracle_sync.token 配置")
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
self._last_error = f"HTTP {resp.status_code}"
|
||||
logger.error(f"同步返回异常: {resp.status_code} {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
self._last_error = "非 JSON 响应"
|
||||
logger.error("同步返回非 JSON 响应")
|
||||
return False
|
||||
|
||||
videos = data.get('videos', []) or []
|
||||
events = data.get('events', []) or []
|
||||
people = data.get('people', []) or []
|
||||
server_time = data.get('server_time', '') or ''
|
||||
|
||||
n_videos = db_layer.upsert_sync_videos(videos)
|
||||
n_events = db_layer.upsert_sync_events(events)
|
||||
n_people = db_layer.upsert_sync_people(people)
|
||||
|
||||
if server_time:
|
||||
db_layer.set_sync_cursor(server_time)
|
||||
|
||||
self._last_sync_at = datetime.now()
|
||||
self._last_error = None
|
||||
self._last_count = (n_videos, n_events, n_people)
|
||||
logger.info(
|
||||
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
|
||||
f"since={since!r} -> server_time={server_time}")
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def push_name_correct(self, label: str, canonical_name: str):
|
||||
"""回推命名校正到 Oracle(手动命名优先级最高,不被 LLM 覆盖)。
|
||||
|
||||
返回 (success: bool, error: str)
|
||||
"""
|
||||
label = (label or '').strip()
|
||||
canonical_name = (canonical_name or '').strip()
|
||||
if not label or not canonical_name:
|
||||
return False, "缺少 label / canonical_name"
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/api/oracle/people/correct",
|
||||
json={"label": label, "canonical_name": canonical_name,
|
||||
"token": self.token},
|
||||
timeout=(10, 30))
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"命名校正回推失败: {e}")
|
||||
return False, str(e)
|
||||
if resp.status_code == 200:
|
||||
return True, ""
|
||||
msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
logger.error(f"命名校正回推失败: {msg}")
|
||||
return False, msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _run(self):
|
||||
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s,目标 {self.base_url}")
|
||||
while self._running:
|
||||
try:
|
||||
self._pull_once()
|
||||
except Exception as e:
|
||||
self._last_error = str(e)
|
||||
logger.error(f"同步异常: {e}", exc_info=True)
|
||||
# 分段休眠,便于 stop 快速唤醒
|
||||
for _ in range(self.interval_sec):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='oracle-sync')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self):
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def trigger_now(self) -> bool:
|
||||
"""立即触发一次同步(命名后即时拉回 / 手动)。"""
|
||||
return self._pull_once()
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self.is_alive(),
|
||||
"last_sync_at": self._last_sync_at.isoformat() if self._last_sync_at else None,
|
||||
"last_error": self._last_error,
|
||||
"last_count": self._last_count,
|
||||
"cursor": db_layer.get_sync_cursor(),
|
||||
"interval_sec": self.interval_sec,
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
Poller - 定期从 Edge 拉取已完成的任务结果,写入 MariaDB
|
||||
|
||||
流程:
|
||||
1. 每 N 秒请求 Edge /api/edge/results?limit=10
|
||||
2. 遍历结果列表,对每个 nas_task_id:
|
||||
- success: 调用 apply_success_event 写入 monitor_events + event_details,标记 SUCCESS
|
||||
- failed: 更新任务状态为 FAILED,记录 failure_stage 和 error_message
|
||||
3. Edge 端自动标记已拉取的结果为 delivered
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from ..logger import setup_logger, log_task
|
||||
from ..config_loader import load_config
|
||||
from .. import db_layer
|
||||
from ..event_receiver.event_receiver import apply_success_event
|
||||
|
||||
logger = setup_logger('fam-core.poller')
|
||||
|
||||
|
||||
class Poller:
|
||||
"""结果拉取器,定期从 Edge 拉取处理结果"""
|
||||
|
||||
def __init__(self):
|
||||
cfg = load_config()
|
||||
poller_cfg = cfg.get('poller', {})
|
||||
self.poll_interval = poller_cfg.get('poll_interval', 30)
|
||||
self.results_url = poller_cfg.get('results_url', 'http://localhost:5000/api/edge/results')
|
||||
self.batch_size = poller_cfg.get('batch_size', 10)
|
||||
self.timeout = poller_cfg.get('timeout', 30)
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def _handle_result(self, item: dict):
|
||||
"""处理单个结果"""
|
||||
nas_task_id = item.get('nas_task_id')
|
||||
result = item.get('result')
|
||||
|
||||
if not nas_task_id:
|
||||
logger.warning(f"结果缺少 nas_task_id,跳过: {item}")
|
||||
return
|
||||
|
||||
if result is None:
|
||||
logger.error(f"[task_id={nas_task_id}] Edge 返回空结果,标记 FAILED")
|
||||
db_layer.update_task_status(
|
||||
nas_task_id, 'FAILED',
|
||||
error_message='Edge returned empty result',
|
||||
failure_stage='callback'
|
||||
)
|
||||
return
|
||||
|
||||
status = result.get('status')
|
||||
if status == 'success':
|
||||
try:
|
||||
event_id = apply_success_event(nas_task_id, result)
|
||||
log_task(logger, nas_task_id, 'poller', f'结果落库成功: event_id={event_id}')
|
||||
except Exception as e:
|
||||
logger.error(f"[task_id={nas_task_id}] 结果落库失败: {e}", exc_info=True)
|
||||
db_layer.update_task_status(
|
||||
nas_task_id, 'FAILED', error_message=str(e), failure_stage='callback')
|
||||
elif status == 'failed':
|
||||
error_message = result.get('error_message', 'unknown')
|
||||
failure_stage = result.get('failure_stage', '')
|
||||
logger.error(f"[task_id={nas_task_id}] Edge 处理失败: stage={failure_stage}, error={error_message}")
|
||||
db_layer.update_task_status(
|
||||
nas_task_id, 'FAILED', error_message=error_message, failure_stage=failure_stage)
|
||||
else:
|
||||
logger.warning(f"[task_id={nas_task_id}] 未知状态: {status}")
|
||||
|
||||
def _poll_once(self):
|
||||
"""执行一次拉取"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
self.results_url,
|
||||
params={'limit': self.batch_size},
|
||||
timeout=(10, 15)
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"拉取结果失败: {e}")
|
||||
return
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Edge 返回 {resp.status_code}")
|
||||
return
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
logger.error("Edge 返回非 JSON 响应")
|
||||
return
|
||||
|
||||
results = data.get('results', [])
|
||||
if not results:
|
||||
return
|
||||
|
||||
logger.info(f"拉取到 {len(results)} 条结果")
|
||||
for item in results:
|
||||
try:
|
||||
self._handle_result(item)
|
||||
except Exception as e:
|
||||
task_id = item.get('nas_task_id', '?')
|
||||
logger.error(f"[task_id={task_id}] 处理结果异常: {e}", exc_info=True)
|
||||
|
||||
def _run(self):
|
||||
"""线程主循环"""
|
||||
logger.info(f"Poller 启动,轮询间隔 {self.poll_interval}s,目标: {self.results_url}")
|
||||
while self._running:
|
||||
try:
|
||||
self._poll_once()
|
||||
except Exception as e:
|
||||
logger.error(f"轮询异常: {e}", exc_info=True)
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def start(self):
|
||||
"""启动拉取线程"""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='poller')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self):
|
||||
"""线程是否存活"""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def check_and_restart(self):
|
||||
"""看门狗:线程崩溃后自动重启"""
|
||||
if self._running and not self.is_alive():
|
||||
logger.warning("Poller 线程已死亡,正在重启...")
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='poller')
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""停止拉取线程"""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Task-Scheduler 包"""
|
||||
from .scheduler import TaskScheduler
|
||||
|
||||
__all__ = ["TaskScheduler"]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""
|
||||
Task-Scheduler - 60s 轮询视频目录,创建 PENDING 任务
|
||||
|
||||
判定视频完成(is_complete):
|
||||
- 修改时间 > 60s(文件已停止写入)
|
||||
- 文件大小稳定(连续两次检查大小一致)
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from ..logger import setup_logger, log_task
|
||||
from ..config_loader import load_config
|
||||
from .. import db_layer
|
||||
|
||||
logger = setup_logger('fam-core.scheduler')
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
"""视频目录扫描器,60s 轮询"""
|
||||
|
||||
def __init__(self):
|
||||
cfg = load_config()
|
||||
self.scan_interval = cfg.get('scheduler', {}).get('scan_interval', 60)
|
||||
self.video_dir = cfg.get('scheduler', {}).get('video_dir', '/volume1/surveillance')
|
||||
self.media_base_url = cfg.get('video_server', {}).get('base_url', 'http://127.0.0.1:8000/media')
|
||||
self.video_extensions = cfg.get('scheduler', {}).get('video_extensions', ['.mp4', '.mkv', '.avi'])
|
||||
self.file_stable_seconds = cfg.get('scheduler', {}).get('file_stable_seconds', 60)
|
||||
self.camera_name = cfg.get('scheduler', {}).get('camera_name', '默认摄像头')
|
||||
|
||||
# 文件大小缓存,用于判断文件是否稳定
|
||||
self._file_sizes: dict = {} # path -> size
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def _is_video(self, filename):
|
||||
return any(filename.lower().endswith(ext) for ext in self.video_extensions)
|
||||
|
||||
def _is_complete(self, filepath):
|
||||
"""判断视频是否已停止写入"""
|
||||
try:
|
||||
stat = os.stat(filepath)
|
||||
now = time.time()
|
||||
# 修改时间距当前 > stable_seconds
|
||||
if now - stat.st_mtime < self.file_stable_seconds:
|
||||
return False
|
||||
# 文件大小稳定(与上次检查一致)
|
||||
prev_size = self._file_sizes.get(filepath)
|
||||
if prev_size is not None and prev_size == stat.st_size:
|
||||
return True
|
||||
self._file_sizes[filepath] = stat.st_size
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _build_video_url(self, filepath):
|
||||
"""构建 Video-Server 下载 URL"""
|
||||
filename = os.path.basename(filepath)
|
||||
token = load_config().get('video_server', {}).get('token', '')
|
||||
return f"{self.media_base_url}/{filename}?token={token}"
|
||||
|
||||
def scan_once(self):
|
||||
"""执行一次扫描"""
|
||||
if not os.path.isdir(self.video_dir):
|
||||
logger.warning(f"视频目录不存在: {self.video_dir}")
|
||||
return
|
||||
|
||||
new_count = 0
|
||||
for root, dirs, files in os.walk(self.video_dir):
|
||||
for filename in files:
|
||||
if not self._is_video(filename):
|
||||
continue
|
||||
filepath = os.path.join(root, filename)
|
||||
if not self._is_complete(filepath):
|
||||
continue
|
||||
# 检查是否已有任务
|
||||
if db_layer.get_video_url_exists(filepath):
|
||||
continue
|
||||
# 创建新任务
|
||||
video_url = self._build_video_url(filepath)
|
||||
task_id = db_layer.create_task(filepath, video_url)
|
||||
new_count += 1
|
||||
log_task(logger, task_id, 'scheduler', f'新任务: {filename}')
|
||||
|
||||
if new_count > 0:
|
||||
logger.info(f"本次扫描发现 {new_count} 个新视频")
|
||||
|
||||
def _run(self):
|
||||
"""线程主循环"""
|
||||
logger.info(f"Task-Scheduler 启动,扫描间隔 {self.scan_interval}s,目录: {self.video_dir}")
|
||||
while self._running:
|
||||
try:
|
||||
self.scan_once()
|
||||
except Exception as e:
|
||||
logger.error(f"扫描异常: {e}", exc_info=True)
|
||||
time.sleep(self.scan_interval)
|
||||
|
||||
def start(self):
|
||||
"""启动调度线程"""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='task-scheduler')
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self):
|
||||
"""线程是否存活"""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def check_and_restart(self):
|
||||
"""看门狗:线程崩溃后自动重启"""
|
||||
if self._running and not self.is_alive():
|
||||
logger.warning("Scheduler 线程已死亡,正在重启...")
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name='task-scheduler')
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""停止调度线程"""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Video-Server 包"""
|
||||
from .video_server import video_bp
|
||||
|
||||
__all__ = ["video_bp"]
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
Video-Server - Flask 蓝图,提供 mp4 静态下载
|
||||
|
||||
路由带 ?token=xxx 鉴权
|
||||
无 token 或 token 错误返回 403
|
||||
文件不存在返回 404
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, request, send_from_directory, jsonify
|
||||
|
||||
from ..logger import setup_logger
|
||||
from ..config_loader import load_config
|
||||
|
||||
logger = setup_logger('fam-core.video_server')
|
||||
|
||||
video_bp = Blueprint('video_server', __name__)
|
||||
|
||||
_config = None
|
||||
|
||||
|
||||
def _get_config():
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_config()
|
||||
return _config
|
||||
|
||||
|
||||
@video_bp.route('/media/<path:filename>', methods=['GET'])
|
||||
def serve_video(filename):
|
||||
"""提供视频文件下载,带 token 鉴权"""
|
||||
cfg = _get_config()
|
||||
token = cfg.get('video_server', {}).get('token', '')
|
||||
video_dir = cfg.get('video_server', {}).get('video_dir', '/volume1/surveillance')
|
||||
|
||||
# 鉴权
|
||||
req_token = request.args.get('token', '')
|
||||
if not token or req_token != token:
|
||||
logger.warning(f"鉴权失败: {filename}, token={req_token}")
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
# 检查文件
|
||||
filepath = os.path.join(video_dir, filename)
|
||||
if not os.path.isfile(filepath):
|
||||
logger.warning(f"文件不存在: {filepath}")
|
||||
return jsonify({"error": "Not Found"}), 404
|
||||
|
||||
logger.info(f"提供视频: {filename}")
|
||||
return send_from_directory(
|
||||
os.path.dirname(filepath),
|
||||
os.path.basename(filepath),
|
||||
as_attachment=True
|
||||
)
|
||||
Reference in New Issue
Block a user