import json import logging import time from datetime import datetime from typing import Any, Dict import gzip from flask import Blueprint, current_app, jsonify, request from sqlalchemy import and_, func, select from app import db from app.models import ( SyncClient, SyncClientDisplayName, SyncConflict, SyncDelivery, SyncMetadata, SyncQueue, ) from app.routes.auth_decorators import require_auth, require_paired_terminal from app.services.sync_manager import ( REPORT_TABLES, SERVER_MASTER_TABLES, SyncManager, apply_sync_change, build_consistency_snapshot, bootstrap_universal_sync, format_sync_pull_out_line, ) from app.services.client_log_upload import store_client_log from app.services.sync_error_display import sync_error_text_for_display from app.services.sync_runtime import sync_runtime from app.routes.sync_request_parser import ( parse_confirm_payload, parse_json_request, parse_pull_payload, parse_push_payload, ) from config import Config bp = Blueprint("sync", __name__, url_prefix="/api/sync") logger = logging.getLogger(__name__) def _sync_payload_message(payload: Any) -> str: if isinstance(payload, dict): m = payload.get("message") if m is not None and str(m).strip(): return str(m).strip()[:2000] err = payload.get("error") if err is not None and err is not True: return str(err)[:2000] return str(payload)[:2000] if payload is not None else "" def _log_sync_manager_result(operation: str, client_id: str, result: dict) -> None: """process_* часто возвращает 4xx/5xx без исключения — без этой строки в логах пусто.""" code = int(result.get("status_code") or 200) if code < 400: return pl = result.get("payload") msg = _sync_payload_message(pl) remote = (getattr(request, "remote_addr", None) or "")[:48] log_fn = logger.error if code >= 500 else logger.warning log_fn( "[%s-FAIL] HTTP %s client_id=%s remote=%s %s", operation, code, (client_id or "")[:36], remote, msg or "—", ) def error_response(message: str, status_code: int = 400, **extra: Any): payload: Dict[str, Any] = {"error": True, "message": message} if extra: payload.update(extra) return jsonify(payload), status_code @bp.get("/ping") def sync_ping(): from app.services.lan_network import detect_lan_ip return jsonify({"status": "ok", "lan_ip": detect_lan_ip() or None}), 200 @bp.get("/clients") @require_auth def sync_clients(): clients = db.session.execute( select(SyncClient) .where(SyncClient.is_deleted.is_(False)) .order_by(SyncClient.last_seen.desc().nullslast(), SyncClient.created_at.desc()) ).scalars().all() names = { n.node_id: n.display_name for n in db.session.execute(select(SyncClientDisplayName)).scalars().all() } return jsonify( [ { "node_id": c.node_id, "client_name": c.client_name, "display_name": names.get(c.node_id), "ip_address": c.ip_address, "status": c.status, "last_seen": c.last_seen.isoformat() if c.last_seen else None, "is_enabled": c.is_enabled, "total_syncs": c.total_syncs, "sync_error": sync_error_text_for_display(c.last_error), } for c in clients ] ) @bp.get("/clients//deliveries") @require_auth def sync_client_deliveries(node_id: str): try: hours = int(request.args.get("hours", 24)) except ValueError: return error_response("hours должен быть числом", 400) if hours <= 0: hours = 24 client = db.session.execute( select(SyncClient).where( SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False) ) ).scalar_one_or_none() if not client: return error_response("Клиент не найден", 404) from datetime import datetime, timedelta cutoff = datetime.now() - timedelta(hours=hours) rows = db.session.execute( select(SyncDelivery, SyncQueue) .join(SyncQueue, SyncDelivery.task_id == SyncQueue.id) .where(SyncDelivery.client_id == client.id) .where(SyncDelivery.delivered_at >= cutoff) .order_by(SyncDelivery.delivered_at.desc()) .limit(500) ).all() deliveries = [] for d, task in rows: deliveries.append( { "task_id": d.task_id, "table_name": task.table_name, "action": task.action, "record_id": task.record_id, "created_at": task.created_at.isoformat() if task.created_at else None, "processed_at": task.processed_at.isoformat() if task.processed_at else None, "delivered_at": d.delivered_at.isoformat() if d.delivered_at else None, "name": task.record_id, "context": task.table_name, } ) return jsonify({"deliveries": deliveries}) @bp.put("/clients//display_name") @require_auth def sync_client_set_display_name(node_id: str): data = request.get_json() or {} display_name = (data.get("display_name") or "").strip() client = db.session.execute( select(SyncClient).where( SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False) ) ).scalar_one_or_none() if not client: return error_response("Клиент не найден", 404) from datetime import datetime row = db.session.execute( select(SyncClientDisplayName).where(SyncClientDisplayName.node_id == node_id) ).scalar_one_or_none() if not display_name: if row: db.session.delete(row) db.session.commit() return jsonify({"success": True, "message": "Имя клиента сброшено"}) if not row: row = SyncClientDisplayName( node_id=node_id, display_name=display_name, updated_at=datetime.now(), ) db.session.add(row) else: row.display_name = display_name row.updated_at = datetime.now() db.session.commit() return jsonify({"success": True, "display_name": display_name}) @bp.delete("/clients/") @require_auth def sync_client_delete(node_id: str): client = db.session.execute( select(SyncClient).where( SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False) ) ).scalar_one_or_none() if not client: return error_response("Клиент не найден", 404) client.is_deleted = True client.status = "disabled" client.is_enabled = False db.session.commit() return jsonify({"success": True, "message": "Клиент удалён"}) @bp.get("/metadata") @require_auth def sync_metadata(): row = db.session.execute( select(SyncMetadata) .where(SyncMetadata.is_deleted.is_(False)) .order_by(SyncMetadata.created_at.asc()) ).scalar_one_or_none() if not row: return error_response("Метаданные синхронизации не найдены", 404) return jsonify( { "id": row.id, "node_id": row.node_id, "node_type": row.node_type, "node_name": row.node_name, "node_status": row.node_status, "last_heartbeat": row.last_heartbeat.isoformat() if row.last_heartbeat else None, "last_sync": row.last_sync.isoformat() if row.last_sync else None, "sync_status": row.sync_status, "is_enabled": row.is_enabled, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } ) @bp.get("/clients/") @require_auth def sync_client_details(node_id: str): client = db.session.execute( select(SyncClient).where( SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False) ) ).scalar_one_or_none() if not client: return error_response("Клиент не найден", 404) return jsonify( { "id": client.id, "node_id": client.node_id, "client_name": client.client_name, "ip_address": client.ip_address, "port": client.port, "status": client.status, "last_seen": client.last_seen.isoformat() if client.last_seen else None, "total_syncs": client.total_syncs, "last_error": client.last_error, "sync_error": sync_error_text_for_display(client.last_error), "is_enabled": client.is_enabled, "created_at": client.created_at.isoformat() if client.created_at else None, "updated_at": client.updated_at.isoformat() if client.updated_at else None, } ) @bp.get("/queue") @require_auth def sync_queue_list(): status = (request.args.get("status") or "").strip() priority_raw = (request.args.get("priority") or "").strip() try: limit = int(request.args.get("limit", 100)) except ValueError: return error_response("limit должен быть числом", 400) query = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)) if status: query = query.where(SyncQueue.status == status) if priority_raw: try: priority = int(priority_raw) except ValueError: return error_response("priority должен быть числом", 400) query = query.where(SyncQueue.priority == priority) rows = db.session.execute( query.order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).limit( max(1, min(limit, 1000)) ) ).scalars().all() return jsonify( [ { "id": r.id, "table_name": r.table_name, "record_id": r.record_id, "action": r.action, "status": r.status, "target_node_id": r.target_node_id, "source_node_id": r.source_node_id, "priority": r.priority, "retry_count": r.retry_count, "max_retries": r.max_retries, "error_message": r.error_message, "created_at": r.created_at.isoformat() if r.created_at else None, "processed_at": r.processed_at.isoformat() if r.processed_at else None, "completed_at": r.completed_at.isoformat() if r.completed_at else None, } for r in rows ] ) @bp.get("/queue/stats") @require_auth def sync_queue_stats(): base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery() stats = { "total": db.session.scalar(select(func.count()).select_from(base)) or 0, "pending": db.session.scalar( select(func.count()).select_from(base).where(base.c.status == "pending") ) or 0, "processing": db.session.scalar( select(func.count()).select_from(base).where(base.c.status == "processing") ) or 0, "completed": db.session.scalar( select(func.count()).select_from(base).where(base.c.status == "completed") ) or 0, "failed": db.session.scalar( select(func.count()).select_from(base).where(base.c.status == "failed") ) or 0, "by_priority": {}, } for p in range(1, 6): stats["by_priority"][f"priority_{p}"] = ( db.session.scalar( select(func.count()).select_from(base).where(base.c.priority == p) ) or 0 ) return jsonify(stats) @bp.post("/bootstrap") @require_auth def sync_bootstrap(): created = bootstrap_universal_sync() db.session.commit() return jsonify({"success": True, "created": created}) @bp.get("/conflicts") @require_auth def sync_conflicts(): resolution = (request.args.get("resolution") or "").strip() conflict_type = (request.args.get("conflict_type") or "").strip() try: limit = int(request.args.get("limit", 50)) except ValueError: return error_response("limit должен быть числом", 400) query = select(SyncConflict).where(SyncConflict.is_deleted.is_(False)) if resolution: query = query.where(SyncConflict.resolution == resolution) if conflict_type: query = query.where(SyncConflict.conflict_type == conflict_type) rows = db.session.execute( query.order_by(SyncConflict.created_at.desc()).limit(max(1, min(limit, 1000))) ).scalars().all() return jsonify( [ { "id": c.id, "table_name": c.table_name, "record_id": c.record_id, "conflict_type": c.conflict_type, "local_data": c.local_data, "remote_data": c.remote_data, "resolution": c.resolution, "resolved_by": c.resolved_by, "resolved_at": c.resolved_at.isoformat() if c.resolved_at else None, "created_at": c.created_at.isoformat() if c.created_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None, } for c in rows ] ) @bp.post("/conflicts//resolve") @require_auth def sync_conflict_resolve(conflict_id: str): row = db.session.execute( select(SyncConflict).where( SyncConflict.id == conflict_id, SyncConflict.is_deleted.is_(False) ) ).scalar_one_or_none() if not row: return error_response("Конфликт не найден", 404) data = request.get_json() or {} resolution = (data.get("resolution") or "").strip() if resolution not in {"local", "remote", "merged"}: return error_response("Неверное разрешение конфликта", 400) local_data = json.loads(row.local_data) if row.local_data else None remote_data = json.loads(row.remote_data) if row.remote_data else None is_server_master = row.table_name in SERVER_MASTER_TABLES is_client_master = row.table_name in REPORT_TABLES applied_payload: Any = None if resolution == "local": if local_data: applied_payload = local_data elif resolution == "remote": if remote_data: applied_payload = remote_data elif resolution == "merged": if is_server_master and remote_data: applied_payload = remote_data elif is_client_master and local_data: applied_payload = local_data elif remote_data: applied_payload = remote_data if applied_payload: result = apply_sync_change(row.table_name, row.record_id, "update", applied_payload) if not result["success"]: return error_response( f"Ошибка применения изменений: {result.get('error', 'unknown')}", 500, ) row.resolution = resolution row.resolved_by = (data.get("resolved_by") or "admin").strip() or "admin" row.resolved_at = datetime.now() db.session.commit() return jsonify( { "success": True, "message": f"Конфликт разрешен: {resolution}", "conflict_id": row.id, "resolved_by": row.resolved_by, "resolved_at": row.resolved_at.isoformat() if row.resolved_at else None, "applied_data": applied_payload is not None, } ) @bp.get("/status") @require_auth def sync_status(): metadata = db.session.execute( select(SyncMetadata).where(SyncMetadata.is_deleted.is_(False)) ).scalar_one_or_none() clients_base = select(SyncClient).where(SyncClient.is_deleted.is_(False)).subquery() clients_stats = { "total": db.session.scalar(select(func.count()).select_from(clients_base)) or 0, "active": db.session.scalar( select(func.count()).select_from(clients_base).where(clients_base.c.status == "active") ) or 0, "offline": db.session.scalar( select(func.count()).select_from(clients_base).where(clients_base.c.status == "offline") ) or 0, "disabled": db.session.scalar( select(func.count()).select_from(clients_base).where(clients_base.c.status == "disabled") ) or 0, } queue_base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery() queue_stats = { "total": db.session.scalar(select(func.count()).select_from(queue_base)) or 0, "pending": db.session.scalar( select(func.count()).select_from(queue_base).where(queue_base.c.status == "pending") ) or 0, "processing": db.session.scalar( select(func.count()).select_from(queue_base).where(queue_base.c.status == "processing") ) or 0, "completed": db.session.scalar( select(func.count()).select_from(queue_base).where(queue_base.c.status == "completed") ) or 0, "failed": db.session.scalar( select(func.count()).select_from(queue_base).where(queue_base.c.status == "failed") ) or 0, } conflicts_base = select(SyncConflict).where(SyncConflict.is_deleted.is_(False)).subquery() conflicts_stats = { "total": db.session.scalar(select(func.count()).select_from(conflicts_base)) or 0, "pending": db.session.scalar( select(func.count()) .select_from(conflicts_base) .where( (conflicts_base.c.resolution == "pending") | (conflicts_base.c.resolution.is_(None)) ) ) or 0, "resolved": db.session.scalar( select(func.count()) .select_from(conflicts_base) .where( (conflicts_base.c.resolution != "pending") & (conflicts_base.c.resolution.is_not(None)) ) ) or 0, "by_type": { "version_mismatch": db.session.scalar( select(func.count()) .select_from(conflicts_base) .where(conflicts_base.c.conflict_type == "version_mismatch") ) or 0, "application_error": db.session.scalar( select(func.count()) .select_from(conflicts_base) .where(conflicts_base.c.conflict_type == "application_error") ) or 0, "deletion": db.session.scalar( select(func.count()) .select_from(conflicts_base) .where(conflicts_base.c.conflict_type == "deletion") ) or 0, "hash": db.session.scalar( select(func.count()) .select_from(conflicts_base) .where(conflicts_base.c.conflict_type == "hash") ) or 0, }, } return jsonify( { "metadata": { "node_id": metadata.node_id if metadata else None, "node_type": metadata.node_type if metadata else None, "sync_status": metadata.sync_status if metadata else None, "last_sync": metadata.last_sync.isoformat() if metadata and metadata.last_sync else None, }, "clients": clients_stats, "queue": queue_stats, "conflicts": conflicts_stats, "timestamp": datetime.now().isoformat(), } ) @bp.get("/consistency") @require_auth def sync_consistency(): """Read-only checksum summary for master-data consistency checks.""" return jsonify({"success": True, "snapshot": build_consistency_snapshot()}) @bp.post("/init") @require_auth def sync_init_metadata(): row = db.session.execute( select(SyncMetadata).where(SyncMetadata.is_deleted.is_(False)) ).scalar_one_or_none() if not row: row = SyncMetadata( node_id="server-local", node_type="server", node_name="WESP Server", node_status="active", sync_status="idle", is_enabled=True, ) db.session.add(row) row.last_heartbeat = datetime.now() db.session.commit() return jsonify( { "success": True, "message": "Метаданные синхронизации инициализированы", "node_id": row.node_id, "node_type": row.node_type, } ) @bp.post("/register") def sync_register_client(): data, parse_error = parse_json_request(request) if parse_error: return error_response(parse_error, 400) assert data is not None client_id = (data.get("client_id") or "").strip() if not client_id: return error_response("client_id обязателен", 400) client = db.session.execute( select(SyncClient).where(SyncClient.node_id == client_id) ).scalar_one_or_none() now = datetime.now() if client: client.client_name = (data.get("client_name") or client.client_name or "Unknown").strip() client.ip_address = request.remote_addr client.port = int(data.get("port", client.port or 80)) client.status = "active" client.last_seen = now client.is_enabled = True client.is_deleted = False else: client = SyncClient( node_id=client_id, client_name=(data.get("client_name") or "Unknown").strip() or "Unknown", ip_address=request.remote_addr, port=int(data.get("port", 80)), status="active", last_seen=now, total_syncs=0, is_enabled=True, ) db.session.add(client) db.session.commit() return jsonify({"success": True, "message": "Клиент зарегистрирован", "client_id": client_id}) @bp.post("/check-db") @require_auth def sync_check_db(): payload = { "success": True, "checks": { "sync_clients": db.session.scalar( select(func.count()).select_from(SyncClient).where(SyncClient.is_deleted.is_(False)) ) or 0, "sync_queue": db.session.scalar( select(func.count()).select_from(SyncQueue).where(SyncQueue.is_deleted.is_(False)) ) or 0, "sync_conflicts": db.session.scalar( select(func.count()) .select_from(SyncConflict) .where(SyncConflict.is_deleted.is_(False)) ) or 0, }, } return jsonify(payload) @bp.post("/check-db-client") @require_paired_terminal def sync_check_db_client(): data = request.get_json(silent=True) or {} client_id = (data.get("client_id") or "").strip() if not client_id: return error_response("client_id обязателен", 400) client = db.session.execute( select(SyncClient).where( SyncClient.node_id == client_id, SyncClient.is_deleted.is_(False) ) ).scalar_one_or_none() if not client: return error_response("Клиент не найден", 404) tasks = db.session.scalar( select(func.count()).select_from(SyncQueue).where( SyncQueue.is_deleted.is_(False), (SyncQueue.target_node_id == client_id) | (SyncQueue.target_node_id.is_(None)), ) ) or 0 return jsonify({"success": True, "client_id": client_id, "pending_tasks": tasks}) @bp.post("/restore-db") @require_paired_terminal def sync_restore_db(): # Legacy endpoint compatibility: explicit restore action is intentionally disabled # in refactored architecture to avoid unsafe ad-hoc DB replacement. return jsonify({"success": False, "message": "Операция restore-db отключена"}), 501 @bp.post("/pull") def sync_pull(): start_time = time.time() client_id_for_lock = "" pull_lock_acquired = False slot_acquired = False try: data, parse_error = parse_json_request(request) if parse_error: return error_response(parse_error, 400) assert data is not None client_id, limit, pull_client_name, payload_error = parse_pull_payload(data) if payload_error: return error_response(payload_error, 400) assert client_id is not None client_id_for_lock = client_id init_hdr = (request.headers.get("X-WESP-Initial-Sync") or "").strip().lower() in ( "1", "true", "yes", "on", ) steady_wait = float(getattr(Config, "SYNC_PULL_CLIENT_LOCK_WAIT_SEC", 180.0)) initial_wait = float(getattr(Config, "SYNC_PULL_INITIAL_LOCK_WAIT_SEC", 600.0)) lock_wait = initial_wait if init_hdr else steady_wait if not sync_runtime.acquire_pull_client(client_id, wait_sec=lock_wait): logger.warning( "[SYNC-PULL-CLIENT-TIMEOUT] Не получен lock pull за %ss (%s) client_id=%s…", lock_wait, "initial" if init_hdr else "steady", (client_id or "")[:12], ) return error_response( "Предыдущая синхронизация этого узла ещё выполняется (превышено время ожидания). Повторите позже.", 503, ) pull_lock_acquired = True used_slots, available_slots = sync_runtime.concurrency_status() if not sync_runtime.acquire_slot(): logger.warning( "[SYNC-PULL-BLOCKED] Все слоты заняты (%s/%s)", used_slots, Config.SYNC_MAX_CONCURRENT, ) return error_response( "Слишком много одновременных синхронизаций. Попробуйте позже.", 429, ) slot_acquired = True logger.info( "[SYNC-PULL-START] client_id=%s… доступно слотов: %s/%s", (client_id or "")[:12], available_slots - 1, Config.SYNC_MAX_CONCURRENT, ) result = SyncManager.process_pull( client_id=client_id, limit=limit, client_name=pull_client_name, client_ip=request.remote_addr, initial_sync_header=init_hdr, ) _log_sync_manager_result("SYNC-PULL", client_id, result) status_code = int(result["status_code"]) payload = result["payload"] if isinstance(payload, dict) and status_code in (200, 202): logger.info(format_sync_pull_out_line(client_id, status_code, payload)) elapsed = time.time() - start_time sync_runtime.record_pull_duration(elapsed) p95 = sync_runtime.pull_duration_p95() dur_ms = str(int(max(0.0, elapsed) * 1000)) p95_ms = str(int(max(0.0, p95) * 1000)) if status_code == 202: resp = jsonify(payload) retry_after = int((payload or {}).get("retry_after_sec") or 2) resp.headers["Retry-After"] = str(max(1, retry_after)) resp.headers["X-WESP-Pull-Duration-Ms"] = dur_ms resp.headers["X-WESP-Pull-P95-Ms"] = p95_ms return resp, 202 resp = jsonify(payload) resp.headers["X-WESP-Pull-Duration-Ms"] = dur_ms resp.headers["X-WESP-Pull-P95-Ms"] = p95_ms return resp, status_code except Exception as e: logger.error("[SYNC-PULL-ERROR] Ошибка sync_pull: %s", e, exc_info=True) return error_response(str(e), 500) finally: logger.debug("[SYNC-PULL-END] client_id=%s…", (client_id_for_lock or "")[:12]) if slot_acquired: sync_runtime.release_slot() if pull_lock_acquired and client_id_for_lock: sync_runtime.release_pull_client(client_id_for_lock) @bp.post("/confirm") def sync_confirm(): start_time = time.time() try: data, parse_error = parse_json_request(request) if parse_error: return error_response(parse_error, 400) assert data is not None client_id, task_ids, payload_error = parse_confirm_payload(data) if payload_error: return error_response(payload_error, 400) assert client_id is not None and task_ids is not None result = SyncManager.process_confirm(client_id=client_id, task_ids=task_ids) _log_sync_manager_result("SYNC-CONFIRM", client_id, result) return jsonify(result["payload"]), result["status_code"] except Exception as e: logger.error("[SYNC-CONFIRM-ERROR] Ошибка sync_confirm: %s", e, exc_info=True) return error_response(str(e), 500) finally: logger.debug("[SYNC-CONFIRM-END] duration=%.3fs", time.time() - start_time) @bp.post("/client-log") def sync_client_log_upload(): """Приём файла лога с полевого клиента (отдельно от sync БД).""" if not current_app.config.get("WESP_CLIENT_LOG_UPLOAD_ENABLED", True): return error_response("Выгрузка логов с клиентов отключена", 403) secret = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_SECRET") or "").strip() if secret and request.headers.get("X-WESP-Client-Log-Secret", "").strip() != secret: return error_response("Неверный секрет выгрузки логов", 401) max_b = int(current_app.config.get("WESP_CLIENT_LOG_UPLOAD_MAX_BYTES") or 8_388_608) client_id = (request.headers.get("X-WESP-Client-Id") or "").strip() log_name = (request.headers.get("X-WESP-Log-Name") or "wesp.log").strip() raw: bytes if request.is_json: data = request.get_json(silent=True) or {} if not isinstance(data, dict): return error_response("Ожидается JSON-объект", 400) if not client_id: client_id = str(data.get("client_id") or "").strip() ln = data.get("log_name") or data.get("name") if ln: log_name = str(ln).strip() b64 = data.get("content_base64") text = data.get("content") if "content" in data else data.get("text") if b64 is not None: import base64 try: raw = base64.b64decode(str(b64), validate=False) except Exception: return error_response("Некорректный content_base64", 400) elif text is not None: raw = str(text).encode("utf-8") else: return error_response("Укажите content (или text) либо content_base64", 400) else: if not client_id: return error_response("Заголовок X-WESP-Client-Id обязателен", 400) raw = request.get_data(cache=False) or b"" if request.headers.get("Content-Encoding", "").lower() == "gzip": try: raw = gzip.decompress(raw) except Exception: return error_response("Ошибка распаковки gzip", 400) if not client_id: return error_response("client_id обязателен", 400) if len(raw) > max_b: return error_response(f"Превышен лимит размера лога ({max_b} байт)", 413) if not sync_runtime.check_rate_limit(client_id): return error_response("Превышен лимит запросов", 429) upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip() if not upload_dir: return error_response("Сервер не настроен на приём логов", 500) try: meta = store_client_log( upload_dir=upload_dir, client_id=client_id, log_basename=log_name, raw=raw, ) except ValueError as e: return error_response(str(e), 400) logger.info( "[CLIENT-LOG] client=%s size=%s -> %s", client_id[:16], meta.get("bytes_written"), meta.get("relative"), ) return jsonify({"success": True, **meta}), 200 @bp.post("/push") def sync_push(): start_time = time.time() used_slots, available_slots = sync_runtime.concurrency_status() if not sync_runtime.acquire_slot(): logger.warning( "[SYNC-PUSH-BLOCKED] Запрос заблокирован: все слоты заняты (%s/%s)", used_slots, Config.SYNC_MAX_CONCURRENT, ) return error_response( "Слишком много одновременных синхронизаций. Попробуйте позже.", 429, ) logger.info( "[SYNC-PUSH-START] Начало обработки, доступно слотов: %s/%s", available_slots - 1, Config.SYNC_MAX_CONCURRENT, ) try: data, parse_error = parse_json_request(request, allow_gzip=True) if parse_error: return error_response(parse_error, 400) assert data is not None client_id, changes, payload_error = parse_push_payload(data) if payload_error: return error_response(payload_error, 400) assert client_id is not None if not sync_runtime.check_rate_limit(client_id): return error_response("Превышен лимит запросов. Попробуйте позже.", 429) result = SyncManager.process_push(client_id=client_id, changes=changes) _log_sync_manager_result("SYNC-PUSH", client_id, result) return jsonify(result["payload"]), result["status_code"] except Exception as e: logger.error("[SYNC-PUSH-ERROR] Ошибка sync_push: %s", e, exc_info=True) return error_response(str(e), 500) finally: logger.debug("[SYNC-PUSH-END] duration=%.3fs", time.time() - start_time) sync_runtime.release_slot()