961 lines
34 KiB
Python
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()
|
|
|