- 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>
414 lines
20 KiB
Markdown
414 lines
20 KiB
Markdown
# 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-007-offline-queue-v2.md (описывает offline queue)
|
||
├── .env.example
|
||
├── requirements.txt
|
||
└── .gitignore
|
||
```
|