Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
75 lines
3.7 KiB
Python
75 lines
3.7 KiB
Python
"""Глобально уникальные идентификаторы сущностей: <префикс>_<uuid7>.
|
||
|
||
Пример: dev_0192f3a1-7c4e-7b1d-9a3f-5e2c1d0b8a47.
|
||
- Между типами — префикс: ID разных сущностей не пересекаются по построению.
|
||
- Внутри типа — UUIDv7 (RFC 9562): 48 бит времени (мс) + 12-битный счётчик + 62 случайных бита.
|
||
Внутри процесса ID строго возрастают (в том числе при откате часов), поэтому сортировка по ID
|
||
совпадает с порядком создания; между процессами уникальность держится на 62 случайных битах и PRIMARY KEY.
|
||
- ID не переиспользуются: значение не зависит от того, какие записи уже удалены.
|
||
"""
|
||
import re
|
||
import secrets
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
|
||
PREFIXES = {
|
||
"dev": "устройство",
|
||
"grp": "группа",
|
||
"job": "задача",
|
||
"bkp": "резервная копия",
|
||
"evt": "запись журнала",
|
||
}
|
||
_TAIL = r"[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||
_lock = threading.Lock()
|
||
_last_ms = 0
|
||
_seq = 0
|
||
_hist: dict[int, int] = {} # счётчики для «исторических» меток времени (миграция)
|
||
|
||
|
||
def _build(ms: int, seq: int) -> uuid.UUID:
|
||
rand_b = secrets.randbits(62)
|
||
return uuid.UUID(int=(ms << 80) | (0x7 << 76) | (seq << 64) | (0b10 << 62) | rand_b)
|
||
|
||
|
||
def new_id(prefix: str, at: datetime | None = None) -> str:
|
||
"""Новый ID. at — момент создания записи (для миграции старых данных: сохраняет порядок)."""
|
||
global _last_ms, _seq
|
||
if prefix not in PREFIXES:
|
||
raise ValueError(f"Неизвестный префикс ID: {prefix}")
|
||
with _lock:
|
||
if at is not None:
|
||
ms = int(at.replace(tzinfo=at.tzinfo or timezone.utc).timestamp() * 1000)
|
||
_hist[ms] = _hist.get(ms, -1) + 1
|
||
return f"{prefix}_{_build(ms, min(_hist[ms], 0xFFF))}"
|
||
now_ms = time.time_ns() // 1_000_000
|
||
if now_ms > _last_ms:
|
||
_last_ms, _seq = now_ms, secrets.randbits(11) # случайный старт: ≥2048 ID на миллисекунду
|
||
else: # тот же миллисекунда или часы ушли назад — время не уменьшаем
|
||
_seq += 1
|
||
if _seq > 0xFFF:
|
||
_last_ms, _seq = _last_ms + 1, 0
|
||
return f"{prefix}_{_build(_last_ms, _seq)}"
|
||
|
||
|
||
def is_id(value: object, prefix: str | None = None) -> bool:
|
||
"""Строка похожа на ID (нужного типа, если prefix задан)."""
|
||
if not isinstance(value, str):
|
||
return False
|
||
pat = rf"({'|'.join(PREFIXES)})_{_TAIL}" if prefix is None else rf"{prefix}_{_TAIL}"
|
||
return re.fullmatch(pat, value) is not None
|
||
|
||
|
||
def check(value: str, prefix: str) -> str:
|
||
"""Проверка ID из пути/запроса: чужой тип или мусор — LookupError (в API это 404 с пояснением)."""
|
||
if not is_id(value, prefix):
|
||
hint = f"ожидается {PREFIXES[prefix]} ({prefix}_…)"
|
||
raise LookupError(f"Неверный идентификатор «{str(value)[:60]}»: {hint}")
|
||
return value
|
||
|
||
|
||
def short_id(value: str | None) -> str:
|
||
"""Короткий вид для таблиц: последние 8 символов (случайная часть); полный ID — в подсказке."""
|
||
return (value or "")[-8:]
|