300 lines
14 KiB
Markdown
300 lines
14 KiB
Markdown
# 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`: 3–30 символов, `[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`
|
||
Функция уже существует (строки 41–48). 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 (строки 223–242 в `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 от аутентификации.
|