kin: BATON-002 [Research] UX Designer

This commit is contained in:
Gros Frumos 2026-03-20 20:44:00 +02:00
commit 057e500d5f
29 changed files with 3530 additions and 0 deletions

View 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`

View 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 — не «опциональный», а обязательный элемент архитектуры.

View file

@ -0,0 +1,35 @@
# ADR-003: Паттерн аутентификации пользователей
**Дата:** 2026-03-20
**Статус:** Stub (подлежит заполнению)
**Автор:** —
---
## Контекст
_Описание контекста — предстоит заполнить._
---
## Варианты
_Описание вариантов — предстоит заполнить._
---
## Решение
_Решение — предстоит заполнить._
---
## Обоснование
_Обоснование — предстоит заполнить._
---
## Последствия
оследствия — предстоит заполнить._

View file

@ -0,0 +1,35 @@
# ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)
**Дата:** 2026-03-20
**Статус:** Stub (подлежит заполнению)
**Автор:** —
---
## Контекст
_Описание контекста — предстоит заполнить._
---
## Варианты
_Описание вариантов — предстоит заполнить._
---
## Решение
_Решение — предстоит заполнить._
---
## Обоснование
_Обоснование — предстоит заполнить._
---
## Последствия
оследствия — предстоит заполнить._

371
docs/backend_spec.md Normal file
View 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 | 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?

414
docs/tech_report.md Normal file
View 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-группу.
**Параметры нагрузки:** 300400 зарегистрированных пользователей, одновременно нажимает максимум 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
View 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/)*