@@ -0,0 +1,960 @@
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user