baton/docs/tech_report.md

415 lines
20 KiB
Markdown
Raw Normal View History

2026-03-20 20:44:00 +02:00
# 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-007-offline-queue-v2.md (описывает offline queue)
2026-03-20 20:44:00 +02:00
├── .env.example
├── requirements.txt
└── .gitignore
```