Рецепты для контрибьюторов
Пошаговые рецепты для трёх самых частых изменений. Каждый рецепт заканчивается одинаково: прогоните гейты (CONTRIBUTING §1) и дайте контрактному тесту / drift-guard подтвердить, что всё связано правильно.
Это рецепты, а не справочник. Справочник лежит здесь:
- docs/architecture.md — обзор системы
- docs/architecture/http-api.md — справочник HTTP-контракта
- docs/architecture/events.md — сгенерированный каталог событий
- docs/development/plugins.md, plugin-contract.md — API плагинов
- CONTRIBUTING.md — соглашения и неприкосновенные правила
Добавить платёжного провайдера
Заголовок раздела «Добавить платёжного провайдера»Провайдер — это один пакет в backend/bot/payment_providers/<name>/. Файл пакета
__init__.py — это тонкий фасад (явный __all__, без реэкспорта
stdlib/фреймворка); реализация живёт в сфокусированных модулях. Тест на
конформность (tests/providers/test_provider_conformance.py) — это ваш чеклист:
он падает с конкретным сообщением, пока каждый пункт ниже не выполнен.
-
Создайте
config.py— подклассProviderEnvConfigсо своимenv_prefix(например,MYPAY_) и полями, плюс опциональную presentation-модель. Модуль провайдера — единственный источник истины для своих env-переменных; глобальныйSettingsправить не нужно. -
Создайте
service.py— классService. Для провайдера с hosted-link наследуйтесь отHttpClientMixin; реализуйтеcreate_payment(...),try_reuse_pending_payment(...), подпись и (если он принимает колбэки)webhook_route(self, request). Держите границу безопасности явной: отключён →503, неверная подпись / неавторизованный IP →403, платёж не найден →404. -
Свяжите поток callback/webapp. Если провайдер — это единообразный hosted-link редирект, оркестрацию руками писать не нужно: объявите
LinkPaymentDescriptor(shared/link_flow.py) и делегируйте:Смотрите
severpay/lava/heleketкак образец. Если тайминг колбэка, семантика инвойса или контракт create-вызова действительно отличаются — оставьте поток bespoke и добавьте имя провайдера вLINKFLOW_BESPOKEв тесте на конформность с однострочной причиной, называющей расхождение; не подгоняйте движок под исключение. -
Объявите
SPEC(PaymentProviderSpec) со стабильнымиid,provider_key,pending_status, webhook-путём, точками входа и manifest-полями. Строкиid/provider_key/ webhook-path персистятся — выберите их один раз и никогда не переименовывайте. -
Зарегистрируйте провайдера в
backend/bot/payment_providers/registry.py(PAYMENT_PROVIDER_SPECS). -
Держите
__init__.pyтонким фасадом — реэкспортируйте толькоSPEC(+ варианты specs), классыService/Config/Presentation,create_service, webapp-фабрики, webhook-маршрут, callback-обработчик иrouter. Объявите явный__all__. Ratchet на гигиену фасадов отвергает протёкшие stdlib/фреймворк/type-символы. -
Покройте тестами. Добавьте тесты провайдера рядом с остальными; патчите внутренние символы (
payment_dal,finalize_successful_payment, …) на подмодуле.service, а не через фасад (CONTRIBUTING §5 — патч фасада молча ничего не делает). -
Прогоните гейты.
python -m pytest tests/providers -q— тест на конформность подскажет, должны ли вы ещё добавить descriptor/bespoke-причину, webhook-профиль или поправить фасад. Затем полныйmake check.
Добавить доменное событие
Заголовок раздела «Добавить доменное событие»События — это плоские dict-уведомления на внутрипроцессной шине. Шина никогда не кидает исключений и намеренно не валидируется; расхождение ловит типизированная модель.
-
Добавьте payload-модель в
backend/bot/infra/event_payloads.py— подклассEventPayload(он задаётextra="forbid"), объявитеEVENT_NAMEи типизируйте каждое поле. Для меток времени используйтеdatetime(сериализуется в ISO-8601 автоматически) иOptional[...] = None, гдеNone— валидное значение. Payload’ы — только примитивы, никогда не ORM-объекты. Подписчики перечитывают богатые данные по id. -
Опубликуйте в источнике:
await events.emit_model(MyEvent(...)).emitникогда не кидает исключений, поэтому падение подписчика не может сломать вызывающий код. -
Подпишитесь (опционально) — подписчики получают сырую сигнатуру
(event_name: str, payload: dict); этот контракт заморожен, типизированная модель — additive-удобство. -
Регенерируйте каталог в том же коммите:
PYTHONPATH=backend python -m bot.infra.event_catalog→docs/architecture/events.md. Никогда не правьте его руками. -
Прогоните гейты.
tests/contracts/test_domain_events.pyи тест на точность документации падают, если модель и каталог разошлись.
Добавить HTTP-эндпоинт
Заголовок раздела «Добавить HTTP-эндпоинт»API — это рукописный aiohttp с типизированными request/response-моделями и
сгенерированным OpenAPI-контрактом. FastAPI нет — не вводите его. Конверт
{"ok": …} — это wire-инвариант.
-
Зарегистрируйте маршрут явно в
setup_subscription_webapp_routes(bot/app/web/webapp/routes.py) илиsetup_admin_routes(bot/app/web/admin_api_impl/routes.py). Плагины добавляют маршруты в рантайме черезPlugin.setup_web. -
Распарсите тело против
HttpBodyModelчерезparse_body_or_400(request, MyRequest)(bot/app/web/request_parsing.py) — не_read_jsonдля новых эндпоинтов. -
Соберите ответ через подкласс
HttpResponseModelи явный classmethodfrom_orm_*, который читает только уже загруженные скалярные атрибуты (напримерobj.__dict__.get("field")). Никогда не включайте автосканfrom_attributes— он триггерит lazy-load после закрытия сессии. Оборачивайте результат конвертом_ok(...)/_error(...). -
Зарегистрируйте контракт в
bot/app/web/route_contracts.py, чтобы маршрут попал вopenapi.json. Не-JSON маршруты (CSV/бинарь/multipart) объявляют content-type вместо JSON-модели. -
Регенерируйте оба артефакта в том же коммите:
PYTHONPATH=backend python -m bot.app.web.openapi(→docs/openapi.json), затемnpm --prefix frontend run generate:api-types(→frontend/src/lib/api/openapi.generated.ts). -
Потребляйте на фронтенде через типизированный клиент (
lib/webapp/publicApi.ts): зовитеapi("/api/...")с литеральной строкой пути и распаковывайте конверт черезunwrap(...). -
Прогоните гейты.
tests/test_openapi_artifact.pyи frontend drift-guard (git diff --exit-codeпо сгенерированным типам) падают, если контракт и артефакты разошлись.