Files
site/WESP_REL/app/routes/sync.py
T
2026-07-17 12:57:18 +03:00

961 lines
34 KiB
Python

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/<string:node_id>/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/<string:node_id>/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/<string:node_id>")
@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/<string:node_id>")
@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/<string:conflict_id>/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()