baton/docs/DESIGN_BATON008.md

14 KiB
Raw Permalink Blame History

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:

{
  "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:

{"status": "pending", "message": "Заявка отправлена на рассмотрение"}

Response 409 (дубль email или login):

{"detail": "Пользователь с таким email или логином уже существует"}

Response 429: rate limit (через существующий rate_limit_register middleware)

Response 422: невалидные поля (Pydantic автоматически)


POST /api/webhook/telegram (расширение)

Существующий эндпоинт. Добавляется ветка обработки callback_query:

Входящий update (approve):

{
  "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 NULLcreate_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 миграция

-- В 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", {})):

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 — новые переменные

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 модели

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 инструкцию по генерации:

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 от аутентификации.