""" API-Gateway - Flask 蓝图,接收任务 模式: 1. enqueue (异步): NAS 上传视频 → Edge 入队 → 立即返回 → 消费者异步处理 → NAS 轮询拉取结果 2. push (同步, 兼容保留): NAS 上传 → Edge 同步处理 → 结果随响应返回 3. analyze (旧拉取模式, 兼容保留) """ import os 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 @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 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('/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