baton/docs/backend_spec.md

372 lines
13 KiB
Markdown
Raw Permalink Normal View History

2026-03-20 20:44:00 +02:00
# Backend Spec: Baton PWA
**Версия:** 1.1
**Дата:** 2026-03-20
**Статус:** Approved (Architect)
---
## 1. API Contracts
### POST /api/register
Регистрирует UUID→имя пользователя. **Идемпотентен**: повторный вызов с тем же UUID возвращает существующую запись.
**Request:**
```http
POST /api/register
Content-Type: application/json
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Алиса"
}
```
| Поле | Тип | Ограничения |
|------|-----|-------------|
| uuid | string | UUID v4, обязателен |
| name | string | 1100 символов, обязателен |
**Response 200 OK:**
```json
{
"user_id": 42,
"uuid": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Status codes:**
| Код | Причина |
|-----|---------|
| 200 | Успешно (новый или существующий) |
| 422 | Ошибка валидации Pydantic |
| 500 | Внутренняя ошибка сервера |
---
### POST /api/signal
Принимает сигнал тревоги от PWA. Сохраняет в SQLite, добавляет в очередь агрегатора Telegram.
**Request:**
```http
POST /api/signal
Content-Type: application/json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1742478000000,
"geo": {
"lat": 55.7558,
"lon": 37.6173,
"accuracy": 15.0
}
}
```
| Поле | Тип | Ограничения |
|------|-----|-------------|
| user_id | string | UUID v4 пользователя, обязателен |
| timestamp | int | Unix ms, > 0, обязателен |
| geo | object \| null | Необязателен; если передан — все три поля lat/lon/accuracy обязательны |
| geo.lat | float | -90.0 … 90.0 |
| geo.lon | float | -180.0 … 180.0 |
| geo.accuracy | float | > 0, метры |
**Response 200 OK:**
```json
{
"status": "ok",
"signal_id": 123
}
```
**Status codes:**
| Код | Причина |
|-----|---------|
| 200 | Сигнал принят |
| 422 | Ошибка валидации |
| 500 | Внутренняя ошибка |
---
### POST /api/webhook/telegram
Входящие обновления от Telegram Bot API. Регистрируется через `setWebhook` при старте сервера.
**Headers:**
```
X-Telegram-Bot-Api-Secret-Token: <WEBHOOK_SECRET>
```
**Request body:** стандартный Telegram Update object (JSON от Telegram).
**Обрабатываемые команды:**
| Команда | Действие |
|---------|----------|
| `/start` | Регистрация пользователя через Telegram (INSERT OR IGNORE в users с Telegram user_id как UUID) |
**Response:**
```json
{"ok": true}
```
**Status codes:**
| Код | Причина |
|-----|---------|
| 200 | Обновление обработано |
| 403 | Неверный или отсутствующий X-Telegram-Bot-Api-Secret-Token |
| 422 | Невалидный JSON |
---
## 2. DB Schema (SQLite WAL)
```sql
-- WAL mode + защита от write-lock (решения #1005, #1002)
PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_uuid TEXT NOT NULL REFERENCES users(uuid),
timestamp INTEGER NOT NULL,
lat REAL DEFAULT NULL,
lon REAL DEFAULT NULL,
accuracy REAL DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
telegram_batch_id INTEGER DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS telegram_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_text TEXT DEFAULT NULL,
sent_at TEXT DEFAULT NULL,
signals_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending'
);
-- Индексы
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid ON users(uuid);
CREATE INDEX IF NOT EXISTS idx_signals_user_uuid ON signals(user_uuid);
CREATE INDEX IF NOT EXISTS idx_signals_created_at ON signals(created_at);
CREATE INDEX IF NOT EXISTS idx_batches_status ON telegram_batches(status);
```
**Инвариант:** все три PRAGMA применяются при каждом подключении. Пропуск любой — нарушение решений #1005, #1002.
---
## 3. Telegram Aggregator Design
### Принцип работы
```
[POST /signal] ──► add_signal(user_name, ts, geo)
[in-memory buffer]
asyncio background task (run loop)
sleep(10 sec)
if buffer not empty:
──► flush()
формат сообщения
send_message()
```
### Буфер и окно
- **Хранение:** список `list[dict]` в памяти, защищённый `asyncio.Lock()`
- **Интервал сброса:** 10 секунд (скользящее окно)
- **Rate limit:** максимум 20 сообщений/минуту в группу, 1 сообщение/секунду (#1014)
- Реализация: `asyncio.sleep(1)` после каждого успешного `send_message`
### Формат сообщения
```
🚨 Получено N сигналов [HH:MM:SS—HH:MM:SS]
Пользователи: name1, name2, name3
📍 С геолокацией: K из N
```
Если имя пользователя неизвестно — показывать первые 8 символов UUID.
### Обработка ошибок Telegram
| Код ответа | Действие |
|-----------|---------|
| 200 OK | Успех, обновить telegram_batch status = 'sent' |
| 429 Too Many Requests | Прочитать `retry_after` из тела ответа, `await asyncio.sleep(retry_after)`, повторить |
| 400 Bad Request | Логировать ошибку, сигналы не теряются (остаются в SQLite) |
| 5xx | Логировать, повторить через 30 сек (1 попытка) |
### Запись batch в SQLite
После успешной отправки:
```sql
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
VALUES (?, datetime('now'), ?, 'sent');
UPDATE signals SET telegram_batch_id = ? WHERE id IN (...);
```
---
## 4. Middleware & Security
### CORS
```python
CORSMiddleware(
allow_origins=[config.FRONTEND_ORIGIN], # env var FRONTEND_ORIGIN
allow_methods=["POST"],
allow_headers=["Content-Type"],
)
```
`FRONTEND_ORIGIN` обязателен в `.env`. Не использовать `allow_origins=["*"]` в продакшне.
### Webhook Secret Validation
Middleware проверяет заголовок `X-Telegram-Bot-Api-Secret-Token` **только** для маршрута `POST /api/webhook/telegram`.
```
Входящий запрос на /api/webhook/telegram
└── if header != config.WEBHOOK_SECRET → 403 Forbidden (немедленно)
└── else → передать в обработчик
```
Реализация: Starlette `BaseHTTPMiddleware` или dependency в роуте (предпочтительно dependency — проще тестировать).
### Input Validation
Все входящие тела запросов валидируются через Pydantic v2 models. FastAPI возвращает 422 автоматически при ошибке валидации.
### Secrets Management
Все секреты исключительно через переменные окружения (`.env` + `python-dotenv`). Ни один секрет не должен логироваться (особенно `WEBHOOK_SECRET` и `BOT_TOKEN`).
---
## 5. Startup / Shutdown
### Startup (lifespan event)
```
1. init_db()
└── PRAGMA journal_mode=WAL
└── PRAGMA busy_timeout=5000
└── PRAGMA synchronous=NORMAL
└── CREATE TABLE IF NOT EXISTS ... (users, signals, telegram_batches)
└── CREATE INDEX IF NOT EXISTS ...
2. set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
└── POST https://api.telegram.org/bot{TOKEN}/setWebhook
└── WEBHOOK_URL ОБЯЗАН быть HTTPS (#1011)
└── Если ошибка → залогировать и завершить старт с исключением
3. aggregator = SignalAggregator(interval=10)
asyncio.create_task(aggregator.run())
```
### Shutdown (lifespan event)
```
1. aggregator.stop() — установить флаг остановки
2. await aggregator.flush() — отправить оставшиеся сигналы из буфера
3. Закрыть все aiosqlite соединения
```
---
## 6. Файловая структура
```
baton/
├── backend/
│ ├── main.py # FastAPI app, роуты, lifespan events, CORS
│ ├── db.py # SQLite init (WAL), CRUD: register_user, save_signal, get_user_name
│ ├── models.py # Pydantic v2: RegisterRequest/Response, SignalRequest/Response, GeoData
│ ├── telegram.py # send_message, set_webhook, SignalAggregator
│ ├── config.py # Settings через python-dotenv: BOT_TOKEN, CHAT_ID, DB_PATH,
│ │ # WEBHOOK_SECRET, WEBHOOK_URL, FRONTEND_ORIGIN
│ └── middleware.py # WebhookSecretValidator (или FastAPI dependency)
├── tests/
│ ├── conftest.py # Фикстуры: in-memory SQLite, AsyncClient, respx mock
│ ├── test_register.py
│ ├── test_signal.py
│ ├── test_telegram.py
│ ├── test_webhook.py
│ └── test_db.py
├── docs/
│ ├── backend_spec.md # Этот файл
│ └── backend_review.md # Создаётся reviewer после имплементации
├── requirements.txt # fastapi, uvicorn[standard], aiosqlite, httpx, python-dotenv, pydantic>=2.0
├── requirements-dev.txt # pytest, pytest-asyncio, httpx, respx
└── .env.example
```
---
## 7. .env.example
```env
# Telegram
BOT_TOKEN=your_telegram_bot_token_here
CHAT_ID=-1001234567890
WEBHOOK_SECRET=your_random_secret_here
WEBHOOK_URL=https://yourdomain.com/api/webhook/telegram
# Database
DB_PATH=baton.db
# CORS
FRONTEND_ORIGIN=https://yourdomain.com
```
---
## 8. Соответствие решениям
| Решение | Статус | Реализация |
|---------|--------|-----------|
| #999: HTTPS для PWA/Geolocation | ✅ | WEBHOOK_URL обязан быть HTTPS; задокументировано в constraints |
| #1001: Background Sync fallback | ✅ | Не касается бэкенда; offline паттерн — на фронте |
| #1002: SQLite write-lock | ✅ | busy_timeout=5000 при каждом подключении |
| #1003: Требования не понижать | ✅ | Все 3 эндпоинта обязательны |
| #1004: bcrypt через run_in_executor | ✅ | UUID auth — без bcrypt; если добавится — только run_in_executor |
| #1005: WAL + busy_timeout + synchronous | ✅ | Все три PRAGMA вместе в init_db() |
| #1009: setWebhook vs getUpdates | ✅ | ТОЛЬКО setWebhook при старте; getUpdates запрещён |
| #1010: webhook secret validation | ✅ | Middleware/dependency на /api/webhook/telegram |
| #1011: HTTPS для webhook | ✅ | WEBHOOK_URL в .env.example — HTTPS |
| #1013: UUID v4 stateless auth | ✅ | register принимает UUID из localStorage |
| #1014: Telegram rate limit 20/min | ✅ | SignalAggregator: 10-сек окно, sleep(1) между отправками |
---
## 9. Открытые вопросы (для Director/PM)
1. **CORS origin** — какой домен фронтенда? (Определяется Infra-отделом)
2. **Количество uvicorn workers** — один процесс (aggregator в памяти) vs несколько (aggregator должен быть shared via Redis/SQLite)
- **Рекомендация:** один uvicorn worker в продакшне для MVP (aggregator в памяти корректен)
3. **Команды Telegram-бота** — только `/start` для регистрации, или нужны другие команды?
4. **Telegram message format** — показывать реальные имена или UUID?