feat(gps): фильтры heartbeat-эхо/valid=false/spike; доступ к GPS и Финансам — матрица на /users (пользователи+роли)
This commit is contained in:
@@ -1,33 +1,13 @@
|
||||
import { gpsStorage } from "./storage";
|
||||
import { hasModuleAccess, type ModuleAccessUser } from "../utils/module-access";
|
||||
|
||||
/**
|
||||
* Контроль доступа к GPS-вкладке.
|
||||
*
|
||||
* Правило:
|
||||
* - admin приложения (users.app_role = 'admin') — доступ всегда;
|
||||
* - если allowed_user_ids и allowed_role_ids оба пустые/NULL — доступ у всех;
|
||||
* - иначе — если user.id ∈ allowedUserIds ИЛИ пользователь состоит
|
||||
* в организационной роли из allowedRoleIds (таблицы roles/role_members).
|
||||
* Правило: users.gps_access OR любая организационная роль пользователя
|
||||
* с gps_access OR appRole = 'admin' (см. utils/module-access).
|
||||
* Раньше использовались gps_settings.allowed_user_ids/allowed_role_ids —
|
||||
* колонки остались в БД, но больше не читаются.
|
||||
*/
|
||||
export async function hasGpsAccess(
|
||||
user: { id: number; organizationId: number; appRole?: string },
|
||||
settings?: Awaited<ReturnType<typeof gpsStorage.getSettings>>
|
||||
): Promise<boolean> {
|
||||
if (user.appRole === "admin") return true;
|
||||
|
||||
const s = settings ?? (await gpsStorage.getSettings(user.organizationId));
|
||||
const userIds = s.allowedUserIds ?? [];
|
||||
const roleIds = s.allowedRoleIds ?? [];
|
||||
|
||||
// Оба списка пустые — модуль открыт всем
|
||||
if (userIds.length === 0 && roleIds.length === 0) return true;
|
||||
|
||||
if (userIds.includes(user.id)) return true;
|
||||
|
||||
if (roleIds.length > 0) {
|
||||
const userRoleIds = await gpsStorage.getUserRoleIds(user.id, user.organizationId);
|
||||
if (userRoleIds.some((id) => roleIds.includes(id))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
export async function hasGpsAccess(user: ModuleAccessUser): Promise<boolean> {
|
||||
return hasModuleAccess(user, "gps");
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface ProcessPositionInput {
|
||||
}
|
||||
|
||||
// Вердикт фильтрации точки относительно предыдущей принятой
|
||||
export type PointVerdict = "accept" | "stale" | "jump";
|
||||
export type PointVerdict = "accept" | "stale" | "jump" | "echo";
|
||||
|
||||
export interface GpsFilterSettings {
|
||||
jitterMeters: number;
|
||||
@@ -64,12 +64,17 @@ interface PrevPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
recordedAt: Date;
|
||||
/** Скорость, км/ч (null — неизвестна) */
|
||||
speed?: number | null;
|
||||
}
|
||||
|
||||
// Сколько точек подряд около новой локации нужно, чтобы подтвердить смену реальности
|
||||
const CANDIDATE_CONFIRM_COUNT = 3;
|
||||
// Время жизни кандидата на смену локации (по времени точек)
|
||||
const CANDIDATE_TTL_MS = 30 * 60 * 1000;
|
||||
// Окно heartbeat-эхо: статусная точка считается эхом стоянки, если последняя
|
||||
// принятая точка «в движении» (speed > 10 км/ч) была меньше 5 минут назад
|
||||
const ECHO_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
interface JumpCandidate {
|
||||
lat: number;
|
||||
@@ -79,7 +84,7 @@ interface JumpCandidate {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful-фильтр потока точек (stale/jump) с гистерезисом подтверждения.
|
||||
* Stateful-фильтр потока точек (stale/jump/echo) с гистерезисом подтверждения.
|
||||
*
|
||||
* Проблема latch-up: если последняя принятая точка — стабильный LBS
|
||||
* (идёт каждую минуту с одной вышки), то при восстановлении GPS реальные
|
||||
@@ -90,18 +95,30 @@ interface JumpCandidate {
|
||||
* Прыжки между разными вышками (туда-сюда) никогда не подтверждаются.
|
||||
* Кандидат живёт CANDIDATE_TTL_MS с первой точки, потом сбрасывается.
|
||||
*
|
||||
* Heartbeat-эхо: трекер шлёт поминутные «статусные» точки (speed≈0),
|
||||
* повторяющие координаты стоянки, даже когда объект едет. Walker помнит
|
||||
* последний кластер стоянки (lastStationary) и время последней принятой
|
||||
* точки в движении (lastMovingAt). Точка с speed < 1 км/ч рядом со стоянкой
|
||||
* в пределах ECHO_WINDOW_MS после движения — эхо, отбрасывается. Если движения
|
||||
* нет дольше окна — правило отпускает и стоянка принимается (это корректно).
|
||||
*
|
||||
* Используется и при приёме (processPosition, per-asset in-memory),
|
||||
* и при отдаче трека (filterTrackPoints).
|
||||
*/
|
||||
export class JumpFilterWalker {
|
||||
private prev: PrevPoint | null;
|
||||
private candidate: JumpCandidate | null = null;
|
||||
// Последняя «стоянка»: кластер принятых точек с speed≈0
|
||||
private lastStationary: { lat: number; lng: number } | null = null;
|
||||
// recordedAt последней принятой точки «в движении» (speed > 10 км/ч), ms
|
||||
private lastMovingAt: number | null = null;
|
||||
|
||||
constructor(
|
||||
private settings: GpsFilterSettings,
|
||||
initialPrev: PrevPoint | null = null
|
||||
) {
|
||||
this.prev = initialPrev;
|
||||
if (initialPrev) this.noteMotion(initialPrev);
|
||||
}
|
||||
|
||||
/** Обновление настроек «на лету» (для долгоживущих per-asset walker'ов) */
|
||||
@@ -109,6 +126,17 @@ export class JumpFilterWalker {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/** Учёт принятой точки в состоянии стоянки/движения */
|
||||
private noteMotion(point: PrevPoint): void {
|
||||
const speed = point.speed;
|
||||
if (speed === null || speed === undefined) return;
|
||||
if (speed < 1) {
|
||||
this.lastStationary = { lat: point.lat, lng: point.lng };
|
||||
} else if (speed > 10) {
|
||||
this.lastMovingAt = point.recordedAt.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка очередной точки. При 'accept' walker обновляет prev —
|
||||
* вызывающий код должен обработать точку как валидную.
|
||||
@@ -119,10 +147,24 @@ export class JumpFilterWalker {
|
||||
return "stale";
|
||||
}
|
||||
|
||||
// Heartbeat-эхо: статусная точка повторяет координаты стоянки на ходу
|
||||
const speed = point.speed;
|
||||
if (
|
||||
speed !== null && speed !== undefined && speed < 1 &&
|
||||
this.lastStationary &&
|
||||
this.lastMovingAt !== null &&
|
||||
point.recordedAt.getTime() - this.lastMovingAt < ECHO_WINDOW_MS &&
|
||||
haversineMeters(this.lastStationary.lat, this.lastStationary.lng, point.lat, point.lng) <
|
||||
Math.max(this.settings.jitterMeters, 50)
|
||||
) {
|
||||
return "echo";
|
||||
}
|
||||
|
||||
if (!this.isJump(point)) {
|
||||
// Обычный путь — кандидат не нужен
|
||||
this.candidate = null;
|
||||
this.prev = point;
|
||||
this.noteMotion(point);
|
||||
return "accept";
|
||||
}
|
||||
|
||||
@@ -145,6 +187,7 @@ export class JumpFilterWalker {
|
||||
// Три подряд точки около новой локации — новая реальность, принимаем
|
||||
this.candidate = null;
|
||||
this.prev = point;
|
||||
this.noteMotion(point);
|
||||
return "accept";
|
||||
}
|
||||
return "jump";
|
||||
@@ -172,9 +215,10 @@ export class JumpFilterWalker {
|
||||
|
||||
/**
|
||||
* Фильтрация сохранённого трека при выдаче (без удаления из БД):
|
||||
* те же правила stale/jump с подтверждением кандидата, что и при приёме.
|
||||
* те же правила stale/jump/echo с подтверждением кандидата, что и при приёме,
|
||||
* плюс spike-фильтр с lookahead (эхо стоянки между движущимися точками).
|
||||
*/
|
||||
export function filterTrackPoints<T extends { lat: number; lng: number; recordedAt: Date }>(
|
||||
export function filterTrackPoints<T extends { lat: number; lng: number; recordedAt: Date; speed?: number | null }>(
|
||||
points: T[],
|
||||
settings: GpsFilterSettings
|
||||
): { points: T[]; filteredCount: number } {
|
||||
@@ -182,13 +226,38 @@ export function filterTrackPoints<T extends { lat: number; lng: number; recorded
|
||||
const accepted: T[] = [];
|
||||
let filteredCount = 0;
|
||||
for (const p of points) {
|
||||
if (walker.check({ lat: p.lat, lng: p.lng, recordedAt: p.recordedAt }) === "accept") {
|
||||
if (walker.check({ lat: p.lat, lng: p.lng, recordedAt: p.recordedAt, speed: p.speed }) === "accept") {
|
||||
accepted.push(p);
|
||||
} else {
|
||||
filteredCount++;
|
||||
}
|
||||
}
|
||||
return { points: accepted, filteredCount };
|
||||
|
||||
// Spike-фильтр с lookahead: точка-«выброс» к координатам стоянки между
|
||||
// движущимися точками (dist(prev,cur) большая, а следующая точка снова
|
||||
// рядом с prev, сама точка почти стоит, соседи едут)
|
||||
const out: T[] = [];
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const cur = accepted[i];
|
||||
const prev = out[out.length - 1];
|
||||
const next = accepted[i + 1];
|
||||
if (prev && next) {
|
||||
const dPrevCur = haversineMeters(prev.lat, prev.lng, cur.lat, cur.lng);
|
||||
const dPrevNext = haversineMeters(prev.lat, prev.lng, next.lat, next.lng);
|
||||
if (
|
||||
dPrevCur > 250 &&
|
||||
dPrevNext < dPrevCur / 2 &&
|
||||
(cur.speed ?? 999) < 2 &&
|
||||
((prev.speed ?? 0) > 10 || (next.speed ?? 0) > 10)
|
||||
) {
|
||||
filteredCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
}
|
||||
|
||||
return { points: out, filteredCount };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,17 +308,22 @@ export async function processPosition(
|
||||
// Предыдущая принятая точка объекта (если есть) — для инициализации walker'а
|
||||
const prev: PrevPoint | null =
|
||||
asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null
|
||||
? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt }
|
||||
? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt, speed: asset.lastSpeed }
|
||||
: null;
|
||||
|
||||
// Фильтры stale/jump с гистерезисом подтверждения (устаревшие копии и LBS-скачки)
|
||||
// Фильтры stale/jump/echo с гистерезисом подтверждения (устаревшие копии,
|
||||
// LBS-скачки, heartbeat-эхо стоянки)
|
||||
const walker = getAssetWalker(assetId, prev, settings);
|
||||
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt });
|
||||
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt, speed: input.speed });
|
||||
if (verdict !== "accept") {
|
||||
if (verdict === "stale") {
|
||||
console.log(
|
||||
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
|
||||
);
|
||||
} else if (verdict === "echo") {
|
||||
console.log(
|
||||
`[GPS] skipped echo: asset=${assetId} heartbeat-точка повторяет координаты стоянки (speed=${input.speed ?? "—"} км/ч)`
|
||||
);
|
||||
} else {
|
||||
const dist = prev ? haversineMeters(prev.lat, prev.lng, input.lat, input.lng) : 0;
|
||||
const dtSec = prev ? (recordedAt.getTime() - prev.recordedAt.getTime()) / 1000 : 0;
|
||||
|
||||
@@ -43,6 +43,8 @@ interface NormalizedPoint {
|
||||
course: number | null;
|
||||
accuracy: number | null;
|
||||
recordedAt: Date | null;
|
||||
/** false только у нативного формата Traccar (position.valid === false) */
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,7 @@ function normalizePoint(raw: any): NormalizedPoint | null {
|
||||
course: parseNum(p.course),
|
||||
accuracy: parseNum(p.accuracy),
|
||||
recordedAt: parseDateParam(p.fixTime ?? p.deviceTime),
|
||||
valid: p.valid !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ function normalizePoint(raw: any): NormalizedPoint | null {
|
||||
course: parseNum(raw.course),
|
||||
accuracy: parseNum(raw.accuracy),
|
||||
recordedAt: parseDateParam(raw.timestamp),
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +122,14 @@ async function handleIngest(req: AuthenticatedRequest, res: Response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Traccar пометил позицию как невалидную (valid=false) — не сохраняем,
|
||||
// но считаем признаком жизни устройства
|
||||
if (!point.valid) {
|
||||
await gpsStorage.touchAssetSeen(asset.id);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await processPosition(asset.id, asset.organizationId, {
|
||||
lat: point.lat,
|
||||
@@ -182,7 +194,7 @@ export function registerGpsRoutes(app: Express) {
|
||||
}
|
||||
|
||||
const settings = await gpsStorage.getSettings(orgId);
|
||||
const allowed = await hasGpsAccess(req.user!, settings);
|
||||
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 }
|
||||
@@ -298,15 +310,13 @@ export function registerGpsRoutes(app: Express) {
|
||||
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
|
||||
}
|
||||
|
||||
// Поля центра карты и доступа — только для admin (у остальных молча игнорируются)
|
||||
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
|
||||
let defaultLat = current.defaultLat;
|
||||
let defaultLng = current.defaultLng;
|
||||
let defaultZoom = current.defaultZoom;
|
||||
let allowedUserIds = current.allowedUserIds;
|
||||
let allowedRoleIds = current.allowedRoleIds;
|
||||
|
||||
if (isAdmin) {
|
||||
// Центр по умолчанию: либо все три поля заданы, либо все null (очистка)
|
||||
// Либо все три поля заданы, либо все null (очистка)
|
||||
const hasCenterField =
|
||||
req.body?.defaultLat !== undefined || req.body?.defaultLng !== undefined || req.body?.defaultZoom !== undefined;
|
||||
if (hasCenterField) {
|
||||
@@ -331,26 +341,6 @@ export function registerGpsRoutes(app: Express) {
|
||||
defaultZoom = zoom;
|
||||
}
|
||||
}
|
||||
|
||||
// Списки доступа: массивы целых чисел или null (очистка)
|
||||
if (req.body?.allowedUserIds !== undefined) {
|
||||
if (req.body.allowedUserIds === null) {
|
||||
allowedUserIds = null;
|
||||
} else if (!isIntArray(req.body.allowedUserIds)) {
|
||||
return badRequest(res, "allowedUserIds: массив целых чисел или null");
|
||||
} else {
|
||||
allowedUserIds = req.body.allowedUserIds;
|
||||
}
|
||||
}
|
||||
if (req.body?.allowedRoleIds !== undefined) {
|
||||
if (req.body.allowedRoleIds === null) {
|
||||
allowedRoleIds = null;
|
||||
} else if (!isIntArray(req.body.allowedRoleIds)) {
|
||||
return badRequest(res, "allowedRoleIds: массив целых чисел или null");
|
||||
} else {
|
||||
allowedRoleIds = req.body.allowedRoleIds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const row = await gpsStorage.upsertSettings(req.user!.organizationId, {
|
||||
@@ -360,8 +350,6 @@ export function registerGpsRoutes(app: Express) {
|
||||
defaultLat,
|
||||
defaultLng,
|
||||
defaultZoom,
|
||||
allowedUserIds,
|
||||
allowedRoleIds,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -371,8 +359,6 @@ export function registerGpsRoutes(app: Express) {
|
||||
defaultLat: row.defaultLat,
|
||||
defaultLng: row.defaultLng,
|
||||
defaultZoom: row.defaultZoom,
|
||||
allowedUserIds: row.allowedUserIds,
|
||||
allowedRoleIds: row.allowedRoleIds,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -689,7 +675,3 @@ function isValidPolygon(polygon: unknown): polygon is Array<[number, number]> {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isIntArray(value: unknown): value is number[] {
|
||||
return Array.isArray(value) && value.every((v) => Number.isInteger(v));
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
gpsGroupAssets,
|
||||
gpsAssetSubscribers,
|
||||
gpsSettings,
|
||||
roles,
|
||||
roleMembers,
|
||||
type GpsAsset,
|
||||
type GpsPosition,
|
||||
type GpsGeozone,
|
||||
@@ -471,8 +469,6 @@ export class GpsStorage {
|
||||
defaultLat: number | null;
|
||||
defaultLng: number | null;
|
||||
defaultZoom: number | null;
|
||||
allowedUserIds: number[] | null;
|
||||
allowedRoleIds: number[] | null;
|
||||
}> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
@@ -485,8 +481,6 @@ export class GpsStorage {
|
||||
defaultLat: row?.defaultLat ?? null,
|
||||
defaultLng: row?.defaultLng ?? null,
|
||||
defaultZoom: row?.defaultZoom ?? null,
|
||||
allowedUserIds: row?.allowedUserIds ?? null,
|
||||
allowedRoleIds: row?.allowedRoleIds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -499,8 +493,6 @@ export class GpsStorage {
|
||||
defaultLat: number | null;
|
||||
defaultLng: number | null;
|
||||
defaultZoom: number | null;
|
||||
allowedUserIds: number[] | null;
|
||||
allowedRoleIds: number[] | null;
|
||||
}
|
||||
): Promise<GpsSettings> {
|
||||
const [row] = await db
|
||||
@@ -513,16 +505,6 @@ export class GpsStorage {
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Организационные роли пользователя (через role_members) в рамках организации */
|
||||
async getUserRoleIds(userId: number, organizationId: number): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ roleId: roleMembers.roleId })
|
||||
.from(roleMembers)
|
||||
.innerJoin(roles, eq(roleMembers.roleId, roles.id))
|
||||
.where(and(eq(roleMembers.userId, userId), eq(roles.organizationId, organizationId)));
|
||||
return rows.map((r) => r.roleId);
|
||||
}
|
||||
}
|
||||
|
||||
export const gpsStorage = new GpsStorage();
|
||||
|
||||
Reference in New Issue
Block a user