baton/docs/DESIGN_BATON008.md

300 lines
14 KiB
Markdown
Raw 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.

# DESIGN_BATON008 — Регистрационный flow с Telegram-апрувом
## Flow диаграмма
```
Пользователь Backend Telegram PWA / Service Worker
| | | |
|-- POST /api/auth/-------->| | |
| register | | |
| {email,login,pwd,push} | | |
| |-- validate input | |
| |-- hash password (PBKDF2) | |
| |-- INSERT registrations | |
| | (status=pending) | |
|<-- 201 {status:pending} --| | |
| |-- create_task ─────────────>| |
| | send_registration_ | |
| | notification() | |
| | | |
| | [Admin видит сообщение с кнопками] |
| | [✅ Одобрить / ❌ Отклонить] |
| | | |
| |<-- POST /api/webhook/ -----| (callback_query) |
| | telegram | |
| |-- parse callback_data | |
| |-- UPDATE registrations | |
| | SET status='approved' | |
| |-- answerCallbackQuery ─────>| |
| |-- editMessageText ─────────>| |
| |-- create_task | |
| | send_push() ─────────────────────────────────────>|
| | | [Push: "Одобрен!"] |
|<-- 200 {"ok": True} ------| | |
```
## API контракт
### POST /api/auth/register
**Request body:**
```json
{
"email": "user@example.com",
"login": "user_name",
"password": "securepass",
"push_subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BNcR...",
"auth": "tBHI..."
}
}
}
```
`push_subscription` — nullable. Если null, push при одобрении не отправляется.
**Validation:**
- `email`: формат email (Pydantic EmailStr или regex `[^@]+@[^@]+\.[^@]+`)
- `login`: 330 символов, `[a-zA-Z0-9_-]`
- `password`: минимум 8 символов
- `push_subscription`: nullable object
**Response 201:**
```json
{"status": "pending", "message": "Заявка отправлена на рассмотрение"}
```
**Response 409 (дубль email или login):**
```json
{"detail": "Пользователь с таким email или логином уже существует"}
```
**Response 429:** rate limit (через существующий `rate_limit_register` middleware)
**Response 422:** невалидные поля (Pydantic автоматически)
---
### POST /api/webhook/telegram (расширение)
Существующий эндпоинт. Добавляется ветка обработки `callback_query`:
**Входящий update (approve):**
```json
{
"callback_query": {
"id": "123456789",
"data": "approve:42",
"message": {
"message_id": 777,
"chat": {"id": 5694335584}
}
}
}
```
**Поведение при `approve:{id}`:**
1. `UPDATE registrations SET status='approved' WHERE id=?`
2. Fetch registration row (для получения login и push_subscription)
3. `answerCallbackQuery(callback_query_id)`
4. `editMessageText(chat_id, message_id, "✅ Пользователь {login} одобрен")`
5. Если `push_subscription IS NOT NULL``create_task(send_push(...))`
6. Вернуть `{"ok": True}`
**Поведение при `reject:{id}`:**
1. `UPDATE registrations SET status='rejected' WHERE id=?`
2. `answerCallbackQuery(callback_query_id)`
3. `editMessageText(chat_id, message_id, "❌ Пользователь {login} отклонён")`
4. Push НЕ отправляется
5. Вернуть `{"ok": True}`
---
## SQL миграция
```sql
-- В init_db(), добавить в executescript:
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
login TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
push_subscription TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_email
ON registrations(email);
CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_login
ON registrations(login);
CREATE INDEX IF NOT EXISTS idx_registrations_status
ON registrations(status);
```
Таблица создаётся через `CREATE TABLE IF NOT EXISTS` — backward compatible, не ломает существующие БД.
---
## Список изменяемых файлов
| Файл | Тип изменения | Суть |
|------|--------------|------|
| `backend/db.py` | Modify | Добавить таблицу `registrations` в `init_db()` + 3 функции CRUD |
| `backend/config.py` | Modify | Добавить `ADMIN_CHAT_ID`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_EMAIL` |
| `backend/models.py` | Modify | Добавить `PushKeys`, `PushSubscription`, `AuthRegisterRequest`, `AuthRegisterResponse` |
| `backend/telegram.py` | Modify | Добавить `send_registration_notification()`, `answer_callback_query()`, `edit_message_text()` |
| `backend/main.py` | Modify | Добавить `POST /api/auth/register` + callback_query ветку в webhook |
| `backend/push.py` | **New** | Отправка Web Push через pywebpush |
| `requirements.txt` | Modify | Добавить `pywebpush>=2.0.0` |
| `tests/test_baton_008.py` | **New** | Тесты для нового flow |
**НЕ трогать:** `backend/middleware.py`, `/api/register`, `users` таблица.
**Замечание:** CORS `allow_headers` уже содержит `Authorization` в `main.py:122` — изменение не требуется.
---
## Интеграционные точки с существующим кодом
### 1. `_hash_password()` в `main.py`
Функция уже существует (строки 4148). Dev agent должен **переиспользовать её напрямую** в новом endpoint `POST /api/auth/register`, не дублируя логику.
### 2. `rate_limit_register` middleware
Существующий middleware из `backend/middleware.py` может быть подключён к новому endpoint как `Depends(rate_limit_register)` — тот же ключ `reg:{ip}`, та же логика.
### 3. `telegram.send_message()` — не модифицировать
Существующая функция использует `config.CHAT_ID` для SOS-сигналов. Для регистрационных уведомлений создаётся отдельная функция `send_registration_notification()`, которая использует `config.ADMIN_CHAT_ID`. Это разделяет два потока уведомлений.
### 4. Webhook handler (строки 223242 в `main.py`)
Добавляется ветка в начало функции (до `message = update.get("message", {})`):
```python
callback_query = update.get("callback_query")
if callback_query:
asyncio.create_task(_handle_callback_query(callback_query))
return {"ok": True}
```
Существующая логика `/start` остаётся нетронутой.
### 5. `lifespan` в `main.py`
Никаких изменений — VAPID-ключи не требуют startup validation (unlike BOT_TOKEN), так как их инвалидация некритична для работы сервиса в целом.
---
## Спецификация новых компонентов
### `backend/db.py` — 3 новые функции
```
create_registration(email, login, password_hash, push_subscription) -> dict | None
INSERT INTO registrations ...
ON CONFLICT → raise aiosqlite.IntegrityError (caller catches → 409)
Returns: {"id", "email", "login", "created_at"}
get_registration_by_id(reg_id: int) -> dict | None
SELECT id, email, login, status, push_subscription FROM registrations WHERE id=?
update_registration_status(reg_id: int, status: str) -> dict | None
UPDATE registrations SET status=? WHERE id=?
Returns registration dict or None if not found
```
### `backend/config.py` — новые переменные
```python
ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584")
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")
```
Все optional (не `_require`) — отсутствие VAPID только отключает Web Push, не ломает сервис.
### `backend/models.py` — новые Pydantic модели
```python
class PushKeys(BaseModel):
p256dh: str
auth: str
class PushSubscription(BaseModel):
endpoint: str
keys: PushKeys
class AuthRegisterRequest(BaseModel):
email: str = Field(..., pattern=r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$')
password: str = Field(..., min_length=8)
push_subscription: Optional[PushSubscription] = None
class AuthRegisterResponse(BaseModel):
status: str
message: str
```
### `backend/telegram.py` — 3 новые функции
```
send_registration_notification(login, email, reg_id, created_at) -> None
POST sendMessage с reply_markup=InlineKeyboardMarkup
chat_id = config.ADMIN_CHAT_ID
Swallows все ошибки (decision #1215)
answer_callback_query(callback_query_id: str, text: str = "") -> None
POST answerCallbackQuery
Swallows все ошибки
edit_message_text(chat_id: str | int, message_id: int, text: str) -> None
POST editMessageText
Swallows все ошибки
```
Все три используют тот же паттерн retry (3 попытки, 429/5xx) что и `send_message()`.
### `backend/push.py` — новый файл
```
send_push(subscription_json: str, title: str, body: str) -> None
Парсит subscription_json → dict
webpush(
subscription_info=subscription_dict,
data=json.dumps({"title": title, "body": body, "icon": "/icon-192.png"}),
vapid_private_key=config.VAPID_PRIVATE_KEY,
vapid_claims={"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL}"}
)
Если VAPID_PRIVATE_KEY пустой → log warning, return (push disabled)
Swallows WebPushException и все прочие ошибки
```
---
## Edge Cases и решения
| Кейс | Решение |
|------|---------|
| Email уже зарегистрирован | `IntegrityError` → HTTP 409 |
| Login уже занят | `IntegrityError` → HTTP 409 |
| Rejected пользователь пытается зарегистрироваться заново | 409 (статус не учитывается — оба поля UNIQUE) |
| push_subscription = null при approve | `if reg["push_subscription"]: send_push(...)` — skip gracefully |
| Истёкший/невалидный push endpoint | pywebpush raises → `logger.warning()` → swallow |
| Двойной клик Одобрить (admin кликает дважды) | UPDATE выполняется (idempotent), editMessageText может вернуть ошибку (уже отредактировано) → swallow |
| reg_id не существует в callback | `get_registration_by_id` returns None → log warning, answerCallbackQuery всё равно вызвать |
| VAPID ключи не настроены | Push не отправляется, log warning, сервис работает |
| Telegram недоступен при регистрации | Fire-and-forget + swallow — пользователь получает 201, уведомление теряется |
---
## Решения по open questions (из context_packet)
**VAPID ключи не сгенерированы:** Dev agent добавляет в README инструкцию по генерации:
```bash
python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print(v.private_key, v.public_key)"
```
Ключи добавляются в `.env` вручную оператором перед деплоем.
**Повторный approve/reject:** Операция idempotent — UPDATE всегда выполняется без проверки текущего статуса. EditMessageText вернёт ошибку при повторном вызове — swallow.
**Service Worker:** Фронтенд вне скоупа этого тикета. Backend отправляет корректный Web Push payload — обработка на стороне клиента.
**Login после approve:** Механизм авторизации не входит в BATON-008. Регистрация — отдельный flow от аутентификации.