baton/docs/backend_spec.md
2026-03-20 20:44:00 +02:00

371 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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?