14 KiB
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: 3–30 символов,[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}:
UPDATE registrations SET status='approved' WHERE id=?- Fetch registration row (для получения login и push_subscription)
answerCallbackQuery(callback_query_id)editMessageText(chat_id, message_id, "✅ Пользователь {login} одобрен")- Если
push_subscription IS NOT NULL→create_task(send_push(...)) - Вернуть
{"ok": True}
Поведение при reject:{id}:
UPDATE registrations SET status='rejected' WHERE id=?answerCallbackQuery(callback_query_id)editMessageText(chat_id, message_id, "❌ Пользователь {login} отклонён")- Push НЕ отправляется
- Вернуть
{"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
Функция уже существует (строки 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", {})):
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 от аутентификации.