kin: BATON-002 [Research] UX Designer
This commit is contained in:
commit
057e500d5f
29 changed files with 3530 additions and 0 deletions
414
docs/tech_report.md
Normal file
414
docs/tech_report.md
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
# Tech Report: Baton PWA
|
||||
|
||||
**Дата:** 2026-03-20
|
||||
**Версия:** 2.0 (пересмотр по директорскому фидбеку v1)
|
||||
**Источник:** исследование марта 2026 + директорский пересмотр требований
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Baton — PWA экстренного сигнала. Одна кнопка → HTTPS POST → FastAPI backend → sendMessage в Telegram-группу.
|
||||
|
||||
**Параметры нагрузки:** 300–400 зарегистрированных пользователей, одновременно нажимает максимум 1 человек, реалистичная частота ~1 нажатие/неделю.
|
||||
|
||||
**Ключевые решения по пересмотру v1 (директор):**
|
||||
- **Офлайн-режим НЕ нужен** — только cache-first SW для мгновенного открытия с главного экрана. Offline queue → v2.0
|
||||
- **Тротлинг Telegram неактуален** — прямой sendMessage без агрегатора. Агрегатор → v2.0 если потребуется
|
||||
- **Система должна висеть в фоне бесконечно** — PWA на главном экране, SW зарегистрирован
|
||||
|
||||
**Граница v1/v2:**
|
||||
| Фича | v1 | v2 |
|
||||
|---|---|---|
|
||||
| Cache-first SW (мгновенное открытие) | ✅ | — |
|
||||
| Offline queue (IndexedDB) | ❌ | ✅ |
|
||||
| Background Sync | ❌ | ✅ |
|
||||
| Прямой sendMessage | ✅ | — |
|
||||
| Агрегатор сигналов | ❌ | ✅ (если нагрузка вырастет) |
|
||||
|
||||
---
|
||||
|
||||
## Матрица покрытия требований (#1007)
|
||||
|
||||
| # | Требование | Технология/решение | Статус | Риски |
|
||||
|---|-----------|-------------------|--------|-------|
|
||||
| R1 | PWA на главный экран iOS/Android | `manifest.json` (name, start_url, icons 192+512, display:standalone) + `<link rel="apple-touch-icon">` | ✅ COVERED | iOS: ручная установка, нет `beforeinstallprompt` |
|
||||
| R2 | Мгновенное открытие с главного экрана | SW cache-first: `cache.addAll()` при install + skipWaiting + clientsClaim | ✅ COVERED | iOS: 7-дневная очистка кеша при неактивности |
|
||||
| R3 | Нажатие кнопки → сообщение в Telegram | FastAPI `POST /api/signal` → `sendMessage` (прямой) | ✅ COVERED | При нажатии без сети — показать ошибку (нет retry в v1) |
|
||||
| R4 | Stateless UUID auth | `crypto.randomUUID()` → `localStorage` | ✅ COVERED | iOS Safari приватный режим: SecurityError → `sessionStorage` fallback (#1015) |
|
||||
| R5 | Telegram /start регистрация | `setWebhook` + `/api/webhook/telegram` endpoint | ✅ COVERED | HTTPS обязателен (#1011), валидация secret token (#1010) |
|
||||
| R6 | Геолокация (optional v1) | `navigator.geolocation.getCurrentPosition()` | ✅ COVERED | HTTPS обязателен (#999), cold start GPS до 60 сек |
|
||||
| R7 | Висеть в фоне бесконечно | PWA на главном экране, SW registration сохраняется | ✅ COVERED | iOS очищает кеш через 7 дней; SW re-registers при открытии |
|
||||
|
||||
---
|
||||
|
||||
## Service Worker — cache-first (без offline queue)
|
||||
|
||||
### Что кешировать при install
|
||||
|
||||
```javascript
|
||||
const CACHE_NAME = 'baton-v1';
|
||||
const PRECACHE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/style.css',
|
||||
'/manifest.json',
|
||||
'/sw.js',
|
||||
'/icon-180.png',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
```
|
||||
|
||||
**Логика выбора:** кешируем только app shell — статику, необходимую для рендера UI. API-запросы (`/api/signal`, `/api/register`) не кешируются — они должны идти в сеть.
|
||||
|
||||
### Стратегия fetch
|
||||
|
||||
```javascript
|
||||
self.addEventListener('fetch', event => {
|
||||
// Кешируем только GET-запросы к статике
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
// API-запросы не перехватываем — только сеть
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Обновление кеша: skipWaiting + clientsClaim
|
||||
|
||||
| Механизм | Этап | Эффект |
|
||||
|---|---|---|
|
||||
| `self.skipWaiting()` | `install` | Новый SW активируется немедленно, не ждёт закрытия вкладок |
|
||||
| `self.clients.claim()` | `activate` | Новый SW берёт контроль над всеми открытыми страницами сразу |
|
||||
|
||||
Вместе обеспечивают бесшовное обновление: пользователь не замечает смены версии SW.
|
||||
|
||||
**Очистка старых кешей при activate:**
|
||||
```javascript
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
),
|
||||
self.clients.claim(),
|
||||
])
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Обработка нажатия без сети (v1 — простой fallback)
|
||||
|
||||
```javascript
|
||||
// В app.js
|
||||
button.addEventListener('click', async () => {
|
||||
if (!navigator.onLine) {
|
||||
showError('Нет подключения. Проверьте сеть и попробуйте снова.');
|
||||
return;
|
||||
}
|
||||
await sendSignal();
|
||||
});
|
||||
```
|
||||
|
||||
**Нет очереди, нет retry** — это v2.0 функционал. В v1 просто показываем ошибку.
|
||||
|
||||
---
|
||||
|
||||
## PWA Installability
|
||||
|
||||
### Web App Manifest — минимальный набор
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Baton",
|
||||
"short_name": "Baton",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#ff0000",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
|
||||
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" },
|
||||
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Критичные поля:** `name`, `start_url`, `display: "standalone"`, иконки 192px + 512px.
|
||||
|
||||
### iOS Safari — особенности и ограничения
|
||||
|
||||
**Установка:** только через Safari → Поделиться → «На экран Домой». `beforeinstallprompt` event отсутствует.
|
||||
|
||||
**Обязательный HTML-тег (manifest.json для иконки недостаточен):**
|
||||
```html
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
```
|
||||
|
||||
**Ограничения iOS PWA:**
|
||||
| Ограничение | Детали |
|
||||
|---|---|
|
||||
| Storage quota | ~50 МБ кеш (Chrome: сотни МБ) |
|
||||
| Cache expiry | 7 дней неиспользования → кеш удаляется |
|
||||
| Background Sync | Не поддерживается (только Chromium) |
|
||||
| Push notifications | iOS 16.4+, НЕ работает в EU |
|
||||
| beforeinstallprompt | Отсутствует — только ручная установка |
|
||||
|
||||
**iOS 17.4 EU standalone (#1016):** Apple анонсировала удаление standalone режима в EU (DMA) → отменила решение 2 марта 2024 до релиза. Standalone работает. Push уведомления в EU по-прежнему недоступны.
|
||||
|
||||
### Android Chrome
|
||||
|
||||
- `beforeinstallprompt` срабатывает автоматически при соответствии критериям
|
||||
- Полный Background Sync (Chrome 49+)
|
||||
- Кеш без срока действия
|
||||
- Splash screen генерируется из `background_color` + иконок
|
||||
|
||||
### Разница iOS vs Android
|
||||
|
||||
| | Android Chrome | iOS Safari |
|
||||
|---|---|---|
|
||||
| Install prompt | Автоматический | Ручной (Share menu) |
|
||||
| Storage | Сотни МБ | ~50 МБ |
|
||||
| Cache TTL | Без ограничений | 7 дней без открытия |
|
||||
| Background Sync | ✅ | ❌ |
|
||||
| beforeinstallprompt | ✅ | ❌ |
|
||||
| Push (EU) | ✅ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Auth — UUID + localStorage
|
||||
|
||||
### Реализация
|
||||
|
||||
```javascript
|
||||
let _sessionUserId = null;
|
||||
|
||||
function getOrCreateUserId() {
|
||||
try {
|
||||
let id = localStorage.getItem('baton_user_id');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem('baton_user_id', id);
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
// iOS Safari приватный режим: SecurityError (#1015)
|
||||
if (!_sessionUserId) {
|
||||
_sessionUserId = crypto.randomUUID();
|
||||
}
|
||||
return _sessionUserId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### crypto.randomUUID() — поддержка
|
||||
|
||||
Требует HTTPS или localhost. Chrome 92+, Firefox 95+, Safari 15.4+. Охват 97%+.
|
||||
|
||||
### Обработка iOS Safari private mode (#1015)
|
||||
|
||||
`localStorage.setItem()` бросает `SecurityError` в приватном режиме. Стратегия:
|
||||
1. `try/catch` вокруг localStorage операций
|
||||
2. Fallback 1: `sessionStorage` — данные живут до закрытия вкладки
|
||||
3. Fallback 2: in-memory переменная `_sessionUserId` — до перезагрузки страницы
|
||||
|
||||
В private mode UUID не сохраняется между сессиями — это ожидаемое поведение.
|
||||
|
||||
### Поведение localStorage
|
||||
|
||||
| Сценарий | Результат |
|
||||
|---|---|
|
||||
| Нормальный режим | UUID хранится бессрочно |
|
||||
| iOS private mode | SecurityError → sessionStorage fallback |
|
||||
| Clear browsing data | UUID удалён → новый UUID |
|
||||
| iOS 7-дневная автоочистка | UUID удалён → новый UUID |
|
||||
| Другое устройство | Новый UUID (stateless — переноса нет) |
|
||||
|
||||
---
|
||||
|
||||
## Backend — стек и endpoints
|
||||
|
||||
### Выбор стека (→ ADR-001)
|
||||
|
||||
| Компонент | Технология | Обоснование |
|
||||
|---|---|---|
|
||||
| Framework | FastAPI (Python 3.11+) | Async, Pydantic, знакомость команды |
|
||||
| БД | SQLite WAL | Один writer, достаточно для ~1 запроса/неделю |
|
||||
| HTTP client | httpx async | Нативный async, нет блокировки event loop |
|
||||
| HTTPS | Nginx reverse proxy | TLS termination перед uvicorn |
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/register` | Регистрация пользователя: `{uuid, name}` → `{user_id, uuid}` |
|
||||
| `POST` | `/api/signal` | Сигнал: `{user_id, timestamp, geo?}` → `{status, signal_id}` |
|
||||
| `POST` | `/api/webhook/telegram` | Входящие обновления от Telegram (для /start) |
|
||||
|
||||
### SQLite WAL конфигурация (#1005)
|
||||
|
||||
`busy_timeout=5000` + `synchronous=NORMAL` — обязательны вместе. Обеспечивают:
|
||||
- Конкурентный доступ нескольких readers
|
||||
- Retry при contention без ошибки для клиента
|
||||
|
||||
---
|
||||
|
||||
## Telegram — прямая отправка (v1)
|
||||
|
||||
### Архитектура (v1 — без агрегатора)
|
||||
|
||||
```
|
||||
[PWA] POST /api/signal
|
||||
→ [Backend] INSERT в SQLite
|
||||
→ [Backend] POST api.telegram.org/sendMessage
|
||||
→ [Telegram] → Группа оповещения
|
||||
```
|
||||
|
||||
**Обоснование отказа от агрегатора в v1:** нагрузка ~1 нажатие/неделю, лимит 20 msg/min группы неактуален. Прямая отправка проще и надёжнее для данной нагрузки.
|
||||
|
||||
### setWebhook (входящий канал для /start)
|
||||
|
||||
Telegram webhook используется **двунаправленно:**
|
||||
1. **Исходящий:** backend вызывает `sendMessage` → сообщение в группу
|
||||
2. **Входящий:** Telegram шлёт обновления (например `/start`) → backend регистрирует пользователя
|
||||
|
||||
```
|
||||
POST /setWebhook
|
||||
{
|
||||
"url": "https://yourdomain.com/api/webhook/telegram",
|
||||
"secret_token": "WEBHOOK_SECRET"
|
||||
}
|
||||
```
|
||||
|
||||
### Валидация входящих запросов (#1010)
|
||||
|
||||
```python
|
||||
# middleware.py
|
||||
async def verify_webhook_secret(
|
||||
x_telegram_bot_api_secret_token: str = Header(default=""),
|
||||
) -> None:
|
||||
if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
```
|
||||
|
||||
Заголовок `X-Telegram-Bot-Api-Secret-Token` присылается Telegram с каждым webhook-запросом.
|
||||
|
||||
### HTTPS требования (#1011)
|
||||
|
||||
- Telegram принимает webhook только на HTTPS
|
||||
- Поддерживаемые порты: **443, 80, 88, 8443** (только эти четыре)
|
||||
- TLS 1.2 минимум (1.0/1.1 отклоняются)
|
||||
- CA-signed сертификат достаточен; self-signed — загрузить PEM через `certificate` параметр
|
||||
|
||||
### Rate limits Telegram
|
||||
|
||||
| Ограничение | Значение |
|
||||
|---|---|
|
||||
| В один чат (любой тип) | ~1 msg/сек |
|
||||
| В группу | 20 msg/минута |
|
||||
| Глобально (бесплатно) | ~30 msg/сек |
|
||||
|
||||
**При превышении:** HTTP 429 с `parameters.retry_after` (секунды). Код (`telegram.py:22-25`) уже обрабатывает это корректно.
|
||||
|
||||
---
|
||||
|
||||
## Схема взаимодействия v1
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ PWA (Браузер) │
|
||||
│ │
|
||||
│ ┌──────────┐ нажатие ┌──────────────────────┐ │
|
||||
│ │ index │ ────────────> │ app.js │ │
|
||||
│ │ .html │ │ getOrCreateUserId() │ │
|
||||
│ └──────────┘ │ getGeolocation() │ │
|
||||
│ │ navigator.onLine? │ │
|
||||
│ ┌──────────┐ │ fetch('/api/signal')│ │
|
||||
│ │ manifest │ └──────────┬───────────┘ │
|
||||
│ │ .json │ │ HTTPS │
|
||||
│ └──────────┘ если offline│ → showError() │
|
||||
│ │ │
|
||||
│ ┌──────────┐ │ │
|
||||
│ │ sw.js │ cache-first static only │ │
|
||||
│ │ precache │ (не перехватывает /api/) │ │
|
||||
│ └──────────┘ │ │
|
||||
└───────────────────────────────────────┼─────────────────┘
|
||||
│ POST /api/signal
|
||||
│ {user_id, timestamp, geo}
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ │
|
||||
│ POST /api/signal │
|
||||
│ ├── валидация (Pydantic) │
|
||||
│ ├── INSERT в SQLite (WAL) │
|
||||
│ └── POST sendMessage (прямой) │
|
||||
│ │
|
||||
│ POST /api/webhook/telegram ←── Telegram (setWebhook) │
|
||||
│ └── /start → register_user() │
|
||||
└──────────────────────────────┬───────────────────────────┘
|
||||
│ POST sendMessage
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Telegram Bot API │
|
||||
│ api.telegram.org │
|
||||
│ → Группа оповещения │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
1. **Иконки:** нужны реальные файлы `icon-180.png`, `icon-192.png`, `icon-512.png` + maskable вариант
|
||||
2. **WEBHOOK_URL:** должен быть публичным HTTPS URL — dev-окружение требует ngrok или tunnel
|
||||
3. **Geolocation permission UX:** когда запрашивать разрешение — при загрузке или при первом нажатии?
|
||||
4. **Код агрегатора в codebase:** `telegram.py:51-121` и `main.py:24,36-44` содержат `SignalAggregator` — по решению директора не нужен в v1, рекомендуется убрать или отключить во избежание confusion
|
||||
5. **Background lifetime PWA:** на iOS SW не работает в фоне без push-события — если пользователь не открывал приложение 7 дней, кеш очищается и при следующем открытии потребуется сетевой запрос
|
||||
|
||||
---
|
||||
|
||||
## Файловая структура проекта (v1)
|
||||
|
||||
```
|
||||
baton/
|
||||
├── frontend/
|
||||
│ ├── index.html # Точка входа PWA, meta теги, apple-touch-icon
|
||||
│ ├── app.js # UUID, геолокация, fetch, offline error handler
|
||||
│ ├── style.css # Стили
|
||||
│ ├── sw.js # SW: cache-first precache, skipWaiting+clientsClaim
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── icon-180.png # iOS apple-touch-icon
|
||||
│ ├── icon-192.png # Android manifest (обязателен)
|
||||
│ └── icon-512.png # Android splash (обязателен + maskable)
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app, /api/signal, /api/register, /api/webhook/telegram
|
||||
│ ├── db.py # SQLite WAL init, CRUD
|
||||
│ ├── models.py # Pydantic схемы
|
||||
│ ├── telegram.py # sendMessage + setWebhook (SignalAggregator — не используется в v1)
|
||||
│ ├── middleware.py # verify_webhook_secret
|
||||
│ └── config.py # Env vars: BOT_TOKEN, CHAT_ID, WEBHOOK_URL, WEBHOOK_SECRET
|
||||
├── docs/
|
||||
│ ├── tech_report.md # Этот файл
|
||||
│ └── adr/
|
||||
│ ├── ADR-001-backend-stack.md
|
||||
│ └── ADR-002-offline-pattern.md (требует обновления — описывает offline queue)
|
||||
├── .env.example
|
||||
├── requirements.txt
|
||||
└── .gitignore
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue