Client Personal Demo (Open Source)
Role Solo Creator / Architect
Period Aug 2026
Stack
Go chi pgx/v5 PostgreSQL JWT Telegram Bot API Docker Compose
subgate — бекенд платного доступу до Telegram-каналу preview

subgate — бекенд платного доступу до Telegram-каналу

Огляд

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

Результат: рівно одноразова обробка платежу і при повторно доставленому вебхуку, і при паралельному sweep-і закінчення терміну — реальні режими відмов платіжних callback-ів і cron-driven даунгрейдів, не гіпотетичні — доведено наживо на 10 одночасних однакових вебхук-доставках і 5 одночасних поновленнях, що гонять проти 5 одночасних sweep-ів.


Справжня складна задача

Платіжні шлюзи повторно доставляють сповіщення “оплата успішна” — таймаут на боці шлюзу вже після того, як сервер обробив і повернув 200 на першу доставку, запускає повторну спробу. Наївна обробка продовжує підписку або надає доступ до каналу один раз на доставку замість одного разу на платіж. Окремо: запланований sweep закінчення терміну і щойно приземлене поновлення можуть гнатися за те саме членство — sweep ніколи не повинен знижувати доступ, який щойно коректно продовжили.


Архітектура

Домен subgate: підписник + канал живлять membership, платіжний вебхук і 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