baton/docs/tech_report.md
Gros Frumos 3456c90e9e docs: rename ADR-002-offline-pattern → ADR-007-offline-queue-v2, update all refs
- git mv docs/adr/ADR-002-offline-pattern.md docs/adr/ADR-007-offline-queue-v2.md
- Update title inside file: ADR-002 → ADR-007
- Update reference in docs/tech_report.md:410
- grep -r 'ADR-002-offline-pattern' returns no matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:00:51 +02:00

414 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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)
├── .env.example
├── requirements.txt
└── .gitignore
```