929 lines
38 KiB
TypeScript
929 lines
38 KiB
TypeScript
import { Router } from "express";
|
||
import type { Express, Response } from "express";
|
||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||
import { gpsStorage } from "./storage";
|
||
import { processPosition, filterTrackPoints, offRoadDistance } from "./geozone.service";
|
||
import { hasGpsAccess } from "./access";
|
||
import { ensureDevice, deleteDevice } from "./traccar.client";
|
||
import { OSRM_URL } from "./osrm";
|
||
import { storage } from "../storage";
|
||
|
||
/**
|
||
* Роуты GPS-модуля.
|
||
* Публичный POST /api/gps/ingest (приём точек от Traccar, секрет в заголовке)
|
||
* регистрируется отдельно, ДО роутера с authenticateToken.
|
||
*/
|
||
|
||
const MAX_TRACK_POINTS = 2000;
|
||
|
||
// =====================
|
||
// OSRM map matching (привязка трека к дорогам)
|
||
// =====================
|
||
|
||
// URL OSRM-сервиса — см. ./osrm (общий модуль, используется и в geozone.service)
|
||
// Лимит OSRM Match API — 100 координат на запрос; берём с запасом
|
||
const OSRM_CHUNK_SIZE = 95;
|
||
// Таймаут запроса к OSRM
|
||
const OSRM_TIMEOUT_MS = 5000;
|
||
// TTL in-memory кэша привязанных треков (точки меняются по мере поступления новых)
|
||
const MATCHED_CACHE_TTL_MS = 10 * 60 * 1000;
|
||
|
||
interface MatchedTrack {
|
||
/** Геометрия привязанного трека в [lat, lng] (для единообразия с остальными точками) */
|
||
points: Array<[number, number]>;
|
||
/** Суммарная длина по дорогам, метры */
|
||
distance: number;
|
||
/** Суммарная длительность по дорогам, секунды */
|
||
duration: number;
|
||
}
|
||
|
||
// In-memory кэш: ключ `${assetId}:${from}:${to}` → результат (или null при недоступности OSRM)
|
||
const matchedTrackCache = new Map<string, { data: MatchedTrack | null; expiresAt: number }>();
|
||
// Warn про недоступность OSRM пишем один раз до следующего успешного ответа
|
||
let osrmUnavailableWarned = false;
|
||
|
||
interface OsrmMatchResponse {
|
||
code?: string;
|
||
matchings?: Array<{
|
||
geometry?: { coordinates?: Array<[number, number]> };
|
||
distance?: number;
|
||
duration?: number;
|
||
}>;
|
||
}
|
||
|
||
/**
|
||
* Привязка трека к дорогам через OSRM Match API.
|
||
* Возвращает null, если OSRM недоступен (endpoint при этом не падает).
|
||
* LBS-точки сюда не передаём — привязываем только точные GPS-данные.
|
||
*/
|
||
async function matchTrack(points: Array<{ lat: number; lng: number }>): Promise<MatchedTrack | null> {
|
||
const allCoords: Array<[number, number]> = [];
|
||
let distance = 0;
|
||
let duration = 0;
|
||
|
||
for (let i = 0; i < points.length; i += OSRM_CHUNK_SIZE) {
|
||
const chunk = points.slice(i, i + OSRM_CHUNK_SIZE);
|
||
if (chunk.length < 2) continue;
|
||
// ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat
|
||
const coordsParam = chunk.map((p) => `${p.lng},${p.lat}`).join(";");
|
||
const url = `${OSRM_URL}/match/v1/driving/${coordsParam}?overview=full&geometries=geojson&gaps=split&tidy=true`;
|
||
|
||
let data: OsrmMatchResponse;
|
||
try {
|
||
const res = await fetch(url, { signal: AbortSignal.timeout(OSRM_TIMEOUT_MS) });
|
||
data = (await res.json()) as OsrmMatchResponse;
|
||
if (osrmUnavailableWarned) {
|
||
console.log("[GPS] OSRM снова доступен");
|
||
osrmUnavailableWarned = false;
|
||
}
|
||
} catch (err) {
|
||
// OSRM недоступен (ECONNREFUSED/таймаут) — трек без привязки, endpoint не падает
|
||
if (!osrmUnavailableWarned) {
|
||
console.warn(`[GPS] OSRM недоступен (${OSRM_URL}):`, err instanceof Error ? err.message : err);
|
||
osrmUnavailableWarned = true;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Чанк не смэтчился (code != 'Ok') — пропускаем его и продолжаем
|
||
if (data.code !== "Ok" || !Array.isArray(data.matchings)) continue;
|
||
|
||
for (const matching of data.matchings) {
|
||
const coords = matching.geometry?.coordinates;
|
||
if (Array.isArray(coords)) {
|
||
for (const [lng, lat] of coords) {
|
||
// Дедупликация стыковочной точки между чанками/matching'ами
|
||
const prev = allCoords[allCoords.length - 1];
|
||
if (prev && prev[0] === lat && prev[1] === lng) continue;
|
||
// Храним в [lat, lng] для единообразия с точками трека
|
||
allCoords.push([lat, lng]);
|
||
}
|
||
}
|
||
distance += matching.distance ?? 0;
|
||
duration += matching.duration ?? 0;
|
||
}
|
||
}
|
||
|
||
if (allCoords.length < 2) return null;
|
||
return { points: allCoords, distance: Math.round(distance), duration: Math.round(duration) };
|
||
}
|
||
|
||
/** Привязка трека с in-memory кэшем (TTL 10 минут) */
|
||
async function matchTrackCached(
|
||
assetId: number,
|
||
from: Date | null,
|
||
to: Date | null,
|
||
points: Array<{ lat: number; lng: number }>
|
||
): Promise<MatchedTrack | null> {
|
||
const key = `${assetId}:${from?.toISOString() ?? ""}:${to?.toISOString() ?? ""}`;
|
||
const cached = matchedTrackCache.get(key);
|
||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||
|
||
const data = await matchTrack(points);
|
||
matchedTrackCache.set(key, { data, expiresAt: Date.now() + MATCHED_CACHE_TTL_MS });
|
||
|
||
// Простая уборка протухших записей, чтобы Map не рос бесконечно
|
||
if (matchedTrackCache.size > 100) {
|
||
const now = Date.now();
|
||
for (const [k, v] of matchedTrackCache) {
|
||
if (v.expiresAt <= now) matchedTrackCache.delete(k);
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
|
||
function parseDateParam(value: unknown): Date | null {
|
||
if (typeof value !== "string" || !value) return null;
|
||
const d = new Date(value);
|
||
return isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
function parseNum(value: unknown): number | null {
|
||
if (value === undefined || value === null || value === "") return null;
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
function badRequest(res: Response, error: string) {
|
||
return res.status(400).json({ success: false, error });
|
||
}
|
||
|
||
/**
|
||
* Мосты-предположения: если между двумя соседними по времени точными
|
||
* GPS-точками (якорями) есть хотя бы одна LBS-точка, строим между якорями
|
||
* маршрут по дорогам через OSRM Route API — целый трек без мусорных
|
||
* вышечных линий. Максимум 50 мостов на трек; OSRM недоступен — без мостов.
|
||
*/
|
||
const MAX_BRIDGES = 50;
|
||
const OSRM_ROUTE_TIMEOUT_MS = 3000;
|
||
|
||
interface TrackBridge {
|
||
points: [number, number][]; // [lat, lng]
|
||
distance: number;
|
||
}
|
||
|
||
async function buildBridges(points: Array<{ lat: number; lng: number; recordedAt: Date; isLbs: boolean }>): Promise<TrackBridge[]> {
|
||
// Якоря — точные точки (не LBS) по времени
|
||
const anchors = points.filter((p) => !p.isLbs);
|
||
const bridges: TrackBridge[] = [];
|
||
for (let i = 1; i < anchors.length && bridges.length < MAX_BRIDGES; i++) {
|
||
const a = anchors[i - 1];
|
||
const b = anchors[i];
|
||
const aTs = a.recordedAt.getTime();
|
||
const bTs = b.recordedAt.getTime();
|
||
// Мост нужен, только если между якорями есть хотя бы одна LBS-точка
|
||
const hasLbsBetween = points.some(
|
||
(p) => p.isLbs && p.recordedAt.getTime() > aTs && p.recordedAt.getTime() < bTs
|
||
);
|
||
if (!hasLbsBetween) continue;
|
||
try {
|
||
const url = `${OSRM_URL}/route/v1/driving/${a.lng},${a.lat};${b.lng},${b.lat}?overview=full&geometries=geojson`;
|
||
const res = await fetch(url, { signal: AbortSignal.timeout(OSRM_ROUTE_TIMEOUT_MS) });
|
||
if (!res.ok) continue;
|
||
const data = (await res.json()) as { code: string; routes?: Array<{ distance: number; geometry: { coordinates: [number, number][] } }> };
|
||
const route = data.routes?.[0];
|
||
if (data.code !== "Ok" || !route) continue;
|
||
bridges.push({
|
||
points: route.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
|
||
distance: Math.round(route.distance),
|
||
});
|
||
} catch {
|
||
// OSRM недоступен для этого моста — просто пропускаем
|
||
}
|
||
}
|
||
return bridges;
|
||
}
|
||
|
||
// Максимум LBS-точек, проверяемых offroad-фильтром при выдаче трека
|
||
// (бюджет ~5 сек при ~1-2 мс на OSRM nearest и конкуренции 10)
|
||
const MAX_LBS_OFFROAD_CHECK = 2000;
|
||
// Конкуренция запросов к OSRM nearest при выдаче трека
|
||
const OFFROAD_CHECK_CONCURRENCY = 10;
|
||
|
||
/**
|
||
* Простой p-limit без зависимостей: выполняет fn над элементами
|
||
* с ограничением числа одновременных вызовов.
|
||
*/
|
||
async function runWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||
let index = 0;
|
||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||
while (index < items.length) {
|
||
const item = items[index++];
|
||
await fn(item);
|
||
}
|
||
});
|
||
await Promise.all(workers);
|
||
}
|
||
|
||
// =====================
|
||
// Приём позиций от Traccar (публичный, по секрету)
|
||
// =====================
|
||
|
||
interface NormalizedPoint {
|
||
externalId: string;
|
||
lat: number;
|
||
lng: number;
|
||
speed: number | null;
|
||
course: number | null;
|
||
accuracy: number | null;
|
||
recordedAt: Date | null;
|
||
/** false только у нативного формата Traccar (position.valid === false) */
|
||
valid: boolean;
|
||
}
|
||
|
||
/**
|
||
* Нормализация одной точки. Поддерживает два формата:
|
||
* 1. Простой: { externalId, lat, lng, speed?, course?, accuracy?, timestamp? }
|
||
* 2. Нативный forward Traccar: { position: {...}, device: { uniqueId } }
|
||
*/
|
||
function normalizePoint(raw: any): NormalizedPoint | null {
|
||
if (!raw || typeof raw !== "object") return null;
|
||
|
||
// Нативный формат Traccar event forwarding
|
||
if (raw.position && raw.device?.uniqueId) {
|
||
const p = raw.position;
|
||
const lat = parseNum(p.latitude);
|
||
const lng = parseNum(p.longitude);
|
||
if (lat === null || lng === null) return null;
|
||
// Traccar отдаёт скорость в узлах (knots) — в БД храним км/ч
|
||
const speedKnots = parseNum(p.speed);
|
||
return {
|
||
externalId: String(raw.device.uniqueId),
|
||
lat,
|
||
lng,
|
||
speed: speedKnots !== null ? Math.round(speedKnots * 1.852 * 10) / 10 : null,
|
||
course: parseNum(p.course),
|
||
accuracy: parseNum(p.accuracy),
|
||
recordedAt: parseDateParam(p.fixTime ?? p.deviceTime),
|
||
valid: p.valid !== false,
|
||
};
|
||
}
|
||
|
||
// Простой формат
|
||
const lat = parseNum(raw.lat);
|
||
const lng = parseNum(raw.lng);
|
||
if (lat === null || lng === null || !raw.externalId) return null;
|
||
return {
|
||
externalId: String(raw.externalId),
|
||
lat,
|
||
lng,
|
||
speed: parseNum(raw.speed),
|
||
course: parseNum(raw.course),
|
||
accuracy: parseNum(raw.accuracy),
|
||
recordedAt: parseDateParam(raw.timestamp),
|
||
valid: true,
|
||
};
|
||
}
|
||
|
||
async function handleIngest(req: AuthenticatedRequest, res: Response) {
|
||
// Endpoint отключён, если секрет не задан в env
|
||
const expectedToken = process.env.GPS_INGEST_TOKEN;
|
||
if (!expectedToken) {
|
||
return res.status(503).json({ success: false, error: "GPS ingest отключён: не задан GPS_INGEST_TOKEN" });
|
||
}
|
||
|
||
const token = req.headers["x-gps-token"];
|
||
if (token !== expectedToken) {
|
||
return res.status(401).json({ success: false, error: "Неверный токен" });
|
||
}
|
||
|
||
const items = Array.isArray(req.body) ? req.body : [req.body];
|
||
let processed = 0;
|
||
let ignored = 0;
|
||
|
||
for (const item of items) {
|
||
const point = normalizePoint(item);
|
||
if (!point) {
|
||
ignored++;
|
||
continue;
|
||
}
|
||
|
||
// Объект ищется по external_id в любой организации;
|
||
// невалидный external_id не раскрываем — просто считаем проигнорированным
|
||
const asset = await gpsStorage.getAssetByExternalId(point.externalId);
|
||
if (!asset || !asset.isActive) {
|
||
ignored++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
await processPosition(asset.id, asset.organizationId, {
|
||
// Traccar пометил позицию как невалидную (valid=false) — протокольный
|
||
// признак «нет GPS-фикса»: сохраняем как примерную (LBS), в геозоны
|
||
// и пробег не пускаем
|
||
forceLbs: !point.valid,
|
||
lat: point.lat,
|
||
lng: point.lng,
|
||
speed: point.speed,
|
||
course: point.course,
|
||
accuracy: point.accuracy,
|
||
recordedAt: point.recordedAt ?? undefined,
|
||
});
|
||
processed++;
|
||
} catch (err) {
|
||
console.error(`[GPS] Ingest error (asset ${asset.id}):`, err);
|
||
ignored++;
|
||
}
|
||
}
|
||
|
||
res.json({ success: true, processed, ...(ignored > 0 ? { ignored } : {}) });
|
||
}
|
||
|
||
// =====================
|
||
// Авторизованные роуты
|
||
// =====================
|
||
|
||
export function registerGpsRoutes(app: Express) {
|
||
// Публичный приём точек — без authenticateToken, защита секретом в заголовке
|
||
app.post("/api/gps/ingest", (req, res) => {
|
||
handleIngest(req as AuthenticatedRequest, res).catch((err) => {
|
||
console.error("[GPS] Ingest handler error:", err);
|
||
res.status(500).json({ success: false, error: "Внутренняя ошибка" });
|
||
});
|
||
});
|
||
|
||
const router = Router();
|
||
router.use(authenticateToken);
|
||
|
||
/**
|
||
* Проверка доступа к GPS-вкладке (по настройкам организации).
|
||
* Применяется через router.use НИЖЕ — только к роутам вкладки;
|
||
* config и персональный трекинг (my-position/my-tracking) остаются открытыми.
|
||
*/
|
||
async function requireGpsAccess(req: AuthenticatedRequest, res: Response, next: () => void) {
|
||
const allowed = await hasGpsAccess(req.user!);
|
||
if (!allowed) {
|
||
return res.status(403).json({ success: false, error: "Нет доступа к GPS-модулю" });
|
||
}
|
||
next();
|
||
}
|
||
|
||
// =====================
|
||
// Конфигурация клиента (ключ Яндекс.Карт из external_services)
|
||
// Открыт всем — фронт использует allowed для гейтинга вкладки.
|
||
// =====================
|
||
|
||
router.get("/config", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
let yandexMapsKey: string | null = null;
|
||
|
||
const service = await storage.getExternalServiceByType("yandex_maps", orgId);
|
||
if (service?.isActive && service.apiKey) {
|
||
const { decrypt } = await import("../crypto");
|
||
yandexMapsKey = decrypt(service.apiKey);
|
||
}
|
||
|
||
const settings = await gpsStorage.getSettings(orgId);
|
||
const allowed = await hasGpsAccess(req.user!);
|
||
const defaultCenter =
|
||
settings.defaultLat !== null && settings.defaultLng !== null && settings.defaultZoom !== null
|
||
? { lat: settings.defaultLat, lng: settings.defaultLng, zoom: settings.defaultZoom }
|
||
: null;
|
||
|
||
res.json({ success: true, yandexMapsKey, allowed, defaultCenter });
|
||
});
|
||
|
||
// =====================
|
||
// PWA-трекинг (личная геолокация пользователя)
|
||
// Не про GPS-вкладку — доступ не проверяем.
|
||
// =====================
|
||
|
||
router.post("/my-position", async (req: AuthenticatedRequest, res: Response) => {
|
||
const user = req.user!;
|
||
const orgId = user.organizationId;
|
||
const { lat, lng, speed, course, accuracy } = req.body ?? {};
|
||
|
||
const latN = parseNum(lat);
|
||
const lngN = parseNum(lng);
|
||
if (latN === null || lngN === null) {
|
||
return badRequest(res, "Обязательные поля: lat, lng");
|
||
}
|
||
|
||
// Находим или создаём персональный объект пользователя
|
||
let asset = await gpsStorage.getPersonAssetByUser(user.id, orgId);
|
||
if (!asset) {
|
||
asset = await gpsStorage.createAsset({
|
||
organizationId: orgId,
|
||
name: `${user.firstName} ${user.lastName}`.trim() || user.email,
|
||
type: "person",
|
||
userId: user.id,
|
||
});
|
||
}
|
||
if (!asset.isActive) {
|
||
return res.status(403).json({ success: false, error: "Отслеживание геолокации выключено" });
|
||
}
|
||
|
||
await processPosition(asset.id, orgId, {
|
||
lat: latN,
|
||
lng: lngN,
|
||
speed: parseNum(speed),
|
||
course: parseNum(course),
|
||
accuracy: parseNum(accuracy),
|
||
});
|
||
|
||
res.json({ success: true, assetId: asset.id });
|
||
});
|
||
|
||
router.get("/my-tracking", async (req: AuthenticatedRequest, res: Response) => {
|
||
const user = req.user!;
|
||
const asset = await gpsStorage.getPersonAssetByUser(user.id, user.organizationId);
|
||
res.json({ success: true, enabled: Boolean(asset?.isActive), assetId: asset?.id ?? null });
|
||
});
|
||
|
||
router.put("/my-tracking", async (req: AuthenticatedRequest, res: Response) => {
|
||
const user = req.user!;
|
||
const orgId = user.organizationId;
|
||
const enabled = Boolean(req.body?.enabled);
|
||
|
||
let asset = await gpsStorage.getPersonAssetByUser(user.id, orgId);
|
||
if (!asset) {
|
||
if (!enabled) {
|
||
// Выключать нечего — объекта ещё нет
|
||
return res.json({ success: true, enabled: false, assetId: null });
|
||
}
|
||
asset = await gpsStorage.createAsset({
|
||
organizationId: orgId,
|
||
name: `${user.firstName} ${user.lastName}`.trim() || user.email,
|
||
type: "person",
|
||
userId: user.id,
|
||
isActive: true,
|
||
});
|
||
} else if (asset.isActive !== enabled) {
|
||
asset = (await gpsStorage.updateAsset(asset.id, orgId, { isActive: enabled }))!;
|
||
}
|
||
|
||
res.json({ success: true, enabled, assetId: asset.id });
|
||
});
|
||
|
||
// Все роуты ниже — только для пользователей с доступом к GPS-вкладке
|
||
router.use(requireGpsAccess);
|
||
|
||
// =====================
|
||
// Настройки GPS организации (фильтр джиттера)
|
||
// =====================
|
||
|
||
router.get("/settings", async (req: AuthenticatedRequest, res: Response) => {
|
||
const settings = await gpsStorage.getSettings(req.user!.organizationId);
|
||
res.json({ success: true, ...settings });
|
||
});
|
||
|
||
router.put("/settings", async (req: AuthenticatedRequest, res: Response) => {
|
||
// Обновляем только переданные поля (обратная совместимость с клиентом,
|
||
// который шлёт только часть настроек)
|
||
const current = await gpsStorage.getSettings(req.user!.organizationId);
|
||
const isAdmin = req.user!.appRole === "admin";
|
||
|
||
const jitterMeters =
|
||
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
|
||
const maxJumpSpeedKmh =
|
||
req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
|
||
const tripStopMinutes =
|
||
req.body?.tripStopMinutes !== undefined ? Number(req.body.tripStopMinutes) : current.tripStopMinutes;
|
||
|
||
if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) {
|
||
return badRequest(res, "jitterMeters: целое число от 0 до 1000");
|
||
}
|
||
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
|
||
return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
|
||
}
|
||
const gpsLostHours =
|
||
req.body?.gpsLostHours !== undefined ? Number(req.body.gpsLostHours) : current.gpsLostHours;
|
||
if (!Number.isInteger(tripStopMinutes) || tripStopMinutes < 0 || tripStopMinutes > 120) {
|
||
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
|
||
}
|
||
if (!Number.isInteger(gpsLostHours) || gpsLostHours < 1 || gpsLostHours > 72) {
|
||
return badRequest(res, "gpsLostHours: целое число от 1 до 72");
|
||
}
|
||
const offRoadMeters =
|
||
req.body?.offRoadMeters !== undefined ? Number(req.body.offRoadMeters) : current.offRoadMeters;
|
||
if (!Number.isInteger(offRoadMeters) || offRoadMeters < 0 || offRoadMeters > 2000) {
|
||
return badRequest(res, "offRoadMeters: целое число от 0 до 2000 (0 = фильтр выключен)");
|
||
}
|
||
|
||
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
|
||
let defaultLat = current.defaultLat;
|
||
let defaultLng = current.defaultLng;
|
||
let defaultZoom = current.defaultZoom;
|
||
|
||
if (isAdmin) {
|
||
// Либо все три поля заданы, либо все null (очистка)
|
||
const hasCenterField =
|
||
req.body?.defaultLat !== undefined || req.body?.defaultLng !== undefined || req.body?.defaultZoom !== undefined;
|
||
if (hasCenterField) {
|
||
const lat = req.body.defaultLat === null ? null : Number(req.body.defaultLat);
|
||
const lng = req.body.defaultLng === null ? null : Number(req.body.defaultLng);
|
||
const zoom = req.body.defaultZoom === null ? null : Number(req.body.defaultZoom);
|
||
|
||
if (lat === null && lng === null && zoom === null) {
|
||
defaultLat = defaultLng = defaultZoom = null;
|
||
} else {
|
||
if (lat === null || !Number.isFinite(lat) || lat < -90 || lat > 90) {
|
||
return badRequest(res, "defaultLat: число от -90 до 90");
|
||
}
|
||
if (lng === null || !Number.isFinite(lng) || lng < -180 || lng > 180) {
|
||
return badRequest(res, "defaultLng: число от -180 до 180");
|
||
}
|
||
if (zoom === null || !Number.isInteger(zoom) || zoom < 1 || zoom > 19) {
|
||
return badRequest(res, "defaultZoom: целое число от 1 до 19");
|
||
}
|
||
defaultLat = lat;
|
||
defaultLng = lng;
|
||
defaultZoom = zoom;
|
||
}
|
||
}
|
||
}
|
||
|
||
const row = await gpsStorage.upsertSettings(req.user!.organizationId, {
|
||
jitterMeters,
|
||
maxJumpSpeedKmh,
|
||
tripStopMinutes,
|
||
gpsLostHours,
|
||
offRoadMeters,
|
||
defaultLat,
|
||
defaultLng,
|
||
defaultZoom,
|
||
});
|
||
res.json({
|
||
success: true,
|
||
jitterMeters: row.jitterMeters,
|
||
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
|
||
tripStopMinutes: row.tripStopMinutes,
|
||
gpsLostHours: row.gpsLostHours,
|
||
offRoadMeters: row.offRoadMeters,
|
||
defaultLat: row.defaultLat,
|
||
defaultLng: row.defaultLng,
|
||
defaultZoom: row.defaultZoom,
|
||
});
|
||
});
|
||
|
||
// =====================
|
||
// Объекты (assets)
|
||
// =====================
|
||
|
||
router.get("/assets", async (req: AuthenticatedRequest, res: Response) => {
|
||
const assets = await gpsStorage.listAssets(req.user!.organizationId);
|
||
res.json({ success: true, assets });
|
||
});
|
||
|
||
router.post("/assets", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const { name, type, externalId, color, userId } = req.body ?? {};
|
||
|
||
if (!name || typeof name !== "string") {
|
||
return badRequest(res, "Название объекта обязательно");
|
||
}
|
||
if (type !== undefined && !["vehicle", "person"].includes(type)) {
|
||
return badRequest(res, "Тип объекта: vehicle или person");
|
||
}
|
||
|
||
const asset = await gpsStorage.createAsset({
|
||
organizationId: orgId,
|
||
name: name.trim(),
|
||
type: type === "person" ? "person" : "vehicle",
|
||
externalId: externalId ? String(externalId).trim() : null,
|
||
color: color || undefined,
|
||
userId: userId ?? null,
|
||
});
|
||
|
||
// Регистрируем устройство в Traccar (no-op, если Traccar не настроен)
|
||
if (asset.externalId) {
|
||
await ensureDevice(asset.externalId, asset.name);
|
||
}
|
||
|
||
res.status(201).json({ success: true, asset });
|
||
});
|
||
|
||
router.patch("/assets/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
const { name, type, externalId, color, isActive, userId } = req.body ?? {};
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = String(name).trim();
|
||
if (type !== undefined) {
|
||
if (!["vehicle", "person"].includes(type)) {
|
||
return badRequest(res, "Тип объекта: vehicle или person");
|
||
}
|
||
updates.type = type;
|
||
}
|
||
if (externalId !== undefined) updates.externalId = externalId ? String(externalId).trim() : null;
|
||
if (color !== undefined) updates.color = color;
|
||
if (isActive !== undefined) updates.isActive = Boolean(isActive);
|
||
if (userId !== undefined) updates.userId = userId ?? null;
|
||
|
||
const asset = await gpsStorage.updateAsset(id, orgId, updates);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
// Если появился/сменился external_id — регистрируем устройство в Traccar
|
||
if (updates.externalId && asset.externalId) {
|
||
await ensureDevice(asset.externalId, asset.name);
|
||
}
|
||
|
||
res.json({ success: true, asset });
|
||
});
|
||
|
||
router.delete("/assets/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
if (asset.externalId) {
|
||
await deleteDevice(asset.externalId);
|
||
}
|
||
|
||
await gpsStorage.deleteAsset(id, orgId);
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// =====================
|
||
// Позиции и треки
|
||
// =====================
|
||
|
||
// Текущие last_* всех активных объектов организации
|
||
router.get("/assets/positions", async (req: AuthenticatedRequest, res: Response) => {
|
||
const assets = await gpsStorage.listCurrentPositions(req.user!.organizationId);
|
||
res.json({
|
||
success: true,
|
||
positions: assets.map((a) => ({
|
||
assetId: a.id,
|
||
name: a.name,
|
||
type: a.type,
|
||
color: a.color,
|
||
isOnline: a.isOnline,
|
||
lat: a.lastLat,
|
||
lng: a.lastLng,
|
||
speed: a.lastSpeed,
|
||
course: a.lastCourse,
|
||
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
|
||
lastSeenAt: a.lastSeenAt,
|
||
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
|
||
lastGpsFixAt: a.lastGpsFixAt, // время последнего GPS-фикса (для индикатора потери спутников)
|
||
gpsLost: a.gpsLost, // режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS
|
||
lastIsLbs: a.lastIsLbs, // последняя точка — координаты вышки (примерная позиция)
|
||
})),
|
||
});
|
||
});
|
||
|
||
// Трек объекта за период (точки ASC; при > 2000 — прореживание)
|
||
router.get("/assets/:id/track", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
const from = parseDateParam(req.query.from);
|
||
const to = parseDateParam(req.query.to);
|
||
|
||
const all = await gpsStorage.getTrack(id, orgId, from ?? undefined, to ?? undefined);
|
||
const total = all.length;
|
||
|
||
// Фильтрация сохранённых данных при выдаче (без удаления из БД):
|
||
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила
|
||
// с гистерезисом подтверждения, что при приёме (общий walker по точкам)
|
||
const settings = await gpsStorage.getSettings(orgId);
|
||
const { points: filtered, filteredCount } = filterTrackPoints(all, settings);
|
||
|
||
// Offroad-фильтр при выдаче: точки далеко от дорог — выкидываем через
|
||
// OSRM nearest. Проверяются ВСЕ точки, а не только isLbs: «легализованный»
|
||
// гистерезисом стабильный LBS идёт с is_lbs=false и обходит фильтр приёма,
|
||
// а настоящий GPS всё равно на дороге (замерено: p90 = 7 м). 0 = выкл.
|
||
let points = filtered;
|
||
let offroadFiltered = 0;
|
||
if (settings.offRoadMeters > 0) {
|
||
let lbsPoints = points;
|
||
if (lbsPoints.length > MAX_LBS_OFFROAD_CHECK) {
|
||
// Бюджет времени ~5 сек: прореживаем точки равномерно до лимита;
|
||
// не попавшие в проверку остаются в выдаче как есть
|
||
const step = Math.ceil(lbsPoints.length / MAX_LBS_OFFROAD_CHECK);
|
||
const thinned = lbsPoints.filter((_, idx) => idx % step === 0);
|
||
console.log(
|
||
`[GPS] track offroad: asset=${id} точек ${lbsPoints.length} > ${MAX_LBS_OFFROAD_CHECK}, проверяем ${thinned.length} (прорежено)`
|
||
);
|
||
lbsPoints = thinned;
|
||
}
|
||
if (lbsPoints.length > 0) {
|
||
const dropped = new Set<(typeof lbsPoints)[number]>();
|
||
await runWithConcurrency(lbsPoints, OFFROAD_CHECK_CONCURRENCY, async (p) => {
|
||
const dist = await offRoadDistance(p.lat, p.lng, settings.offRoadMeters);
|
||
if (dist !== null) dropped.add(p);
|
||
});
|
||
if (dropped.size > 0) {
|
||
offroadFiltered = dropped.size;
|
||
points = points.filter((p) => !dropped.has(p));
|
||
}
|
||
}
|
||
}
|
||
|
||
let sampled = false;
|
||
if (points.length > MAX_TRACK_POINTS) {
|
||
// Точки ДО прореживания — для гарантии включения последней точки трека
|
||
const beforeSample = points;
|
||
const step = Math.ceil(points.length / MAX_TRACK_POINTS);
|
||
points = points.filter((_, idx) => idx % step === 0);
|
||
// Всегда включаем последнюю точку трека
|
||
if (points[points.length - 1] !== beforeSample[beforeSample.length - 1]) {
|
||
points.push(beforeSample[beforeSample.length - 1]);
|
||
}
|
||
sampled = true;
|
||
}
|
||
|
||
// Привязка трека к дорогам через OSRM (?matched=1): только точные
|
||
// GPS-точки (isLbs исключаем). При недоступности OSRM — matched: null,
|
||
// endpoint не падает.
|
||
let matched: MatchedTrack | null | undefined;
|
||
if (req.query.matched === "1") {
|
||
const gpsOnly = points.filter((p) => !p.isLbs);
|
||
matched = gpsOnly.length >= 2 ? await matchTrackCached(id, from, to, gpsOnly) : null;
|
||
}
|
||
|
||
// Мосты-предположения по дорогам между GPS-якорями на LBS-участках
|
||
const bridges = await buildBridges(points);
|
||
|
||
res.json({
|
||
success: true,
|
||
points,
|
||
total,
|
||
filteredCount,
|
||
offroadFiltered,
|
||
sampled,
|
||
...(matched !== undefined ? { matched } : {}),
|
||
...(bridges.length > 0 ? { bridges } : {}),
|
||
});
|
||
});
|
||
|
||
// =====================
|
||
// Геозоны
|
||
// =====================
|
||
|
||
router.get("/geozones", async (req: AuthenticatedRequest, res: Response) => {
|
||
const geozones = await gpsStorage.listGeozones(req.user!.organizationId);
|
||
res.json({ success: true, geozones });
|
||
});
|
||
|
||
router.post("/geozones", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const { name, polygon, color, notifyEnter, notifyExit } = req.body ?? {};
|
||
|
||
if (!name || typeof name !== "string") {
|
||
return badRequest(res, "Название геозоны обязательно");
|
||
}
|
||
if (!isValidPolygon(polygon)) {
|
||
return badRequest(res, "Полигон: массив минимум из 3 пар [lat, lng]");
|
||
}
|
||
|
||
const geozone = await gpsStorage.createGeozone({
|
||
organizationId: orgId,
|
||
name: name.trim(),
|
||
polygon,
|
||
color: color || undefined,
|
||
notifyEnter: notifyEnter ?? undefined,
|
||
notifyExit: notifyExit ?? undefined,
|
||
});
|
||
|
||
res.status(201).json({ success: true, geozone });
|
||
});
|
||
|
||
router.patch("/geozones/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
const { name, polygon, color, notifyEnter, notifyExit, isActive } = req.body ?? {};
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = String(name).trim();
|
||
if (polygon !== undefined) {
|
||
if (!isValidPolygon(polygon)) {
|
||
return badRequest(res, "Полигон: массив минимум из 3 пар [lat, lng]");
|
||
}
|
||
updates.polygon = polygon;
|
||
}
|
||
if (color !== undefined) updates.color = color;
|
||
if (notifyEnter !== undefined) updates.notifyEnter = Boolean(notifyEnter);
|
||
if (notifyExit !== undefined) updates.notifyExit = Boolean(notifyExit);
|
||
if (isActive !== undefined) updates.isActive = Boolean(isActive);
|
||
|
||
const geozone = await gpsStorage.updateGeozone(id, orgId, updates);
|
||
if (!geozone) return res.status(404).json({ success: false, error: "Геозона не найдена" });
|
||
|
||
res.json({ success: true, geozone });
|
||
});
|
||
|
||
router.delete("/geozones/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const deleted = await gpsStorage.deleteGeozone(Number(req.params.id), req.user!.organizationId);
|
||
if (!deleted) return res.status(404).json({ success: false, error: "Геозона не найдена" });
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// События входа/выхода из геозон
|
||
router.get("/geozone-events", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const events = await gpsStorage.listGeozoneEvents(orgId, {
|
||
assetId: req.query.assetId ? Number(req.query.assetId) : undefined,
|
||
geozoneId: req.query.geozoneId ? Number(req.query.geozoneId) : undefined,
|
||
from: parseDateParam(req.query.from) ?? undefined,
|
||
to: parseDateParam(req.query.to) ?? undefined,
|
||
limit: req.query.limit ? Math.min(Number(req.query.limit), 1000) : undefined,
|
||
});
|
||
res.json({ success: true, events });
|
||
});
|
||
|
||
// =====================
|
||
// Группы
|
||
// =====================
|
||
|
||
router.get("/groups", async (req: AuthenticatedRequest, res: Response) => {
|
||
const groups = await gpsStorage.listGroups(req.user!.organizationId);
|
||
res.json({ success: true, groups });
|
||
});
|
||
|
||
router.post("/groups", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const { name, assetIds } = req.body ?? {};
|
||
|
||
if (!name || typeof name !== "string") {
|
||
return badRequest(res, "Название группы обязательно");
|
||
}
|
||
|
||
const group = await gpsStorage.createGroup(
|
||
orgId,
|
||
name.trim(),
|
||
Array.isArray(assetIds) ? assetIds.map(Number).filter(Number.isFinite) : []
|
||
);
|
||
res.status(201).json({ success: true, group });
|
||
});
|
||
|
||
router.patch("/groups/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
const { name, assetIds } = req.body ?? {};
|
||
|
||
const group = await gpsStorage.updateGroup(id, orgId, {
|
||
name: name !== undefined ? String(name).trim() : undefined,
|
||
assetIds: Array.isArray(assetIds) ? assetIds.map(Number).filter(Number.isFinite) : undefined,
|
||
});
|
||
if (!group) return res.status(404).json({ success: false, error: "Группа не найдена" });
|
||
|
||
res.json({ success: true, group });
|
||
});
|
||
|
||
router.delete("/groups/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||
const deleted = await gpsStorage.deleteGroup(Number(req.params.id), req.user!.organizationId);
|
||
if (!deleted) return res.status(404).json({ success: false, error: "Группа не найдена" });
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// =====================
|
||
// Подписчики объекта
|
||
// =====================
|
||
|
||
router.get("/assets/:id/subscribers", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
const subscribers = await gpsStorage.listSubscribers(id);
|
||
res.json({ success: true, subscribers });
|
||
});
|
||
|
||
// Полная замена списка подписчиков: [{ userId, onEnter, onExit, onOffline }]
|
||
router.put("/assets/:id/subscribers", async (req: AuthenticatedRequest, res: Response) => {
|
||
const orgId = req.user!.organizationId;
|
||
const id = Number(req.params.id);
|
||
|
||
const asset = await gpsStorage.getAsset(id, orgId);
|
||
if (!asset) return res.status(404).json({ success: false, error: "Объект не найден" });
|
||
|
||
const list = Array.isArray(req.body) ? req.body : req.body?.subscribers;
|
||
if (!Array.isArray(list)) {
|
||
return badRequest(res, "Ожидается массив подписчиков [{ userId, onEnter, onExit, onOffline }]");
|
||
}
|
||
|
||
const subscribers = list.map((s: any) => ({
|
||
userId: Number(s.userId),
|
||
onEnter: Boolean(s.onEnter ?? true),
|
||
onExit: Boolean(s.onExit ?? true),
|
||
onOffline: Boolean(s.onOffline ?? false),
|
||
})).filter((s: { userId: number }) => Number.isFinite(s.userId));
|
||
|
||
const result = await gpsStorage.replaceSubscribers(id, subscribers);
|
||
res.json({ success: true, subscribers: result });
|
||
});
|
||
|
||
app.use("/api/gps", router);
|
||
}
|
||
|
||
function isValidPolygon(polygon: unknown): polygon is Array<[number, number]> {
|
||
return (
|
||
Array.isArray(polygon) &&
|
||
polygon.length >= 3 &&
|
||
polygon.every(
|
||
(p) => Array.isArray(p) && p.length === 2 && Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1]))
|
||
)
|
||
);
|
||
}
|