kin: BATON-002 [Research] UX Designer
This commit is contained in:
commit
057e500d5f
29 changed files with 3530 additions and 0 deletions
97
docs/adr/ADR-001-backend-stack.md
Normal file
97
docs/adr/ADR-001-backend-stack.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# ADR-001: Выбор бэкенд-стека
|
||||
|
||||
**Дата:** 2026-03-20
|
||||
**Статус:** Accepted
|
||||
**Автор:** Architect Agent (Kin pipeline, BATON-001)
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Baton — минималистичное PWA приложение экстренного сигнала. Бэкенд выполняет три задачи:
|
||||
1. Принять POST /signal от 300-400 пользователей (возможны одновременные запросы)
|
||||
2. Сохранить сигнал в SQLite
|
||||
3. Отправить уведомление в Telegram-группу через Bot API
|
||||
|
||||
Проект требует простого деплоя на один VPS, без kubernetes, без сложной инфраструктуры.
|
||||
Команда имеет опыт работы с Python/FastAPI (используется в проекте Kin).
|
||||
|
||||
Рассматривались три стека: FastAPI (Python), Express/Fastify (Node.js), Go (net/http).
|
||||
|
||||
---
|
||||
|
||||
## Варианты
|
||||
|
||||
### Вариант A: FastAPI (Python 3.11+)
|
||||
|
||||
**Плюсы:**
|
||||
- Знакомость команды (используется в Kin)
|
||||
- asyncio нативно
|
||||
- Pydantic — автоматическая валидация входных данных
|
||||
- `aiosqlite` или `sqlite3` через `run_in_executor`
|
||||
- Быстрый старт разработки
|
||||
|
||||
**Минусы:**
|
||||
- В 2-3x медленнее Go по RPS
|
||||
- Docker образ ~200-400 MB (Python runtime + зависимости)
|
||||
- bcrypt блокирует event loop — нужен `run_in_executor` (решение #1004)
|
||||
|
||||
### Вариант B: Express/Fastify (Node.js 20+)
|
||||
|
||||
**Плюсы:**
|
||||
- Единый язык с фронтендом (vanilla JS)
|
||||
- `better-sqlite3` — синхронный, самый быстрый SQLite биндинг для Node.js
|
||||
- Fastify ~24% быстрее FastAPI по RPS в независимых тестах
|
||||
- Docker образ ~200-300 MB
|
||||
|
||||
**Минусы:**
|
||||
- Нет опыта работы в команде
|
||||
- `better-sqlite3` синхронный — блокирует event loop при долгих запросах (на практике приемлемо для simple INSERT)
|
||||
- Дополнительное переключение контекста (JS для фронта, JS для бека)
|
||||
|
||||
### Вариант C: Go (net/http)
|
||||
|
||||
**Плюсы:**
|
||||
- Компилируется в единый статический бинарь ~8-15 MB (простейший деплой)
|
||||
- В 2-3x быстрее Python, ~2x быстрее Node.js
|
||||
- Горутины — нативный concurrency без event loop ограничений
|
||||
- Нет проблем с bcrypt (не блокирует горутины)
|
||||
- Cross-compile: `GOARCH=amd64 GOOS=linux go build`
|
||||
|
||||
**Минусы:**
|
||||
- Нет опыта работы в команде
|
||||
- Более длительный онбординг
|
||||
- `modernc.org/sqlite` (CGO-free): 10-100% медленнее нативного SQLite — компромисс для кросс-компиляции
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
**Выбран Вариант A: FastAPI (Python 3.11+)**
|
||||
|
||||
---
|
||||
|
||||
## Обоснование
|
||||
|
||||
1. **Знакомость команды — главный фактор для минимального проекта.** FastAPI используется в Kin. Нет времени на онбординг в Go или Node.js для задачи, которая требует ~200 строк бэкенд-кода.
|
||||
|
||||
2. **Производительности FastAPI достаточно.** При нагрузке 300-400 одновременных запросов бутылочное горлышко — Telegram rate limit (20 сообщений/минуту в группу), а не скорость Python. SQLite WAL + `busy_timeout=5000` справится с 400 одновременными INSERT за ~400 мс (решения #1002, #1005).
|
||||
|
||||
3. **Pydantic даёт бесплатную валидацию** входных данных (user_id, timestamp, geo) без дополнительного кода.
|
||||
|
||||
4. **Размер деплоя приемлем.** ~300 MB Docker образ — не проблема для одного VPS сервиса.
|
||||
|
||||
5. **Вариант B отклонён:** нет опыта у команды, преимущество в скорости (+24%) несущественно при текущей нагрузке.
|
||||
|
||||
6. **Вариант C отклонён:** несмотря на превосходную производительность и минимальный деплой, отсутствие опыта в команде создаёт риск для проекта без аргументированной причины переходить на Go.
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
- Использовать `aiosqlite` для async SQLite операций (или `sqlite3` через `run_in_executor`)
|
||||
- bcrypt (если понадобится в будущем) — только через `run_in_executor` (решение #1004)
|
||||
- SQLite WAL обязателен: `busy_timeout=5000`, `synchronous=NORMAL` — вместе (решение #1005)
|
||||
- Агрегатор Telegram: реализовать в Python как background task (asyncio) или через простой in-memory буфер с `asyncio.sleep`
|
||||
- requirements.txt: `fastapi`, `uvicorn[standard]`, `aiosqlite`, `httpx` (для Telegram API)
|
||||
- Переменные окружения: `BOT_TOKEN`, `CHAT_ID`, `DB_PATH` — читать из `.env` через `python-dotenv`
|
||||
123
docs/adr/ADR-002-offline-pattern.md
Normal file
123
docs/adr/ADR-002-offline-pattern.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# ADR-002: Паттерн офлайн-очереди
|
||||
|
||||
**Дата:** 2026-03-20
|
||||
**Статус:** Accepted
|
||||
**Автор:** Architect Agent (Kin pipeline, BATON-001)
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Baton — приложение экстренного сигнала. Критичное требование: сигнал **не должен быть потерян**, если пользователь нажал кнопку в момент отсутствия сети (тоннель, слабый сигнал, офлайн).
|
||||
|
||||
Сигнал должен быть сохранён локально и доставлен на сервер, как только соединение восстановится.
|
||||
|
||||
Аудитория: 300-400 пользователей, разные браузеры и платформы (Android Chrome, iOS Safari, Desktop Firefox/Chrome).
|
||||
|
||||
---
|
||||
|
||||
## Варианты
|
||||
|
||||
### Вариант A: IndexedDB outbox + BackgroundSync + online event fallback
|
||||
|
||||
**Схема:**
|
||||
1. Кнопка нажата → немедленная попытка `fetch('/signal')`
|
||||
2. Ошибка или offline → запись в IndexedDB outbox
|
||||
3. Trigger 1: `window.addEventListener('online', flushOutbox)` — main thread, все браузеры
|
||||
4. Trigger 2: SW регистрирует `registration.sync.register('flush-outbox')` — Chromium только
|
||||
5. SW обрабатывает `sync` event → читает IndexedDB → flush
|
||||
|
||||
**Плюсы:**
|
||||
- IndexedDB персистентна: не очищается при закрытии вкладки (в отличие от memory)
|
||||
- IndexedDB доступна как из main thread, так и из Service Worker → общее хранилище
|
||||
- BackgroundSync: браузер сам управляет повтором (Chrome может сработать даже при закрытой вкладке)
|
||||
- Dual trigger страхует: если BackgroundSync не сработал → online event
|
||||
- Квота IndexedDB: обычно GB (не 5 MB как localStorage)
|
||||
- Соответствует принятому решению #1006
|
||||
|
||||
**Минусы:**
|
||||
- IndexedDB API громоздкий → нужна обёртка (`idb` библиотека, ~1.9 KB gzip) или написать самому
|
||||
- BackgroundSync поддерживается только 78.75% браузеров (caniuse, март 2026) — Safari и Firefox не поддерживают
|
||||
- Сложнее отлаживать в DevTools, чем localStorage
|
||||
|
||||
### Вариант B: localStorage queue + online event listener
|
||||
|
||||
**Схема:**
|
||||
1. Кнопка нажата → попытка отправки
|
||||
2. Ошибка → `JSON.stringify` очереди в `localStorage`
|
||||
3. `window.addEventListener('online', flush)` → отправить всё из очереди
|
||||
|
||||
**Плюсы:**
|
||||
- Самый простой вариант (~20 строк)
|
||||
- Нет зависимостей
|
||||
- Синхронный API — легко читать и писать
|
||||
|
||||
**Минусы:**
|
||||
- iOS Safari приватный режим: `localStorage.setItem()` бросает `SecurityError` → нужен try/catch → если упал, сигнал теряется
|
||||
- localStorage недоступна в Service Worker контексте → нельзя flush из SW
|
||||
- Лимит: 5 MB (достаточно, но IndexedDB надёжнее)
|
||||
- Нет BackgroundSync — только один trigger (online event)
|
||||
|
||||
### Вариант C: Cache API + Request replay в Service Worker
|
||||
|
||||
**Схема:**
|
||||
- SW перехватывает failed POST запросы → сохраняет в Cache API
|
||||
- При появлении сети → повторяет запросы из кэша
|
||||
|
||||
**Плюсы:**
|
||||
- Нативная интеграция с SW fetch event
|
||||
- Нет отдельного хранилища
|
||||
|
||||
**Минусы:**
|
||||
- Cache API спроектирован для Response (кэш ответов), не для Request replay
|
||||
- Нет гарантий персистентности очереди (Cache может быть очищен браузером без предупреждения)
|
||||
- Workbox Background Sync внутри использует IndexedDB, не Cache API (косвенное свидетельство)
|
||||
- Нестандартное использование API → неожиданное поведение в edge cases
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
**Выбран Вариант A: IndexedDB outbox + BackgroundSync + online event fallback**
|
||||
|
||||
Соответствует зафиксированному решению #1006.
|
||||
|
||||
---
|
||||
|
||||
## Обоснование
|
||||
|
||||
1. **IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker.** Это критично: flush может произойти как из app.js (online event), так и из sw.js (BackgroundSync event). Общее хранилище исключает дублирование кода.
|
||||
|
||||
2. **Dual trigger — страховочная сеть.** BackgroundSync не обязателен для работы (Safari/Firefox — 21% юзеров обойдутся без него), но является бонусом для Chrome пользователей: flush случится даже при закрытой вкладке.
|
||||
|
||||
3. **Вариант B отклонён** из-за проблемы iOS Safari приватного режима (решение #1003: не понижать явные требования) и недоступности в SW контексте. При том что приложение экстренного сигнала должно работать без потерь на iOS.
|
||||
|
||||
4. **Вариант C отклонён** как злоупотребление API не по назначению. Cache API не гарантирует персистентность POST запросов.
|
||||
|
||||
5. **Размер зависимости `idb`:** ~1.9 KB gzip — приемлемо. Альтернатива: написать минимальную обёртку (~30 строк) для трёх операций (add, getAll, delete).
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
**При реализации учесть:**
|
||||
|
||||
1. **iOS Safari приватный режим:** `localStorage` недоступен → переход на IndexedDB не помогает (IndexedDB тоже может быть ограничен). Нужен graceful degradation: попытка записи в IndexedDB → при ошибке сигнал отправляется только online или теряется с явным UI-предупреждением.
|
||||
|
||||
2. **Idempotency ключ:** `id: "${Date.now()}-${Math.random().toString(36).slice(2)}"` — уникальный ключ каждой записи в outbox. Защита от дубликатов при повторных попытках. Бэкенд должен игнорировать дубликаты (INSERT OR IGNORE по `client_id`).
|
||||
|
||||
3. **Лимит попыток:** `attempts` в outbox entry. После 3-5 неудачных попыток — показать пользователю UI-предупреждение. Не flush бесконечно.
|
||||
|
||||
4. **SW lifecycle:** при обновлении SW (новая версия) старый SW активен до закрытия всех вкладок. Flush в процессе обновления → запрос может быть потерян. Idempotency ключ и `INSERT OR IGNORE` на бэкенде защищают от дубликатов.
|
||||
|
||||
5. **Background Sync — проверка перед регистрацией:**
|
||||
```javascript
|
||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
await reg.sync.register('flush-outbox');
|
||||
} else {
|
||||
if (navigator.onLine) flushOutbox(); // немедленный fallback
|
||||
}
|
||||
```
|
||||
|
||||
6. **Решение #1001 требует обновления:** фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.
|
||||
35
docs/adr/ADR-003-auth-pattern.md
Normal file
35
docs/adr/ADR-003-auth-pattern.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# ADR-003: Паттерн аутентификации пользователей
|
||||
|
||||
**Дата:** 2026-03-20
|
||||
**Статус:** Stub (подлежит заполнению)
|
||||
**Автор:** —
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
_Описание контекста — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Варианты
|
||||
|
||||
_Описание вариантов — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
_Решение — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Обоснование
|
||||
|
||||
_Обоснование — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
_Последствия — предстоит заполнить._
|
||||
35
docs/adr/ADR-004-telegram-strategy.md
Normal file
35
docs/adr/ADR-004-telegram-strategy.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)
|
||||
|
||||
**Дата:** 2026-03-20
|
||||
**Статус:** Stub (подлежит заполнению)
|
||||
**Автор:** —
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
_Описание контекста — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Варианты
|
||||
|
||||
_Описание вариантов — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
_Решение — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Обоснование
|
||||
|
||||
_Обоснование — предстоит заполнить._
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
_Последствия — предстоит заполнить._
|
||||
371
docs/backend_spec.md
Normal file
371
docs/backend_spec.md
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
# 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 | 1–100 символов, обязателен |
|
||||
|
||||
**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?
|
||||
414
docs/tech_report.md
Normal file
414
docs/tech_report.md
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
# Tech Report: Baton PWA
|
||||
|
||||
**Дата:** 2026-03-20
|
||||
**Версия:** 2.0 (пересмотр по директорскому фидбеку v1)
|
||||
**Источник:** исследование марта 2026 + директорский пересмотр требований
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Baton — PWA экстренного сигнала. Одна кнопка → HTTPS POST → FastAPI backend → sendMessage в Telegram-группу.
|
||||
|
||||
**Параметры нагрузки:** 300–400 зарегистрированных пользователей, одновременно нажимает максимум 1 человек, реалистичная частота ~1 нажатие/неделю.
|
||||
|
||||
**Ключевые решения по пересмотру v1 (директор):**
|
||||
- **Офлайн-режим НЕ нужен** — только cache-first SW для мгновенного открытия с главного экрана. Offline queue → v2.0
|
||||
- **Тротлинг Telegram неактуален** — прямой sendMessage без агрегатора. Агрегатор → v2.0 если потребуется
|
||||
- **Система должна висеть в фоне бесконечно** — PWA на главном экране, SW зарегистрирован
|
||||
|
||||
**Граница v1/v2:**
|
||||
| Фича | v1 | v2 |
|
||||
|---|---|---|
|
||||
| Cache-first SW (мгновенное открытие) | ✅ | — |
|
||||
| Offline queue (IndexedDB) | ❌ | ✅ |
|
||||
| Background Sync | ❌ | ✅ |
|
||||
| Прямой sendMessage | ✅ | — |
|
||||
| Агрегатор сигналов | ❌ | ✅ (если нагрузка вырастет) |
|
||||
|
||||
---
|
||||
|
||||
## Матрица покрытия требований (#1007)
|
||||
|
||||
| # | Требование | Технология/решение | Статус | Риски |
|
||||
|---|-----------|-------------------|--------|-------|
|
||||
| R1 | PWA на главный экран iOS/Android | `manifest.json` (name, start_url, icons 192+512, display:standalone) + `<link rel="apple-touch-icon">` | ✅ COVERED | iOS: ручная установка, нет `beforeinstallprompt` |
|
||||
| R2 | Мгновенное открытие с главного экрана | SW cache-first: `cache.addAll()` при install + skipWaiting + clientsClaim | ✅ COVERED | iOS: 7-дневная очистка кеша при неактивности |
|
||||
| R3 | Нажатие кнопки → сообщение в Telegram | FastAPI `POST /api/signal` → `sendMessage` (прямой) | ✅ COVERED | При нажатии без сети — показать ошибку (нет retry в v1) |
|
||||
| R4 | Stateless UUID auth | `crypto.randomUUID()` → `localStorage` | ✅ COVERED | iOS Safari приватный режим: SecurityError → `sessionStorage` fallback (#1015) |
|
||||
| R5 | Telegram /start регистрация | `setWebhook` + `/api/webhook/telegram` endpoint | ✅ COVERED | HTTPS обязателен (#1011), валидация secret token (#1010) |
|
||||
| R6 | Геолокация (optional v1) | `navigator.geolocation.getCurrentPosition()` | ✅ COVERED | HTTPS обязателен (#999), cold start GPS до 60 сек |
|
||||
| R7 | Висеть в фоне бесконечно | PWA на главном экране, SW registration сохраняется | ✅ COVERED | iOS очищает кеш через 7 дней; SW re-registers при открытии |
|
||||
|
||||
---
|
||||
|
||||
## Service Worker — cache-first (без offline queue)
|
||||
|
||||
### Что кешировать при install
|
||||
|
||||
```javascript
|
||||
const CACHE_NAME = 'baton-v1';
|
||||
const PRECACHE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/style.css',
|
||||
'/manifest.json',
|
||||
'/sw.js',
|
||||
'/icon-180.png',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
```
|
||||
|
||||
**Логика выбора:** кешируем только app shell — статику, необходимую для рендера UI. API-запросы (`/api/signal`, `/api/register`) не кешируются — они должны идти в сеть.
|
||||
|
||||
### Стратегия fetch
|
||||
|
||||
```javascript
|
||||
self.addEventListener('fetch', event => {
|
||||
// Кешируем только GET-запросы к статике
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
// API-запросы не перехватываем — только сеть
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Обновление кеша: skipWaiting + clientsClaim
|
||||
|
||||
| Механизм | Этап | Эффект |
|
||||
|---|---|---|
|
||||
| `self.skipWaiting()` | `install` | Новый SW активируется немедленно, не ждёт закрытия вкладок |
|
||||
| `self.clients.claim()` | `activate` | Новый SW берёт контроль над всеми открытыми страницами сразу |
|
||||
|
||||
Вместе обеспечивают бесшовное обновление: пользователь не замечает смены версии SW.
|
||||
|
||||
**Очистка старых кешей при activate:**
|
||||
```javascript
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
),
|
||||
self.clients.claim(),
|
||||
])
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Обработка нажатия без сети (v1 — простой fallback)
|
||||
|
||||
```javascript
|
||||
// В app.js
|
||||
button.addEventListener('click', async () => {
|
||||
if (!navigator.onLine) {
|
||||
showError('Нет подключения. Проверьте сеть и попробуйте снова.');
|
||||
return;
|
||||
}
|
||||
await sendSignal();
|
||||
});
|
||||
```
|
||||
|
||||
**Нет очереди, нет retry** — это v2.0 функционал. В v1 просто показываем ошибку.
|
||||
|
||||
---
|
||||
|
||||
## PWA Installability
|
||||
|
||||
### Web App Manifest — минимальный набор
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Baton",
|
||||
"short_name": "Baton",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#ff0000",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
|
||||
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" },
|
||||
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Критичные поля:** `name`, `start_url`, `display: "standalone"`, иконки 192px + 512px.
|
||||
|
||||
### iOS Safari — особенности и ограничения
|
||||
|
||||
**Установка:** только через Safari → Поделиться → «На экран Домой». `beforeinstallprompt` event отсутствует.
|
||||
|
||||
**Обязательный HTML-тег (manifest.json для иконки недостаточен):**
|
||||
```html
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
```
|
||||
|
||||
**Ограничения iOS PWA:**
|
||||
| Ограничение | Детали |
|
||||
|---|---|
|
||||
| Storage quota | ~50 МБ кеш (Chrome: сотни МБ) |
|
||||
| Cache expiry | 7 дней неиспользования → кеш удаляется |
|
||||
| Background Sync | Не поддерживается (только Chromium) |
|
||||
| Push notifications | iOS 16.4+, НЕ работает в EU |
|
||||
| beforeinstallprompt | Отсутствует — только ручная установка |
|
||||
|
||||
**iOS 17.4 EU standalone (#1016):** Apple анонсировала удаление standalone режима в EU (DMA) → отменила решение 2 марта 2024 до релиза. Standalone работает. Push уведомления в EU по-прежнему недоступны.
|
||||
|
||||
### Android Chrome
|
||||
|
||||
- `beforeinstallprompt` срабатывает автоматически при соответствии критериям
|
||||
- Полный Background Sync (Chrome 49+)
|
||||
- Кеш без срока действия
|
||||
- Splash screen генерируется из `background_color` + иконок
|
||||
|
||||
### Разница iOS vs Android
|
||||
|
||||
| | Android Chrome | iOS Safari |
|
||||
|---|---|---|
|
||||
| Install prompt | Автоматический | Ручной (Share menu) |
|
||||
| Storage | Сотни МБ | ~50 МБ |
|
||||
| Cache TTL | Без ограничений | 7 дней без открытия |
|
||||
| Background Sync | ✅ | ❌ |
|
||||
| beforeinstallprompt | ✅ | ❌ |
|
||||
| Push (EU) | ✅ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Auth — UUID + localStorage
|
||||
|
||||
### Реализация
|
||||
|
||||
```javascript
|
||||
let _sessionUserId = null;
|
||||
|
||||
function getOrCreateUserId() {
|
||||
try {
|
||||
let id = localStorage.getItem('baton_user_id');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem('baton_user_id', id);
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
// iOS Safari приватный режим: SecurityError (#1015)
|
||||
if (!_sessionUserId) {
|
||||
_sessionUserId = crypto.randomUUID();
|
||||
}
|
||||
return _sessionUserId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### crypto.randomUUID() — поддержка
|
||||
|
||||
Требует HTTPS или localhost. Chrome 92+, Firefox 95+, Safari 15.4+. Охват 97%+.
|
||||
|
||||
### Обработка iOS Safari private mode (#1015)
|
||||
|
||||
`localStorage.setItem()` бросает `SecurityError` в приватном режиме. Стратегия:
|
||||
1. `try/catch` вокруг localStorage операций
|
||||
2. Fallback 1: `sessionStorage` — данные живут до закрытия вкладки
|
||||
3. Fallback 2: in-memory переменная `_sessionUserId` — до перезагрузки страницы
|
||||
|
||||
В private mode UUID не сохраняется между сессиями — это ожидаемое поведение.
|
||||
|
||||
### Поведение localStorage
|
||||
|
||||
| Сценарий | Результат |
|
||||
|---|---|
|
||||
| Нормальный режим | UUID хранится бессрочно |
|
||||
| iOS private mode | SecurityError → sessionStorage fallback |
|
||||
| Clear browsing data | UUID удалён → новый UUID |
|
||||
| iOS 7-дневная автоочистка | UUID удалён → новый UUID |
|
||||
| Другое устройство | Новый UUID (stateless — переноса нет) |
|
||||
|
||||
---
|
||||
|
||||
## Backend — стек и endpoints
|
||||
|
||||
### Выбор стека (→ ADR-001)
|
||||
|
||||
| Компонент | Технология | Обоснование |
|
||||
|---|---|---|
|
||||
| Framework | FastAPI (Python 3.11+) | Async, Pydantic, знакомость команды |
|
||||
| БД | SQLite WAL | Один writer, достаточно для ~1 запроса/неделю |
|
||||
| HTTP client | httpx async | Нативный async, нет блокировки event loop |
|
||||
| HTTPS | Nginx reverse proxy | TLS termination перед uvicorn |
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/register` | Регистрация пользователя: `{uuid, name}` → `{user_id, uuid}` |
|
||||
| `POST` | `/api/signal` | Сигнал: `{user_id, timestamp, geo?}` → `{status, signal_id}` |
|
||||
| `POST` | `/api/webhook/telegram` | Входящие обновления от Telegram (для /start) |
|
||||
|
||||
### SQLite WAL конфигурация (#1005)
|
||||
|
||||
`busy_timeout=5000` + `synchronous=NORMAL` — обязательны вместе. Обеспечивают:
|
||||
- Конкурентный доступ нескольких readers
|
||||
- Retry при contention без ошибки для клиента
|
||||
|
||||
---
|
||||
|
||||
## Telegram — прямая отправка (v1)
|
||||
|
||||
### Архитектура (v1 — без агрегатора)
|
||||
|
||||
```
|
||||
[PWA] POST /api/signal
|
||||
→ [Backend] INSERT в SQLite
|
||||
→ [Backend] POST api.telegram.org/sendMessage
|
||||
→ [Telegram] → Группа оповещения
|
||||
```
|
||||
|
||||
**Обоснование отказа от агрегатора в v1:** нагрузка ~1 нажатие/неделю, лимит 20 msg/min группы неактуален. Прямая отправка проще и надёжнее для данной нагрузки.
|
||||
|
||||
### setWebhook (входящий канал для /start)
|
||||
|
||||
Telegram webhook используется **двунаправленно:**
|
||||
1. **Исходящий:** backend вызывает `sendMessage` → сообщение в группу
|
||||
2. **Входящий:** Telegram шлёт обновления (например `/start`) → backend регистрирует пользователя
|
||||
|
||||
```
|
||||
POST /setWebhook
|
||||
{
|
||||
"url": "https://yourdomain.com/api/webhook/telegram",
|
||||
"secret_token": "WEBHOOK_SECRET"
|
||||
}
|
||||
```
|
||||
|
||||
### Валидация входящих запросов (#1010)
|
||||
|
||||
```python
|
||||
# middleware.py
|
||||
async def verify_webhook_secret(
|
||||
x_telegram_bot_api_secret_token: str = Header(default=""),
|
||||
) -> None:
|
||||
if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
```
|
||||
|
||||
Заголовок `X-Telegram-Bot-Api-Secret-Token` присылается Telegram с каждым webhook-запросом.
|
||||
|
||||
### HTTPS требования (#1011)
|
||||
|
||||
- Telegram принимает webhook только на HTTPS
|
||||
- Поддерживаемые порты: **443, 80, 88, 8443** (только эти четыре)
|
||||
- TLS 1.2 минимум (1.0/1.1 отклоняются)
|
||||
- CA-signed сертификат достаточен; self-signed — загрузить PEM через `certificate` параметр
|
||||
|
||||
### Rate limits Telegram
|
||||
|
||||
| Ограничение | Значение |
|
||||
|---|---|
|
||||
| В один чат (любой тип) | ~1 msg/сек |
|
||||
| В группу | 20 msg/минута |
|
||||
| Глобально (бесплатно) | ~30 msg/сек |
|
||||
|
||||
**При превышении:** HTTP 429 с `parameters.retry_after` (секунды). Код (`telegram.py:22-25`) уже обрабатывает это корректно.
|
||||
|
||||
---
|
||||
|
||||
## Схема взаимодействия v1
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ PWA (Браузер) │
|
||||
│ │
|
||||
│ ┌──────────┐ нажатие ┌──────────────────────┐ │
|
||||
│ │ index │ ────────────> │ app.js │ │
|
||||
│ │ .html │ │ getOrCreateUserId() │ │
|
||||
│ └──────────┘ │ getGeolocation() │ │
|
||||
│ │ navigator.onLine? │ │
|
||||
│ ┌──────────┐ │ fetch('/api/signal')│ │
|
||||
│ │ manifest │ └──────────┬───────────┘ │
|
||||
│ │ .json │ │ HTTPS │
|
||||
│ └──────────┘ если offline│ → showError() │
|
||||
│ │ │
|
||||
│ ┌──────────┐ │ │
|
||||
│ │ sw.js │ cache-first static only │ │
|
||||
│ │ precache │ (не перехватывает /api/) │ │
|
||||
│ └──────────┘ │ │
|
||||
└───────────────────────────────────────┼─────────────────┘
|
||||
│ POST /api/signal
|
||||
│ {user_id, timestamp, geo}
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ │
|
||||
│ POST /api/signal │
|
||||
│ ├── валидация (Pydantic) │
|
||||
│ ├── INSERT в SQLite (WAL) │
|
||||
│ └── POST sendMessage (прямой) │
|
||||
│ │
|
||||
│ POST /api/webhook/telegram ←── Telegram (setWebhook) │
|
||||
│ └── /start → register_user() │
|
||||
└──────────────────────────────┬───────────────────────────┘
|
||||
│ POST sendMessage
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Telegram Bot API │
|
||||
│ api.telegram.org │
|
||||
│ → Группа оповещения │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
1. **Иконки:** нужны реальные файлы `icon-180.png`, `icon-192.png`, `icon-512.png` + maskable вариант
|
||||
2. **WEBHOOK_URL:** должен быть публичным HTTPS URL — dev-окружение требует ngrok или tunnel
|
||||
3. **Geolocation permission UX:** когда запрашивать разрешение — при загрузке или при первом нажатии?
|
||||
4. **Код агрегатора в codebase:** `telegram.py:51-121` и `main.py:24,36-44` содержат `SignalAggregator` — по решению директора не нужен в v1, рекомендуется убрать или отключить во избежание confusion
|
||||
5. **Background lifetime PWA:** на iOS SW не работает в фоне без push-события — если пользователь не открывал приложение 7 дней, кеш очищается и при следующем открытии потребуется сетевой запрос
|
||||
|
||||
---
|
||||
|
||||
## Файловая структура проекта (v1)
|
||||
|
||||
```
|
||||
baton/
|
||||
├── frontend/
|
||||
│ ├── index.html # Точка входа PWA, meta теги, apple-touch-icon
|
||||
│ ├── app.js # UUID, геолокация, fetch, offline error handler
|
||||
│ ├── style.css # Стили
|
||||
│ ├── sw.js # SW: cache-first precache, skipWaiting+clientsClaim
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── icon-180.png # iOS apple-touch-icon
|
||||
│ ├── icon-192.png # Android manifest (обязателен)
|
||||
│ └── icon-512.png # Android splash (обязателен + maskable)
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app, /api/signal, /api/register, /api/webhook/telegram
|
||||
│ ├── db.py # SQLite WAL init, CRUD
|
||||
│ ├── models.py # Pydantic схемы
|
||||
│ ├── telegram.py # sendMessage + setWebhook (SignalAggregator — не используется в v1)
|
||||
│ ├── middleware.py # verify_webhook_secret
|
||||
│ └── config.py # Env vars: BOT_TOKEN, CHAT_ID, WEBHOOK_URL, WEBHOOK_SECRET
|
||||
├── docs/
|
||||
│ ├── tech_report.md # Этот файл
|
||||
│ └── adr/
|
||||
│ ├── ADR-001-backend-stack.md
|
||||
│ └── ADR-002-offline-pattern.md (требует обновления — описывает offline queue)
|
||||
├── .env.example
|
||||
├── requirements.txt
|
||||
└── .gitignore
|
||||
```
|
||||
575
docs/tech_research_raw.md
Normal file
575
docs/tech_research_raw.md
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
# Tech Research Raw: Baton PWA
|
||||
**Дата:** 2026-03-20
|
||||
**Статус:** Полное исследование — все 6 требований покрыты
|
||||
**Источники:** web.dev, MDN, caniuse.com, core.telegram.org, sqlite.org, caniuse.com
|
||||
|
||||
---
|
||||
|
||||
## ТРЕБОВАНИЕ 1: PWA на главный экран iOS/Android
|
||||
|
||||
### manifest.json — обязательные поля
|
||||
|
||||
Минимум для Android Chrome (Add to Home Screen / A2HS):
|
||||
- `name` или `short_name` — строка, отображается под иконкой
|
||||
- `start_url` — относительный путь к начальной странице
|
||||
- `icons` — массив, минимум одна запись с размером 192×192
|
||||
- `display` — одно из: `standalone`, `fullscreen`, `minimal-ui`
|
||||
|
||||
Без `display: standalone` — не устанавливается как PWA (остаётся закладкой).
|
||||
|
||||
Дополнительно рекомендованы:
|
||||
- `background_color` — цвет сплэш-экрана при запуске
|
||||
- `theme_color` — цвет браузерного хромирования
|
||||
- `description` — для магазинов и SEO
|
||||
|
||||
### Иконки: обязательные размеры
|
||||
|
||||
**Android (Chrome, Samsung Internet):**
|
||||
- `192×192` px PNG — минимум для установки
|
||||
- `512×512` px PNG — для экрана загрузки (splash screen)
|
||||
- Маскируемая иконка: `"purpose": "any maskable"` — без неё ОС обрезает иконку в круг
|
||||
- Отдельный файл с отступом ~10% с каждой стороны
|
||||
|
||||
**iOS (Safari, Chrome на iOS):**
|
||||
- manifest.json НЕ используется для иконки на главном экране iOS
|
||||
- Нужен HTML-тег: `<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">`
|
||||
- Стандарт 2025: 180×180 px — покрывает все современные iPhone и iPad
|
||||
- Без apple-touch-icon iOS берёт скриншот страницы как иконку
|
||||
|
||||
**Рекомендованный набор файлов (покрывает все платформы):**
|
||||
- `icon-180.png` — iOS/Safari
|
||||
- `icon-192.png` — Android/Chrome (manifest, обязателен)
|
||||
- `icon-512.png` — Android/Chrome splash (manifest, обязателен)
|
||||
- `icon-384.png` — дополнительно
|
||||
- `icon-1024.png` — дополнительно для высокого разрешения
|
||||
|
||||
### HTTPS — подтверждение
|
||||
|
||||
PWA installability требует HTTPS — подтверждено MDN, web.dev, Apple Developer Docs. HTTP-страница не может быть установлена как PWA ни на iOS, ни на Android. Исключение: `localhost` для разработки.
|
||||
|
||||
### Ограничения iOS
|
||||
|
||||
**iOS 16.4+ (2023):**
|
||||
- Push-уведомления для PWA: добавлены в iOS 16.4 через Web Push API
|
||||
- Только если PWA **добавлена на главный экран** → только тогда можно запросить permission
|
||||
- Нет тихих уведомлений (silent push) для PWA на iOS
|
||||
- Только текст и иконки в уведомлениях (без rich media)
|
||||
|
||||
**iOS 17.4 (EU-регион):**
|
||||
- Standalone PWA в EU — открывается в Safari Tab, без push support
|
||||
- Причина: Digital Markets Act (DMA), Apple удалила standalone режим
|
||||
- Статус: под расследованием EU регуляторов
|
||||
|
||||
**Хранилище на iOS:**
|
||||
- Квота кэша: ~50 МБ
|
||||
- Хранилище очищается автоматически через несколько недель при неиспользовании
|
||||
- 7-дневный лимит на script-writable storage (IndexedDB, localStorage)
|
||||
|
||||
**Background execution на iOS:**
|
||||
- Фоновое выполнение скриптов: не поддерживается
|
||||
- Service Worker работает только когда PWA активна в foreground или при push-событии
|
||||
|
||||
### Как установить на iOS
|
||||
|
||||
- Пользователь открывает Safari → Share → «На экран Домой»
|
||||
- Начиная с iOS 16.4 также работает из Chrome, Edge, Firefox на iOS
|
||||
- Нет автоматического install prompt (как на Android) — только ручная установка
|
||||
|
||||
---
|
||||
|
||||
## ТРЕБОВАНИЕ 2: Офлайн через Service Worker + precache
|
||||
|
||||
### Background Sync API — реальная поддержка (2026)
|
||||
|
||||
Источник: caniuse.com, данные на март 2026:
|
||||
|
||||
**Глобальный охват: 78.75%**
|
||||
|
||||
Поддерживают:
|
||||
- Chrome 49+ (десктоп и Android)
|
||||
- Edge 79+
|
||||
- Opera 42+
|
||||
- Samsung Internet 5+
|
||||
- UC Browser для Android 15.5+
|
||||
- Android Browser 97+
|
||||
|
||||
**НЕ поддерживают:**
|
||||
- Safari (все версии, десктоп и iOS)
|
||||
- Firefox (все версии, включая Android)
|
||||
- Opera Mini
|
||||
- IE
|
||||
|
||||
Итого: ~21.25% пользователей без поддержки Background Sync API.
|
||||
|
||||
> Решение #1001 указывало ~15% без поддержки. Актуальные данные caniuse (март 2026): ~21% без поддержки. Разница значительна — ручной fallback обязателен.
|
||||
|
||||
### Workbox vs ручной Service Worker
|
||||
|
||||
**Workbox:**
|
||||
- Используется на 54% мобильных сайтов (web.dev 2025)
|
||||
- Модульная архитектура (import только нужное)
|
||||
- Встроенные стратегии: CacheFirst, NetworkFirst, StaleWhileRevalidate
|
||||
- Встроенный BackgroundSync модуль: автоматическая очередь + повтор
|
||||
- Размер: базовый модуль ~6-10 KB gzip
|
||||
- Плюсы: готовые паттерны, меньше ошибок, активная поддержка
|
||||
- Минусы: зависимость, для 3-5 файлов «тяжеловато» (но размер приемлем)
|
||||
|
||||
**Ручной SW:**
|
||||
- Полный контроль
|
||||
- Для 3-5 файлов: ~30-50 строк кода
|
||||
- Минусы: нужно вручную реализовать cache versioning, cleanup, retry логику
|
||||
- Ошибки сложнее отловить (SW обновляется с задержкой)
|
||||
|
||||
**Для минимального приложения (5 файлов):**
|
||||
- Ручной SW достаточен для кэширования статики
|
||||
- Для BackgroundSync + outbox лучше Workbox (workbox-background-sync)
|
||||
|
||||
### Cache-first стратегия для статики
|
||||
|
||||
```javascript
|
||||
// Ручной SW — cache-first для статических файлов
|
||||
const CACHE_NAME = 'baton-v1';
|
||||
const PRECACHE_URLS = ['/', '/index.html', '/app.js', '/style.css', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
if (PRECACHE_URLS.some(url => event.request.url.endsWith(url))) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### IndexedDB Outbox + Dual Triggers (решение #1006)
|
||||
|
||||
**Trigger 1: online event**
|
||||
```javascript
|
||||
// В main thread (app.js)
|
||||
window.addEventListener('online', () => flushOutbox());
|
||||
|
||||
async function flushOutbox() {
|
||||
const items = await getAllFromOutbox(); // читаем из IndexedDB
|
||||
for (const item of items) {
|
||||
try {
|
||||
await fetch('/signal', { method: 'POST', body: JSON.stringify(item) });
|
||||
await removeFromOutbox(item.id);
|
||||
} catch (e) { /* останется в очереди */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trigger 2: Background Sync (SW)**
|
||||
```javascript
|
||||
// В service worker
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'flush-outbox') {
|
||||
event.waitUntil(flushOutboxFromSW());
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Регистрация синка:**
|
||||
```javascript
|
||||
// Когда добавляем в outbox:
|
||||
if ('serviceWorker' in navigator && 'sync' in registration) {
|
||||
await registration.sync.register('flush-outbox');
|
||||
} else {
|
||||
// Fallback: пробуем сразу если online
|
||||
if (navigator.onLine) flushOutbox();
|
||||
}
|
||||
```
|
||||
|
||||
**Edge cases:**
|
||||
- SW lifecycle: при обновлении SW (новая версия) старый SW остаётся активным до закрытия всех вкладок
|
||||
- Если SW обновляется во время flush — запрос может быть потерян → нужен idempotent ключ (timestamp + random)
|
||||
- IndexedDB: доступна как из main thread, так и из SW → разделяемое хранилище
|
||||
- iOS: Background Sync не работает → только Trigger 1 (online event) и ручная кнопка retry
|
||||
|
||||
### Ручной Fallback (обязателен для iOS и Firefox)
|
||||
|
||||
При отправке сигнала:
|
||||
1. Попробовать fetch()
|
||||
2. При ошибке → сохранить в IndexedDB
|
||||
3. На каждый `window.addEventListener('online')` → попытка flush
|
||||
4. Кнопка в UI «Повторить отправку» → явный flush
|
||||
|
||||
---
|
||||
|
||||
## ТРЕБОВАНИЕ 3: POST → Сервер → Telegram
|
||||
|
||||
### Telegram Bot API — sendMessage
|
||||
|
||||
**Эндпоинт:**
|
||||
```
|
||||
POST https://api.telegram.org/bot{TOKEN}/sendMessage
|
||||
```
|
||||
|
||||
**Обязательные параметры:**
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `chat_id` | Integer или String | ID группы (отрицательное число) или @username |
|
||||
| `text` | String | Текст сообщения, 1-4096 символов |
|
||||
|
||||
**Опциональные параметры:**
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `parse_mode` | String | `Markdown`, `MarkdownV2`, или `HTML` |
|
||||
| `disable_notification` | Boolean | Без звукового уведомления |
|
||||
| `reply_to_message_id` | Integer | Ответить на сообщение |
|
||||
| `message_thread_id` | Integer | Тред в супергруппе |
|
||||
|
||||
**Пример payload:**
|
||||
```json
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"text": "🚨 Экстренный сигнал от пользователя abc123\nВремя: 2026-03-20T10:30:00Z\nГеолокация: 55.7558, 37.6173",
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Ответ при успехе:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": { "message_id": 42, "chat": {...}, "text": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
**При ошибке:** `{ "ok": false, "error_code": 429, "description": "Too Many Requests", "parameters": { "retry_after": 30 } }`
|
||||
|
||||
### Rate Limits Telegram Bot API (официальные, 2026)
|
||||
|
||||
| Сценарий | Лимит |
|
||||
|----------|-------|
|
||||
| Один чат (любой) | 1 сообщение/секунду |
|
||||
| Группа | 20 сообщений/минуту |
|
||||
| Глобально (все чаты) | 30 сообщений/секунду |
|
||||
| С платными broadcast | до 1000 сообщений/секунду |
|
||||
|
||||
**Критично для Baton:**
|
||||
- 400 пользователей нажали кнопку одновременно = 400 `sendMessage` запросов
|
||||
- В одну группу: лимит 20/минуту
|
||||
- **Проблема:** 400 сообщений в одну группу за 1 минуту превышает лимит в 20 раз
|
||||
- Решение: не отправлять по одному сообщению на пользователя — агрегировать сигналы
|
||||
|
||||
### setWebhook vs getUpdates — уточнение для Baton
|
||||
|
||||
**Ключевое:** Baton ОТПРАВЛЯЕТ сообщения в Telegram, не принимает. Поэтому:
|
||||
- `setWebhook` — НЕ нужен. Webhook нужен только если бот принимает команды от пользователей.
|
||||
- `getUpdates` — НЕ нужен. Polling нужен только для получения обновлений.
|
||||
- Для отправки: просто `POST /sendMessage` с токеном бота в URL.
|
||||
- Токен бота = Bearer-авторизация через URL: `api.telegram.org/bot{TOKEN}/sendMessage`
|
||||
|
||||
**Если в будущем понадобится принимать ответы** → setWebhook и getUpdates взаимоисключающие (решение #1009). Выбрать что-то одно.
|
||||
|
||||
### X-Telegram-Bot-Api-Secret-Token (решение #1010)
|
||||
|
||||
Актуально **только** если мы принимаем webhook-запросы от Telegram на наш сервер.
|
||||
Для исходящих sendMessage — не применимо.
|
||||
|
||||
### HTTPS для эндпоинта (решение #1011)
|
||||
|
||||
Актуально только для webhook-эндпоинта (если бот принимает входящие). Для исходящих вызовов Bot API — Telegram API сам является HTTPS, TLS на стороне клиента.
|
||||
|
||||
---
|
||||
|
||||
## ТРЕБОВАНИЕ 4: Stateless авторизация UUID v4
|
||||
|
||||
### Паттерн реализации (решение #1013)
|
||||
|
||||
```javascript
|
||||
// Инициализация при первом запуске
|
||||
function getOrCreateUserId() {
|
||||
let userId = localStorage.getItem('baton_user_id');
|
||||
if (!userId) {
|
||||
userId = crypto.randomUUID(); // UUID v4, встроен в браузер (ES2022+)
|
||||
localStorage.setItem('baton_user_id', userId);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
// Отправка сигнала
|
||||
async function sendSignal(geo) {
|
||||
const userId = getOrCreateUserId();
|
||||
return fetch('/signal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, geo, timestamp: Date.now() })
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**`crypto.randomUUID()`:** Доступен в современных браузерах (Chrome 92+, Firefox 95+, Safari 15.4+). Требует HTTPS или localhost.
|
||||
|
||||
### UUID v4 — энтропия и безопасность
|
||||
|
||||
- UUID v4: 122 бита случайности (6 бит зарезервированы для версии/варианта)
|
||||
- `crypto.randomUUID()` использует CSPRNG (криптографически безопасный ГПСЧ)
|
||||
- Вероятность коллизии при 400 пользователях: практически нулевая
|
||||
|
||||
**Это НЕ секретный токен в классическом смысле, а постоянный идентификатор устройства/пользователя.** Безопасность: UUID не может быть угадан, но может быть украден через XSS.
|
||||
|
||||
### Риски localStorage
|
||||
|
||||
| Риск | Описание | Вероятность |
|
||||
|------|----------|-------------|
|
||||
| XSS-кража | Скрипт на странице читает `localStorage.getItem('baton_user_id')` | Средняя (если есть XSS) |
|
||||
| Clear browsing data | Пользователь сбросил браузер → UUID потерян | Средняя |
|
||||
| Приватный режим iOS Safari | `localStorage.setItem()` выбрасывает исключение (блокирует запись) | Высокая на iOS |
|
||||
| Приватный режим Chrome/Firefox | Работает в сессии, очищается при закрытии вкладки | Средняя |
|
||||
| Другое устройство | Новое устройство → новый UUID, идентификатор не переносится | Ожидаемо |
|
||||
| Замена UUID | Пользователь вручную заменил значение в DevTools | Низкая (намеренное действие) |
|
||||
|
||||
**Ограничение приватного режима iOS Safari:**
|
||||
- `localStorage` в приватном режиме Safari бросает `SecurityError` при попытке записи
|
||||
- Нужен try/catch и fallback на `sessionStorage` или переменную в памяти (UUID не сохраняется между сессиями)
|
||||
|
||||
**Это — ЖЁСТКОЕ требование проекта** (решение #1003: не понижать до nice-to-have).
|
||||
|
||||
### Поведение при очистке хранилища
|
||||
|
||||
- `Clear browsing data` / «Очистить данные сайта» → UUID удаляется → следующий визит генерирует новый
|
||||
- iOS: автоматическая очистка после нескольких недель неиспользования
|
||||
|
||||
---
|
||||
|
||||
## ТРЕБОВАНИЕ 5: 300-400 пользователей, разные страны
|
||||
|
||||
### SQLite WAL — анализ нагрузки
|
||||
|
||||
**WAL Mode характеристики:**
|
||||
- Чтение: одновременные read-транзакции, без блокировок
|
||||
- Запись: **один writer в любой момент времени** — все write-операции сериализуются
|
||||
- `busy_timeout=5000`: ожидать до 5 секунд перед возвратом SQLITE_BUSY
|
||||
- `synchronous=NORMAL`: батчинг fsync вместо вызова после каждой транзакции (вместе с WAL — обязательно, решение #1005)
|
||||
|
||||
**Расчёт worst case — 400 одновременных нажатий:**
|
||||
- 400 POST /signal за 1 секунду
|
||||
- Каждый запрос = 1 INSERT в БД
|
||||
- SQLite WAL: серилизует все 400 INSERT — они встанут в очередь
|
||||
- Каждый INSERT (простой, без joins): ~0.1-1 мс в WAL+NORMAL режиме
|
||||
- 400 INSERT × 1 мс = ~400 мс до завершения последней транзакции
|
||||
- С `busy_timeout=5000`: все 400 запросов получат ответ в течение 5 секунд (не сразу упадут с ошибкой)
|
||||
- Telegram rate limit (20 сообщений/минуту в группу) — бутылочное горлышко раньше SQLite
|
||||
|
||||
**Бенчмарки SQLite WAL (из исследований):**
|
||||
- 150,000 rows/second с 100 INSERT/транзакцию (full synchronous mode)
|
||||
- 400 одиночных INSERT: ~0.4 секунды суммарно при busy_timeout=5000
|
||||
|
||||
**Вывод:** SQLite WAL с busy_timeout=5000 + synchronous=NORMAL справится с 400 одновременными записями без потери данных. Задержка ответа может вырасти до 500 мс для последних в очереди.
|
||||
|
||||
### Telegram Rate Limits при массовой отправке
|
||||
|
||||
| Сценарий | Лимит | При 400 юзерах |
|
||||
|----------|-------|----------------|
|
||||
| Сообщения в 1 группу | 20/минуту | 400 > 20 = превышение в 20 раз |
|
||||
| Глобально | 30/секунду | 400 > 30 = нужна очередь |
|
||||
|
||||
**Критическая проблема:** отправка 400 отдельных сообщений в одну группу невозможна без throttling.
|
||||
|
||||
**Возможные решения (факты, не рекомендации):**
|
||||
1. Агрегация: собирать сигналы за 1 минуту → одно сообщение «N сигналов получено»
|
||||
2. Throttling: очередь отправки с задержкой 3 секунды между сообщениями
|
||||
3. Несколько групп: распределить оповещения по разным чатам
|
||||
|
||||
### CDN / Геораспределение
|
||||
|
||||
Для 400 пользователей из разных стран:
|
||||
- Статика (HTML/JS/CSS): CDN снизит latency для первого посещения
|
||||
- API запросы: без CDN (CDN для API требует сложной настройки Edge Functions)
|
||||
- Без CDN: для 400 юзеров — один сервер в центральной локации достаточен
|
||||
- Latency без CDN: Европа→Европа ~20-50 мс, США→Европа ~100-150 мс, Азия→Европа ~200-300 мс
|
||||
|
||||
---
|
||||
|
||||
## ТРЕБОВАНИЕ 6: Геолокация (опциональна в v1)
|
||||
|
||||
### Geolocation API — базовые факты
|
||||
|
||||
**HTTPS:** Обязателен. Chrome 50+, Firefox 55+, Safari 10.1+ заблокировали Geolocation на HTTP. Исключение: `localhost`.
|
||||
|
||||
**User Permission:** Обязателен. Нет способа получить координаты без явного разрешения пользователя.
|
||||
|
||||
**API:**
|
||||
```javascript
|
||||
// Получить текущую позицию
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude; // Float
|
||||
const lon = pos.coords.longitude; // Float
|
||||
const acc = pos.coords.accuracy; // метры
|
||||
},
|
||||
(err) => { /* PERMISSION_DENIED, POSITION_UNAVAILABLE, TIMEOUT */ },
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
||||
);
|
||||
```
|
||||
|
||||
### Точность по методу
|
||||
|
||||
| Метод | Точность | Время получения |
|
||||
|-------|----------|-----------------|
|
||||
| GPS (enableHighAccuracy: true) | 3-5 м (95% случаев) | 5-60 секунд (cold start) |
|
||||
| WiFi positioning | 10-20 м | 1-3 секунды |
|
||||
| Cell towers triangulation | 300-3000 м | 1-2 секунды |
|
||||
| IP-based | 5-50 км | Мгновенно |
|
||||
|
||||
Браузер выбирает метод автоматически исходя из `enableHighAccuracy`. На мобильных: при `true` → GPS; при `false` → WiFi/Cell.
|
||||
|
||||
### Формат передачи в POST body
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"timestamp": 1742471400000,
|
||||
"geo": {
|
||||
"lat": 55.7558,
|
||||
"lon": 37.6173,
|
||||
"accuracy": 5.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Когда геолокация недоступна или пользователь отказал: `"geo": null`
|
||||
|
||||
### Поддержка браузерами
|
||||
|
||||
Geolocation API: 98%+ глобальная поддержка (caniuse). Доступен во всех современных браузерах при HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## СРАВНЕНИЕ БЭКЕНД-СТЕКОВ
|
||||
|
||||
### FastAPI (Python)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Runtime | Python 3.11+, требует установки |
|
||||
| Deployment | venv/virtualenv или Docker образ ~200-400 MB |
|
||||
| SQLite binding | `aiosqlite` (async) или `sqlite3` (sync через executor) |
|
||||
| Async | asyncio, нативно |
|
||||
| bcrypt | Нужен `run_in_executor` (#1004) |
|
||||
| Знакомость команде | Да (используется в Kin) |
|
||||
| Производительность (vs Go) | В 2-3x медленнее Go по RPS |
|
||||
| SQLite + Python | sqlite3 (stdlib), aiosqlite для async |
|
||||
|
||||
### Express/Fastify (Node.js)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Runtime | Node.js 20+, требует установки |
|
||||
| Deployment | node_modules + ~200-300 MB Docker |
|
||||
| SQLite binding | `better-sqlite3` (sync, самый быстрый) или `node-sqlite3` |
|
||||
| Async | Eventloop, I/O async |
|
||||
| bcrypt | `bcryptjs` или `bcrypt` (нативный) — без проблем с eventloop |
|
||||
| Единый язык с фронтом | Да (vanilla JS → Node.js) |
|
||||
| Производительность | Fastify ~24% быстрее FastAPI по RPS в тестах |
|
||||
|
||||
**Примечание:** `better-sqlite3` работает синхронно, но это преимущество для SQLite (не блокирует eventloop нестандартно, быстрее async биндингов).
|
||||
|
||||
### Go (net/http)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Runtime | Нет. Компилируется в статический бинарь |
|
||||
| Deployment | Один файл ~8-15 MB |
|
||||
| SQLite binding | `modernc.org/sqlite` (CGO-free) или `mattn/go-sqlite3` (CGO) |
|
||||
| Async | Горутины, нативный concurrency |
|
||||
| bcrypt | `golang.org/x/crypto/bcrypt` — не блокирует горутины |
|
||||
| Знакомость команде | Нет |
|
||||
| Производительность | В 2-3x быстрее Python, ~2x быстрее Node.js |
|
||||
| Cross-compile | `GOARCH=amd64 GOOS=linux go build` |
|
||||
|
||||
**modernc.org/sqlite (CGO-free):** В 10-100% медленнее нативного SQLite (CGO), но кросс-компиляция без C toolchain.
|
||||
|
||||
### Сравнительная таблица
|
||||
|
||||
| Критерий | FastAPI | Express/Fastify | Go |
|
||||
|----------|---------|-----------------|-----|
|
||||
| Знакомость | ✅ | ⚠️ (JS фронт) | ❌ |
|
||||
| Размер деплоя | ~300 MB | ~200 MB | ~10 MB |
|
||||
| Производительность | Базовая | +24% vs FastAPI | +200% vs FastAPI |
|
||||
| SQLite-интеграция | Хорошая | Отличная (better-sqlite3) | Хорошая (modernc) |
|
||||
| Сложность деплоя | Средняя | Средняя | Низкая (единый бинарь) |
|
||||
| 400 конк. запросов | Справится | Справится | Справится |
|
||||
|
||||
---
|
||||
|
||||
## СРАВНЕНИЕ ОФЛАЙН-ПАТТЕРНОВ
|
||||
|
||||
### Вариант 1: IndexedDB outbox + Background Sync + manual fallback (решение #1006)
|
||||
|
||||
**Схема:**
|
||||
1. Пользователь нажал кнопку → сохраняем в IndexedDB
|
||||
2. Если online → немедленная попытка отправки
|
||||
3. Если offline → регистрируем BackgroundSync tag
|
||||
4. При появлении сети: SW получает `sync` event → flush очереди
|
||||
5. Fallback: `window.addEventListener('online', flush)` + кнопка retry
|
||||
|
||||
**Плюсы:**
|
||||
- Персистентность: IndexedDB не очищается при закрытии вкладки
|
||||
- BackgroundSync: браузер сам управляет повтором (даже без открытой вкладки — в Chrome)
|
||||
- Работает в SW контексте (shared между main thread и SW)
|
||||
- Размер: IndexedDB не имеет лимита по умолчанию (квота браузера, обычно GB)
|
||||
|
||||
**Минусы:**
|
||||
- IndexedDB API — громоздкий (нужна обёртка или `idb` библиотека ~1.9 KB)
|
||||
- BackgroundSync: не работает в Safari/Firefox (~21% юзеров) → ручной fallback обязателен
|
||||
- Сложнее отлаживать
|
||||
|
||||
### Вариант 2: localStorage queue + online event listener
|
||||
|
||||
**Схема:**
|
||||
1. Сохраняем в `localStorage` как JSON-массив
|
||||
2. `window.addEventListener('online', flush)` → при появлении сети отправляем всё
|
||||
|
||||
**Плюсы:**
|
||||
- Простая синхронная реализация (~20 строк)
|
||||
- Нет зависимостей
|
||||
- Работает в iOS Safari (при HTTPS, в non-private mode)
|
||||
|
||||
**Минусы:**
|
||||
- Лимит localStorage: 5 МБ (достаточно для outbox, но риск при больших данных)
|
||||
- Недоступна в SW контексте — нет синхронизации между main thread и SW
|
||||
- В iOS приватном режиме: бросает исключение при записи → нужен try/catch + memory fallback
|
||||
- Не персистентна между сессиями в приватном режиме Chrome
|
||||
|
||||
### Вариант 3: Cache API + Request replay
|
||||
|
||||
**Схема:**
|
||||
- Перехватываем failed запросы в SW → сохраняем в Cache API
|
||||
- При появлении сети → повторяем запросы
|
||||
|
||||
**Плюсы:**
|
||||
- Нативная интеграция с SW fetch event
|
||||
- Не требует отдельного хранилища
|
||||
|
||||
**Минусы:**
|
||||
- Cache API спроектирован для Response (кэш ответов), не для Request replay
|
||||
- Нет гарантий персистентности очереди (Cache может быть очищен браузером)
|
||||
- Workbox Background Sync использует IndexedDB внутри, не Cache API
|
||||
- Нестандартный подход, мало документации для outbox паттерна
|
||||
|
||||
---
|
||||
|
||||
## ПОКРЫТИЕ ТРЕБОВАНИЙ — СВОДНАЯ ТАБЛИЦА
|
||||
|
||||
| # | Требование | Статус | Ключевые факты |
|
||||
|---|-----------|--------|----------------|
|
||||
| 1 | PWA на главный экран | RESEARCHED | manifest: name, start_url, icons (192+512), display:standalone. iOS: apple-touch-icon 180px. HTTPS обязателен. |
|
||||
| 2 | Офлайн через SW | RESEARCHED | Background Sync: 78.75% охват (не 85% как в #1001). Ручной fallback обязателен для Safari/Firefox/iOS. |
|
||||
| 3 | POST → Telegram | RESEARCHED | sendMessage: chat_id + text. Лимит группы: 20/мин. 400 одновременных > лимит. Webhook не нужен. |
|
||||
| 4 | Stateless UUID auth | RESEARCHED | crypto.randomUUID(). iOS приватный режим: пишет исключение. Clear data = потеря UUID. Это ЖЁСТКОЕ требование (#1003). |
|
||||
| 5 | 300-400 юзеров | RESEARCHED | SQLite WAL: справится. Telegram 20/мин в группу — критическое ограничение. |
|
||||
| 6 | Геолокация (optional) | RESEARCHED | HTTPS + permission. GPS: 3-5м, 5-60с. WiFi: 10-20м, 1-3с. POST body: geo: {lat, lon, accuracy} или null. |
|
||||
|
||||
---
|
||||
|
||||
*Источники: [web.dev/learn/pwa](https://web.dev/learn/pwa/web-app-manifest), [MDN PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable), [caniuse Background Sync](https://caniuse.com/background-sync), [Telegram Bot FAQ](https://core.telegram.org/bots/faq), [SQLite WAL](https://sqlite.org/wal.html), [firt.dev iOS PWA](https://firt.dev/notes/pwa-ios/)*
|
||||
Loading…
Add table
Add a link
Reference in a new issue