subgate — бекенд платного доступу до Telegram-каналу
Огляд
Незалежний demo-проєкт: REST API на Go для платного доступу до Telegram-каналу — підписники, канали з обмеженим доступом, ідемпотентний прийом платіжних вебхуків та стейт-машина членства (active → grace → expired). Моделює патерн, що стоїть за ботами платного доступу: платіжний шлюз викликає вебхук при успішній оплаті, і бекенд має перетворити цей виклик на “надати/продовжити доступ до каналу” рівно один раз — навіть коли шлюз повторює доставку, а запланована перевірка закінчення терміну спрацьовує тієї ж миті.
Результат: рівно одноразова обробка платежу і при повторно доставленому вебхуку, і при паралельному sweep-і закінчення терміну — реальні режими відмов платіжних callback-ів і cron-driven даунгрейдів, не гіпотетичні — доведено наживо на 10 одночасних однакових вебхук-доставках і 5 одночасних поновленнях, що гонять проти 5 одночасних sweep-ів.
Справжня складна задача
Платіжні шлюзи повторно доставляють сповіщення “оплата успішна” — таймаут на боці шлюзу вже після того, як сервер обробив і повернув 200 на першу доставку, запускає повторну спробу. Наївна обробка продовжує підписку або надає доступ до каналу один раз на доставку замість одного разу на платіж. Окремо: запланований sweep закінчення терміну і щойно приземлене поновлення можуть гнатися за те саме членство — sweep ніколи не повинен знижувати доступ, який щойно коректно продовжили.
Архітектура

Кожна доставка вебхука проходить одну транзакцію: захопити advisory lock (subscriber_id, channel_id) → вставка-або-ігнор платежу → пошук періоду білінгу каналу → upsert членства, продовжуючи expires_at від того, що пізніше — поточного значення чи дати платежу → коміт. Sweep закінчення терміну бере той самий замок на кожне членство перед повторним читанням і зниженням статусу — саме це й закриває гонку проти поновлення, що виконується паралельно.
Ключові інженерні рішення
| Рішення | Чому |
|---|---|
payments.UNIQUE(gateway, external_payment_id) + ON CONFLICT DO NOTHING |
Повторно доставлений вебхук поглинається на рівні БД — жодного дубльованого рядка платежу, жодної подвійно продовженої підписки |
pg_advisory_xact_lock(subscriber_id, channel_id) — нативний two-key overload Postgres, не хешований єдиний ключ |
Кожне поновлення платежем і кожне рішення sweep-а для того самого членства серіалізуються; sweep, що дістається до членства посеред поновлення, блокується на замку і перечитує свіжий стан після його отримання, замість гонки за застарілим читанням |
NextSweepStatus винесена як чиста функція поточного статусу, expires_at, now та днів пільгового періоду |
Жодного I/O, повністю юніт-тестована — active→grace→expired рухається лише вперед; лише платіж скидає статус назад в active, а expired — задокументований термінальний стан, з якого sweep ніколи не воскрешає |
| Sweep виставлений як автентифікований ендпоінт, не підключений до реального cron | Тримає демо самодостатнім — поведінка даунгрейду запускається й перевіряється без залежності від планувальника, та сама логіка, що й виставлення ендпоінта вебхука замість вимоги реального акаунта платіжного шлюзу |
| Вебхук підписаний по-payload (HMAC-SHA256 над order reference + сумою + часовою міткою), не статичний shared-secret заголовок | Віддзеркалює, як реальний шлюз (WayForPay, обраний замість Stripe — він трапляється приблизно втричі частіше в даних попиту, на яких ґрунтується цей проєкт) насправді підписує callback-и, і означає, що підроблена сума інвалідує підпис |
Результати
- Повний REST API: JWT-авторизація, CRUD підписників/каналів/членств, підписаний
POST /api/webhook/payment(machine-to-machine, не дія персоналу) - Рішення про даунгрейд чисте й повністю юніт-тестоване — включно з термінальним станом і крайовим випадком нульового пільгового періоду — без потреби в БД для
go test ./... - Перевірено наскрізно через
docker compose up: міграції застосовуються, seed-логін, повна послідовність платіж → активне членство → заднім числом прострочений термін → sweep-до-grace поводиться згідно з документацією - 10 одночасних доставок ідентичного платіжного payload дали рівно 1 рядок платежу і рівно одне продовження членства — 1 відповідь з
duplicate:false, 9 зduplicate:true - 5 одночасних поновлень вебхуком, що гонять проти 5 одночасних sweep-ів закінчення терміну для того самого вже простроченого членства, детерміновано приземлились у стані
active, з коректно продовженимexpires_at— жодного разу не застрягли вgraceчиexpiredчерез sweep, що обігнав поновлення
Код: github.com/valpere/subgate
Стек
Go · chi (router) · pgx/v5 (без ORM) · golang-jwt · bcrypt · PostgreSQL (pg_advisory_xact_lock, make_interval) · Docker Compose · testify