""" API-Gateway - Flask 蓝图,接收任务 模式: 1. enqueue (异步): NAS 上传视频 → Edge 入队 → 立即返回 → 消费者异步处理 → NAS 轮询拉取结果 2. push (同步, 兼容保留): NAS 上传 → Edge 同步处理 → 结果随响应返回 3. analyze (旧拉取模式, 兼容保留) """ import os import base64 import threading import requests from flask import Blueprint, request, jsonify from ..logger import setup_logger from ..ai_orchestrator.orchestrator import AIOrchestrator from ..video_preprocessor.preprocessor import VideoPreprocessor from ..queue import queue_manager logger = setup_logger('fam-edge.api_gateway') api_bp = Blueprint('api_gateway', __name__) _current_task_lock = threading.Lock() _currently_processing = False _orchestrator = None def get_orchestrator(): global _orchestrator if _orchestrator is None: _orchestrator = AIOrchestrator() return _orchestrator @api_bp.route('/api/edge/video/analyze', methods=['POST']) def receive_task(): """接收分析任务""" global _currently_processing data = request.get_json(silent=True) if not data: return jsonify({"error": "Invalid JSON"}), 400 task_id = data.get('task_id') video_url = data.get('video_url') webhook_url = data.get('webhook_url') if not task_id or not video_url or not webhook_url: return jsonify({"error": "缺少必填字段: task_id, video_url, webhook_url"}), 400 logger.info(f"[task_id={task_id}] 收到任务: {video_url}") # 并发控制 with _current_task_lock: if _currently_processing: logger.warning(f"[task_id={task_id}] 队列已满 (当前有任务处理中),返回 429") return jsonify({"error": "Queue full", "retry_after": 60}), 429 _currently_processing = True # 异步处理 def _process(): global _currently_processing try: orch = get_orchestrator() orch.process_task(data) except Exception as e: logger.error(f"[task_id={task_id}] 处理异常: {e}", exc_info=True) finally: with _current_task_lock: _currently_processing = False thread = threading.Thread(target=_process, daemon=True, name=f'task-{task_id}') thread.start() return jsonify({"status": "accepted", "task_id": task_id}), 202 @api_bp.route('/api/edge/video/enqueue', methods=['POST']) def enqueue_task(): """异步模式:接收 multipart 视频上传,入队后立即返回 NAS 上传视频 → Edge 保存到磁盘 + 入 SQLite 队列 → 返回 task_id 消费者线程异步处理,NAS 通过 /api/edge/results 拉取结果 """ task_id_raw = request.form.get('task_id') file = request.files.get('video') if not task_id_raw or not file: return jsonify({"error": "缺少必填字段: task_id, video"}), 400 try: task_id = int(task_id_raw) except ValueError: return jsonify({"error": "task_id 必须是整数"}), 400 camera_name = request.form.get('camera_name', '') event_start_time = request.form.get('event_start_time', '') known_members_context = request.form.get('known_members_context', '') upload_dir = os.environ.get('FAM_UPLOAD_DIR', '/tmp/fam_uploads') os.makedirs(upload_dir, exist_ok=True) video_filename = f"task_{task_id}_{file.filename}" video_path = os.path.join(upload_dir, video_filename) try: file.save(video_path) size_mb = os.path.getsize(video_path) / 1024 / 1024 logger.info(f"[task_id={task_id}] 入队: {file.filename} ({size_mb:.1f}MB)") queue_id = queue_manager.enqueue( nas_task_id=task_id, video_filename=file.filename, video_path=video_path, camera_name=camera_name, event_start_time=event_start_time, known_members_context=known_members_context, ) return jsonify({ "status": "queued", "task_id": task_id, "queue_id": queue_id, }), 202 except Exception as e: logger.error(f"[task_id={task_id}] 入队失败: {e}", exc_info=True) if os.path.exists(video_path): os.remove(video_path) return jsonify({"error": str(e)}), 500 # ========== 分块上传(断点续传)========== CHUNK_SIZE = 20 * 1024 * 1024 # 20MB per chunk def _chunk_dir(task_id: int) -> str: upload_dir = os.environ.get('FAM_UPLOAD_DIR', '/tmp/fam_uploads') d = os.path.join(upload_dir, f"task_{task_id}") os.makedirs(d, exist_ok=True) return d @api_bp.route('/api/edge/video/chunk', methods=['POST']) def upload_chunk(): """接收单个分块,保存到 task_{id}/chunk_{index:04d} 断点续传:同一 task_id + chunk_index 重复上传会覆盖, NAS 端可通过 /chunks 查询已上传分块,跳过已有的。 """ task_id_raw = request.form.get('task_id') chunk_index_raw = request.form.get('chunk_index') total_chunks_raw = request.form.get('total_chunks') filename = request.form.get('filename', 'video.mp4') chunk_file = request.files.get('chunk') if not task_id_raw or not chunk_index_raw or not chunk_file: return jsonify({"error": "缺少必填字段: task_id, chunk_index, chunk"}), 400 try: task_id = int(task_id_raw) chunk_index = int(chunk_index_raw) total_chunks = int(total_chunks_raw) if total_chunks_raw else 0 except ValueError: return jsonify({"error": "task_id/chunk_index 必须是整数"}), 400 d = _chunk_dir(task_id) chunk_path = os.path.join(d, f"chunk_{chunk_index:04d}") try: # 检查 total_chunks 是否变化(chunk_size 变更导致),自动清理旧分块并更新元数据 import json meta_path = os.path.join(d, "meta.json") if total_chunks and os.path.exists(meta_path): try: with open(meta_path) as mf: old_meta = json.load(mf) if old_meta.get('total_chunks') and old_meta['total_chunks'] != total_chunks: logger.warning(f"[task_id={task_id}] total_chunks 变更 " f"({old_meta['total_chunks']}→{total_chunks}),清理旧分块") for fn in os.listdir(d): if fn.startswith('chunk_'): os.remove(os.path.join(d, fn)) with open(meta_path, 'w') as f: json.dump({"filename": filename, "total_chunks": total_chunks}, f) except (json.JSONDecodeError, IOError): pass chunk_file.save(chunk_path) size_kb = os.path.getsize(chunk_path) / 1024 # 写元数据(首次上传时) if not os.path.exists(meta_path): meta = {"filename": filename, "total_chunks": total_chunks} with open(meta_path, 'w') as f: json.dump(meta, f) # 统计已上传分块 uploaded = sorted([ int(fn.split('_')[1]) for fn in os.listdir(d) if fn.startswith('chunk_') and len(fn.split('_')) == 2 ]) logger.info(f"[task_id={task_id}] 分块 {chunk_index}/{total_chunks} 上传成功 " f"({size_kb:.0f}KB, 已上传 {len(uploaded)}/{total_chunks})") return jsonify({ "status": "ok", "task_id": task_id, "chunk_index": chunk_index, "uploaded_count": len(uploaded), "total_chunks": total_chunks, }), 200 except Exception as e: logger.error(f"[task_id={task_id}] 分块上传失败: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 @api_bp.route('/api/edge/video/chunks', methods=['GET']) def query_chunks(): """查询已上传分块列表(断点续传:NAS 重启后查询跳过已有分块)""" task_id_raw = request.args.get('task_id') if not task_id_raw: return jsonify({"error": "缺少 task_id"}), 400 try: task_id = int(task_id_raw) except ValueError: return jsonify({"error": "task_id 必须是整数"}), 400 d = _chunk_dir(task_id) uploaded = sorted([ int(fn.split('_')[1]) for fn in os.listdir(d) if fn.startswith('chunk_') and len(fn.split('_')) == 2 ]) if os.path.isdir(d) else [] total = 0 meta_path = os.path.join(d, "meta.json") if os.path.exists(meta_path): import json try: with open(meta_path) as f: total = json.load(f).get('total_chunks', 0) except (json.JSONDecodeError, IOError): pass return jsonify({ "task_id": task_id, "uploaded_chunks": uploaded, "uploaded_count": len(uploaded), "total_chunks": total, }), 200 @api_bp.route('/api/edge/video/assemble', methods=['POST']) def assemble_chunks(): """合并所有分块为完整视频文件,入 SQLite 队列 NAS 上传完全部分块后调用此端点触发合并 + 入队。 """ task_id_raw = request.form.get('task_id') if not task_id_raw: return jsonify({"error": "缺少 task_id"}), 400 try: task_id = int(task_id_raw) except ValueError: return jsonify({"error": "task_id 必须是整数"}), 400 camera_name = request.form.get('camera_name', '') event_start_time = request.form.get('event_start_time', '') known_members_context = request.form.get('known_members_context', '') d = _chunk_dir(task_id) # 读取元数据 import json meta_path = os.path.join(d, "meta.json") if not os.path.exists(meta_path): return jsonify({"error": "元数据不存在,请先上传分块"}), 400 try: with open(meta_path) as f: meta = json.load(f) except json.JSONDecodeError: return jsonify({"error": "元数据损坏"}), 500 filename = meta.get('filename', 'video.mp4') total_chunks = meta.get('total_chunks', 0) # 检查分块完整性 chunk_files = sorted([ fn for fn in os.listdir(d) if fn.startswith('chunk_') and len(fn.split('_')) == 2 ]) if total_chunks and len(chunk_files) < total_chunks: missing = total_chunks - len(chunk_files) return jsonify({ "error": f"分块不完整: {len(chunk_files)}/{total_chunks},缺 {missing} 块", "uploaded_count": len(chunk_files), "total_chunks": total_chunks, }), 400 # 合并分块 upload_dir = os.environ.get('FAM_UPLOAD_DIR', '/tmp/fam_uploads') video_filename = f"task_{task_id}_{filename}" video_path = os.path.join(upload_dir, video_filename) try: with open(video_path, 'wb') as out: for cf in chunk_files: chunk_path = os.path.join(d, cf) with open(chunk_path, 'rb') as chunk_f: out.write(chunk_f.read()) size_mb = os.path.getsize(video_path) / 1024 / 1024 logger.info(f"[task_id={task_id}] 分块合并完成: {filename} ({size_mb:.1f}MB, {len(chunk_files)} 块)") # 清理分块目录 import shutil shutil.rmtree(d, ignore_errors=True) # 入队 queue_id = queue_manager.enqueue( nas_task_id=task_id, video_filename=filename, video_path=video_path, camera_name=camera_name, event_start_time=event_start_time, known_members_context=known_members_context, ) return jsonify({ "status": "queued", "task_id": task_id, "queue_id": queue_id, "size_mb": round(size_mb, 1), }), 202 except Exception as e: logger.error(f"[task_id={task_id}] 合并失败: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 @api_bp.route('/api/edge/results', methods=['GET']) def get_results(): """返回已完成但未拉取的结果,标记为已交付""" limit = int(request.args.get('limit', 10)) results = queue_manager.get_undelivered_results(limit=limit) import json payload = [] task_ids = [] for r in results: try: result_json = json.loads(r['result_json']) if r['result_json'] else None except json.JSONDecodeError: result_json = None if r['status'] == 'FAILED': result_json = { "status": "failed", "error_message": r['error_message'] or 'unknown', "failure_stage": r['failure_stage'] or '', } payload.append({ "nas_task_id": r['nas_task_id'], "result": result_json, }) task_ids.append(r['id']) if task_ids: queue_manager.mark_delivered(task_ids) return jsonify({"results": payload, "count": len(payload)}), 200 @api_bp.route('/api/edge/queue/stats', methods=['GET']) def queue_stats(): """队列状态统计""" stats = queue_manager.get_queue_stats() return jsonify(stats), 200 @api_bp.route('/api/edge/video/push', methods=['POST']) def receive_push_task(): """推送模式:接收 multipart 视频上传,同步分析,结果随 HTTP 响应返回 NAS 无法被 Oracle 反向访问(Tailscale 不通),因此改为 NAS 主动上传视频, Edge 用 OpenCV 场景变化检测抽帧后分析,摘要直接放在响应里带回。 """ global _currently_processing task_id_raw = request.form.get('task_id') file = request.files.get('video') if not task_id_raw or not file: return jsonify({"error": "缺少必填字段: task_id, video"}), 400 try: task_id = int(task_id_raw) except ValueError: return jsonify({"error": "task_id 必须是整数"}), 400 logger.info(f"[task_id={task_id}] 收到推送任务: {file.filename}") # 并发控制(同步处理,占用整个请求周期) with _current_task_lock: if _currently_processing: logger.warning(f"[task_id={task_id}] 已有任务处理中,返回 429") return jsonify({"error": "Queue full", "retry_after": 60}), 429 _currently_processing = True preprocessor = None try: preprocessor = VideoPreprocessor(task_id) video_path = preprocessor.save_upload(file) task_data = { "task_id": task_id, "camera_name": request.form.get('camera_name', ''), "event_start_time": request.form.get('event_start_time', ''), "event_end_time": request.form.get('event_end_time', ''), "known_members_context": request.form.get('known_members_context', ''), } result = get_orchestrator().process_push_task(task_data, video_path, preprocessor) return jsonify(result), 200 except Exception as e: logger.error(f"[task_id={task_id}] 推送任务异常: {e}", exc_info=True) return jsonify({ "task_id": task_id, "status": "failed", "failure_stage": "upload", "error_message": str(e) }), 200 finally: if preprocessor is not None: preprocessor.cleanup() with _current_task_lock: _currently_processing = False @api_bp.route('/api/edge/mark_frames', methods=['POST']) def mark_frames(): """NAS 存量关键帧批量补红框(检测计算在 Edge,NAS 只存图)""" data = request.get_json(silent=True) if not data: return jsonify({"error": "Invalid JSON"}), 400 images = data.get('images') if not isinstance(images, list) or not images or len(images) > 12: return jsonify({"error": "images 需要 1-12 项 [{key, data}]"}), 400 from ..frame_marker import mark_jpeg results = [] for item in images: key = item.get('key', '') b64 = item.get('data', '') try: marked, faces = mark_jpeg(base64.b64decode(b64)) results.append({ "key": key, "data": base64.b64encode(marked).decode('ascii'), "faces": faces }) except Exception as e: logger.warning(f"补标失败 {key}: {e}") results.append({"key": key, "data": None, "faces": 0, "error": str(e)}) return jsonify({"results": results}), 200 @api_bp.route('/health', methods=['GET']) def health(): """健康检查""" global _currently_processing orch = get_orchestrator() healthy = orch.health_check_all() if not healthy: return jsonify({ "status": "unavailable", "healthy_models": [], "processing": _currently_processing }), 503 return jsonify({ "status": "ok", "healthy_models": [a.provider_name for a in healthy], "processing": _currently_processing }), 200 @api_bp.route('/api/edge/chat', methods=['POST']) def chat_proxy(): """代理转发至本地 Ollama /api/generate(兼容旧调用,Ollama 未对外暴露)""" data = request.get_json(silent=True) if not data: return jsonify({"error": "Invalid JSON"}), 400 try: resp = requests.post( 'http://127.0.0.1:11434/api/generate', json=data, timeout=data.get('options', {}).get('timeout', 120) ) return jsonify(resp.json()), resp.status_code except requests.RequestException as e: logger.error(f"Chat proxy error: {e}") return jsonify({"error": f"Ollama unreachable: {e}"}), 502 @api_bp.route('/api/edge/chat/ask', methods=['POST']) def chat_ask(): """智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底) 请求: {"prompt": "..."} 响应: {"answer": "...", "provider": "gemini"|"nvidia"|"ollama"} """ data = request.get_json(silent=True) if not data or 'prompt' not in data: return jsonify({"error": "缺少必填字段: prompt"}), 400 prompt = data['prompt'] max_tokens = int(data.get('max_tokens', 512)) answer, provider = get_orchestrator().run_qa(prompt, max_tokens=max_tokens) if answer is None: return jsonify({ "error": "所有模型均不可用(Gemini / NVIDIA / Ollama 全部失败)" }), 503 return jsonify({"answer": answer, "provider": provider}), 200