Технические детали и особенности реализации.
Проблема: 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 идут первыми
[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