Технические детали и особенности реализации.
Проблема: python-jose требует JWT sub поле как string, но мы используем integer ID.
Решение:
# При создании токена
if "sub" in to_encode and isinstance(to_encode["sub"], int):
to_encode["sub"] = str(to_encode["sub"])
# При парсинге
user_id_str: str = payload.get("sub")
user_id = int(user_id_str)
Файлы:
app/core/security.py:26-27 - конвертация при созданииapp/api/deps.py:48-62 - парсинг обратно в intПроблема: Нужны разные типы токенов (access, refresh, device).
Решение: Добавлено поле type в JWT payload:
# Access token для пользователей
{"sub": "user_id", "type": "access", "exp": ...}
# Refresh token
{"sub": "user_id", "type": "refresh", "exp": ...}
# Device token
{"sub": "device_id", "type": "device", "mac": "...", "org_id": ..., "exp": ...}
Важно: При создании токена НЕ перезаписываем type если он уже есть:
# app/core/security.py:31-34
if "type" not in to_encode:
to_encode["type"] = "access"
to_encode["exp"] = expire
Файлы:
app/core/security.py:31-34 - не перезаписываем typeapp/api/deps.py:182-187 - проверка type="device"Проблема: NULL constraint violation при создании Device без simple_id.
Решение: Добавить server_default в SQLAlchemy модель:
simple_id: Mapped[int] = mapped_column(
Integer,
unique=True,
nullable=False,
server_default=text("nextval('device_simple_id_seq')"),
)
Критично: server_default должен быть в модели, даже если sequence создан в миграции!
Файлы:
alembic/versions/001_initial_schema.py - создание sequenceapp/models/device.py:23-28 - server_default в моделиПроблема: Passlib с bcrypt backend имеет compatibility issues:
ValueError: password cannot be longer than 72 bytes
Решение: Использовать bcrypt напрямую, убрать passlib:
import bcrypt
def hash_password(password: str) -> str:
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8")
)
Файлы:
app/core/security.py:83-103 - прямой bcryptpyproject.toml - только bcrypt, без passlibПроблема: Демон на железке ожидает stub API (192.168.5.2:5000).
Решение: Реализованы endpoints полностью совместимые со stub:
POST /api/v1/registration
GET /api/v1/config?device_id=MAC
POST /api/v1/ble
POST /api/v1/wifi
Важно:
device_id передаётся как query parameter в GET /configФайлы:
app/api/v1/registration.py - POST /registrationapp/api/v1/config.py - GET /configapp/api/v1/events.py - POST /ble, POST /wifiПроблема: Каждое новое устройство регистрировать руками неудобно.
Решение: Settings table с JSON value + toggle в superadmin API:
# settings table
{
"key": "auto_registration",
"value": {
"enabled": true,
"last_device_at": "2025-12-27T11:39:14Z"
}
}
Механизм:
Файлы:
app/models/settings.py - модель Settingsapp/services/device_auth_service.py:36-62 - авторегистрацияapp/api/v1/superadmin/settings.py - toggle APIПроблема:
db: Annotated[AsyncSession, Depends(get_db)] = Depends(get_db)
# AssertionError: Cannot specify Depends in Annotated and default value
Решение: Параметры БЕЗ default value должны идти ПЕРЕД параметрами с default:
# Правильно
async def endpoint(
db: Annotated[AsyncSession, Depends(get_db)], # БЕЗ default
param: str = Query(None), # С default
):
pass
# Неправильно
async def endpoint(
param: str = Query(None),
db: Annotated[AsyncSession, Depends(get_db)], # ОШИБКА!
):
pass
Файлы: Все endpoints с Depends
Два поля в devices:
device_token - Bearer token для API (base64, 32 bytes)device_password - 8-digit password для dashboard admindevice_token - используется в Authorization header device_password - для POST /admin/update_device в будущем
Генерация:
def _generate_token() -> str:
return b64encode(secrets.token_bytes(32)).decode("ascii")
def _generate_password() -> str:
n = secrets.randbelow(10**8)
return f"{n:08d}"
Файлы:
app/api/v1/registration.py:29-37 - генерацияapp/models/device.py:61-62 - поля в моделиSQL уровень:
# Автоматическая фильтрация по organization_id
result = await db.execute(
select(Device).where(
Device.organization_id == current_user.organization_id
)
)
Проверки:
# Проверка принадлежности к организации
if device.organization_id != current_user.organization_id:
raise HTTPException(status_code=403, detail="...")
Файлы:
app/api/v1/client/devices.py:72-76 - проверка organization_idapp/api/v1/client/users.py:81-85 - multi-tenant для usersИерархия:
get_current_user() # Любой авторизованный
└─ get_current_owner() # owner или superadmin
└─ get_current_superadmin() # только superadmin
Использование:
@router.post("/users")
async def create_user(
current_user: Annotated[User, Depends(get_current_owner)], # owner/admin only
):
pass
Файлы:
app/api/deps.py:83-127 - все dependenciesDevice config - это JSONB поле для гибких настроек:
# Default config (hardcoded)
default_config = {
"ble": {"enabled": True, "batch_interval_ms": 2500},
"wifi": {"monitor_enabled": False}
}
# Device-specific overrides (в БД)
device.config = {
"wifi": {"monitor_enabled": True} # override
}
# Merged config (отправляется устройству)
merged = {
"ble": {"enabled": True, "batch_interval_ms": 2500},
"wifi": {"monitor_enabled": True} # OVERRIDDEN
}
Файлы:
app/api/v1/config.py:56-95 - merge logic# asyncpg driver
DATABASE_URL = "postgresql+asyncpg://user:password@host/db"
# async session
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# usage
async with async_session() as session:
result = await session.execute(select(User))
user = result.scalar_one_or_none()
Важно: expire_on_commit=False чтобы можно было использовать объекты после commit.
Файлы:
app/core/database.py:11-14 - async session configPostgreSQL JSONB для гибких данных:
devices.config - конфигурация устройстваsettings.value - системные настройкиПреимущества:
Файлы:
app/models/device.py:58 - config JSONBapp/models/settings.py:14 - value JSONBPostgreSQL sequence для simple_id:
CREATE SEQUENCE device_simple_id_seq START 1;
ALTER TABLE devices
ALTER COLUMN simple_id
SET DEFAULT nextval('device_simple_id_seq');
Важно: SQLAlchemy модель ДОЛЖНА иметь server_default!
Файлы:
alembic/versions/001_initial_schema.py - CREATE SEQUENCEapp/models/device.py:23-28 - server_defaultSuperadmin:
Owner:
Devices:
/tmp/test_client.sh # Client API test
/tmp/test_device_api.sh # Device API test
/tmp/demo_hardware.sh # Hardware integration demo
/tmp/enable_autoreg.sh # Enable auto-registration
/tmp/assign_device.sh # Assign device to org
cd /home/user/work/luckfox/alpine/mybeacon-backend/backend
poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000
Production: Добавить systemd service + nginx reverse proxy.
✅ JWT Subject must be string - конвертируем int → str ✅ Device simple_id NULL - добавлен server_default ✅ Bcrypt/passlib compatibility - используем прямой bcrypt ✅ Device token type - не перезаписываем type в create_access_token ✅ FastAPI Depends ordering - параметры без default идут первыми
ClickHouse Version: 25.12.1.649 (official build)
Installation:
sudo systemctl start clickhouse-server
clickhouse-client
Database Creation:
CREATE DATABASE mybeacon;
Tables:
-- BLE events
CREATE TABLE mybeacon.ble_events (
timestamp DateTime64(3),
device_id UInt32,
device_mac String,
organization_id UInt32 DEFAULT 0,
beacon_mac String,
rssi Int8,
uuid String DEFAULT '',
major UInt16 DEFAULT 0,
minor UInt16 DEFAULT 0,
tx_power Int8 DEFAULT 0,
raw_data String DEFAULT ''
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (organization_id, device_id, timestamp);
-- WiFi events
CREATE TABLE mybeacon.wifi_events (
timestamp DateTime64(3),
device_id UInt32,
device_mac String,
organization_id UInt32 DEFAULT 0,
client_mac String,
ssid String DEFAULT '',
rssi Int8,
channel UInt8 DEFAULT 0,
frame_type String DEFAULT 'probe_request',
raw_data String DEFAULT ''
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (organization_id, device_id, timestamp);
Service: app/services/clickhouse_service.py
insert_ble_events() - batch insert BLE eventsinsert_wifi_events() - batch insert WiFi eventsts field)Integration: app/api/v1/events.py
Event Format (from device):
{
"mac": "f9:29:de:16:b4:f3",
"rssi": -64,
"ts": 1766923995934,
"type": "ibeacon",
"uuid": "b9407f30f5f8466eaff925556b57fe6d",
"major": 1,
"minor": 2
}
toYYYYMM(timestamp))(organization_id, device_id, timestamp) for fast multi-tenant queriesTotal events:
SELECT count(*) FROM mybeacon.ble_events;
SELECT count(*) FROM mybeacon.wifi_events;
Unique beacons:
SELECT uniq(beacon_mac) FROM mybeacon.ble_events;
Latest events:
SELECT timestamp, beacon_mac, rssi, uuid
FROM mybeacon.ble_events
ORDER BY timestamp DESC
LIMIT 10;
Events by organization:
SELECT organization_id, count(*) as events
FROM mybeacon.ble_events
GROUP BY organization_id;
[REGISTRATION] device=38:54:39:4b:1b:ac simple_id=4
[BLE BATCH] device=38:54:39:4b:1b:ac simple_id=4 count=46
[WIFI BATCH] device=38:54:39:4b:1b:ac simple_id=4 count=10
Repository: https://h2.e-bash.ru/root/mybeacon-backend.git Branch: master Latest commit: Implement complete MyBeacon backend MVP
Supported Languages:
Implementation:
/src/i18n/index.js{{ $t('nav.dashboard') }}Adding new translations:
// src/i18n/index.js
const messages = {
en: {
mySection: {
myKey: 'My English Text'
}
},
ru: {
mySection: {
myKey: 'Мой русский текст'
}
}
}
Superadmin:
Client (owner/admin):
Dashboard должен отображать метрики хоста в реальном времени:
Метрики:
Варианты реализации:
API Endpoint (планируется):
GET /api/v1/monitoring/host
{
"cpu": {"usage": 45.2, "cores": 4},
"memory": {"total": 16384, "used": 8192, "percent": 50},
"network": {"rx_bytes": 1024000, "tx_bytes": 512000},
"load": {"1m": 0.5, "5m": 0.7, "15m": 0.6},
"disk": {
"io": {"read_bytes": 2048000, "write_bytes": 1024000},
"usage": {"total": 512000, "used": 256000, "percent": 50}
},
"timestamp": "2025-12-28T16:00:00Z"
}
Frontend компонент: