IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime18/69-dars22 daqiqa
Mundarija (29)

Bildirishnoma tizimi arxitekturasi: kanallar, dedup va limit

Qisqacha: Har modul o'zi email yoki SMS yuborsa, tez orada tartibsizlik boshlanadi: takror xatlar, o'chirib qo'yilgan kanalga yuborish, limitsiz SMS. To'g'ri yo'l — bitta bildirishnoma markazi: bildir(foydalanuvchi, hodisa) bazaga ilova ichidagi (in-app) yozuvni va har kanal uchun yetkazishlar qatorini bitta tranzaksiyada yozadi, yuborishni esa navbatdagi worker qiladi. Kanal foydalanuvchi afzalligi va manzil bor-yo'qligiga qarab tanlanadi, dedup_kalit takrorni to'xtatadi, soatlik limit spamni, FOR UPDATE esa ikki marta yuborishni.

Bu darsda

  • Bildirishnoma tizimining qismlarini (hodisa, kanal tanlash, yetkazish, in-app) chizasiz.
  • sql/202 sxemasini o'qiysiz: bildirishnomalar, yetkazishlar, kanal manzillari.
  • Dedup kaliti, soatlik limit va idempotent yetkazishni SQL va kodda ko'rasiz.
  • Kanal tanlash qoidasini o'zingiz yozasiz.
  • Kanon markaz.ts modulini va uning 7 testini o'z Postgres'ingizda ishga tushirasiz.

Oldin bilishingiz kerak: Tranzaksiya asoslari, Qulflar: row, table va advisory, JSONB, Keyset pagination, Email yuborish, SMS provayderlar, Push bildirishnomalar.

Oxirgi to'rt darsda uchta "yuboruvchi" yozdik: Pochta, EskizProvayder, WebPush. Keyingi darslarda to'rtinchisi — Telegram boti — keladi. Bugun ularni bitta tizimga yig'amiz.

1. Nega bu kerak?

"Non olish" vazifasining muddati yaqinlashdi. Kim, qayerga, nima yuborishi kerak?

  • Aziz email va Telegram'ni yoqqan, push yo'q.
  • Dilnoza email'ni o'chirgan, Telegram'ni ulamagan.
  • Sardor hech narsani sozlamagan.
  • Cron har 5 daqiqada muddatlarni tekshiradi — bir vazifa uchun ikki marta ishlab qolsa?
  • Worker ikkita nusxada ishlaydi — ikkalasi bir xatni olsa?
  • Telegram boti bloklangan — har safar xato olib turamizmi?

Agar har modul (vazifalar, izohlar, to'lovlar) o'zi yuborsa, bu savollarning har biriga har modulda alohida javob yoziladi — va bittasida unutiladi.

O'xshatish: kompaniyadagi devonxona. Har bo'lim o'zi pochtaga yugurmaydi. Hujjat devonxonaga beriladi, u ro'yxatga yozadi (raqam bilan — ikki marta yozilmaydi), har adresat qanday aloqani afzal ko'rishini biladi va kun oxirida jo'natadi. Kimga yetmagani ham daftarda.

2. Tuzilma

2.1 Qismlar

flowchart LR
  H[Hodisa: muddat yaqin] --> B[bildir]
  B --> DB[(bildirishnomalar + yetkazishlar)]
  B -->|COMMIT dan keyin| N[Navbat]
  N --> W[Worker: yetkaz]
  W --> E[Email]
  W --> T[Telegram]
  W --> P[Push]
  W --> S[SMS]
  DB --> A[Ilova: qo'ng'iroqcha]

Diagrammaga qarang: hodisa faqat bildir() ni chaqiradi va qaysi kanal ekanini bilmaydi. Markaz yozuvni bazaga qo'yadi. Bu yozuv ikki ishga xizmat qiladi:

  1. In-app — ilovadagi qo'ng'iroqcha belgisi va ro'yxat ("3 ta o'qilmagan").
  2. Yetkazish manbai — har kanal uchun alohida qator: email ketdimi, Telegram ketdimi.

Yuborishni esa boshqa jarayon — worker (ishchi) — qiladi. HTTP so'rov SMTP'ni kutib turmaydi.

2.2 Uch qoida

Qoida Nima uchun Qayerda
Yozuv va kanallar — bitta tranzaksiyada yarim yozilgan bildirishnoma bo'lmasin bildir()
Navbatga — COMMIT'dan keyin worker hali ko'rinmaydigan qatorni olmasin bildir() oxiri
Yetkazish — idempotent qayta ishlansa ham bir marta yuborilsin yetkaz()

3. Sxema: sql/202

3.1 Jadvallar

Kanon to'rtta jadval qo'shdi:

Jadval Nima saqlaydi
bildirishnomalar sarlavha, matn, ichki havola, dedup_kalit, oqilgan_vaqt
yetkazishlar (bildirishnoma, kanal) → holat, urinishlar, oxirgi xato
telegram_ulanishlar foydalanuvchi → chat_id (bigint), faol
push_obunalar endpoint (noyob, faqat https://), p256dh, auth

Asosiy jadvalning cheklovlari:

sql
CONSTRAINT bildirishnomalar_dedup_kalit_key
  UNIQUE (foydalanuvchi_id, dedup_kalit),
CONSTRAINT bildirishnomalar_tur_check
  CHECK (tur ~ '^[a-z]+(\.[a-z_]+)*$'),
CONSTRAINT bildirishnomalar_havola_check
  CHECK (havola IS NULL OR havola ~ '^/[^/]')

Afzalliklar uchun yangi jadval yo'q: ular 27-qismdagi foydalanuvchilar.sozlamalar JSONB ustunida — {"email": true, "telegram": false} (JSONB). Kalit yo'q bo'lsa — sukutdagi qiymat.

SQL misollarini biz o'z PostgreSQL 17.6 klasterimizda bajardik — kanon sql/001–204 (namuna ma'lumotlar bilan). Kanonning o'zi PostgreSQL 18.6 da; bu fayllar 17 da ham xatosiz qo'llandi.

sql
SELECT id, toliq_ism, sozlamalar->'bildirishnoma' AS bildirishnoma
FROM foydalanuvchilar WHERE id IN (1, 2, 3, 6) ORDER BY id;
text
 id |    toliq_ism     |           bildirishnoma
----+------------------+------------------------------------
  1 | Aziz Karimov     | {"email": true, "telegram": true}
  2 | Dilnoza Rahimova | {"email": false}
  3 | Sardor Tursunov  |
  6 | Gulnora Saidova  | {"email": true, "telegram": false}
(4 rows)

3.2 Dedup kaliti

Dedup (deduplication — takrorni olib tashlash) kerak bo'ladigan holat: cron har 5 daqiqada "muddati 1 soatdan kam vazifalar" ni topadi. 18:00 dagi vazifa 17:05 da ham, 17:10 da ham topiladi. Bildirishnoma bitta bo'lishi kerak. Yechim — hodisadan yasalgan kalit: vazifa:1:muddat:2026-10-28. Bir xil kalit ikkinchi marta kirmaydi:

sql
INSERT INTO bildirishnomalar
  (foydalanuvchi_id, tur, sarlavha, matn, havola, dedup_kalit)
VALUES (1, 'vazifa.muddat', 'Muddat yaqin', 'Non olish — 18:00',
        '/vazifalar/1', 'vazifa:1:muddat:2026-10-28')
ON CONFLICT (foydalanuvchi_id, dedup_kalit) DO NOTHING
RETURNING dedup_kalit;
text
        dedup_kalit
----------------------------
 vazifa:1:muddat:2026-10-28
(1 row)

INSERT 0 1

Xuddi shu so'rov ikkinchi marta:

sql
INSERT INTO bildirishnomalar
  (foydalanuvchi_id, tur, sarlavha, matn, havola, dedup_kalit)
VALUES (1, 'vazifa.muddat', 'Muddat yaqin', 'Non olish — 18:00',
        '/vazifalar/1', 'vazifa:1:muddat:2026-10-28')
ON CONFLICT (foydalanuvchi_id, dedup_kalit) DO NOTHING
RETURNING dedup_kalit;
text
 dedup_kalit
-------------
(0 rows)

INSERT 0 0

ON CONFLICT … DO NOTHING — xato yo'q, qator ham yo'q, RETURNING bo'sh (UPSERT va MERGE). Kod bo'sh natijani ko'rib "bu allaqachon bor" deb tushunadi va kanallarga hech narsa qo'shmaydi. Kalitda sana bor: ertangi muddat — yangi kalit, yangi bildirishnoma. Kalit NULL bo'lsa (tizim xabari), UNIQUE uni cheklamaydi — Postgres'da NULL lar teng emas.

3.3 Havola — faqat ichki yo'l

Bildirishnoma ichidagi havola ilovada bosiladi. Unga tashqi manzil tushsa — ochiq yo'naltirish (open redirect, Dasturiy navigatsiya va URL holati darsidagi xavf): "Hisobingiz bloklandi" bildirishnomasi fishing saytga olib boradi. Sxema buni bazaning o'zida to'xtatadi:

sql
\set VERBOSITY terse
INSERT INTO bildirishnomalar
  (foydalanuvchi_id, tur, sarlavha, matn, havola)
VALUES (1, 'tizim', 'Yutuq!', 'Bosing',
        'https://yomon.example/kirish');
INSERT INTO bildirishnomalar
  (foydalanuvchi_id, tur, sarlavha, matn, havola)
VALUES (1, 'tizim', 'Yutuq!', 'Bosing', '//yomon.example/kirish');
text
ERROR:  new row for relation "bildirishnomalar" violates check constraint "bildirishnomalar_havola_check"
ERROR:  new row for relation "bildirishnomalar" violates check constraint "bildirishnomalar_havola_check"

Ikkinchisi qiziqroq: //yomon.example — "protokolsiz URL", brauzer uni boshqa domen deb ochadi. ^/[^/] qoidasi: bitta / bilan boshlansin, ikkinchi belgi / bo'lmasin. Ilova kodi ham tekshiradi, lekin bazadagi cheklov — oxirgi qatlam: qaysi kod yozmasin, yomon havola saqlanmaydi.

3.4 Kanal manzillari

Kanal tanlash uchun markaz bitta so'rovda afzallik va manzillarni oladi. Aziz Telegram'ni ulagan deb faraz qilamiz:

sql
INSERT INTO telegram_ulanishlar (foydalanuvchi_id, chat_id)
VALUES (1, 900000001);
SELECT f.id,
       f.sozlamalar->'bildirishnoma' AS sozlama,
       EXISTS (SELECT 1 FROM telegram_ulanishlar t
               WHERE t.foydalanuvchi_id = f.id AND t.faol)
         AS telegram,
       EXISTS (SELECT 1 FROM push_obunalar p
               WHERE p.foydalanuvchi_id = f.id) AS push
FROM foydalanuvchilar f WHERE f.id IN (1, 2, 3) ORDER BY f.id;
text
INSERT 0 1
 id |              sozlama              | telegram | push
----+-----------------------------------+----------+------
  1 | {"email": true, "telegram": true} | t        | f
  2 | {"email": false}                  | f        | f
  3 |                                   | f        | f
(3 rows)

chat_id — bigint: Telegram ID'lari 52 bitgacha bo'lishi mumkin, int (32 bit) ga sig'maydi. Bot bloklansa (403) — faol = false, ulanish o'chmaydi: foydalanuvchi botni qayta ochsa, tiklash oson.

4. Kanal tanlash

Qoida uch qavat: afzallik (foydalanuvchi yoqqanmi), sukut (sozlamagan bo'lsa), manzil (kanalga yuborish mumkinmi). Kanon mantig'ining JS nusxasi:

js
const SUKUT = { email: true, telegram: true, push: true, sms: false };
const KANALLAR = ["email", "telegram", "push", "sms"];

function kanallarniTanla(sozlama, manzil, soralgan = KANALLAR) {
  const yoqilgan = (k) =>
    typeof sozlama?.[k] === "boolean" ? sozlama[k] : SUKUT[k];
  return soralgan.filter((k) => yoqilgan(k) && manzil[k]);
}

const hamma = { email: true, telegram: true, push: true, sms: true };
console.log(kanallarniTanla({ email: true, telegram: true }, hamma));
const tgsiz = { ...hamma, telegram: false };
console.log(kanallarniTanla({ email: false }, tgsiz));
console.log(kanallarniTanla(null, { ...hamma, push: false }));
console.log(kanallarniTanla({ sms: true }, hamma, ["sms"]));

Konsolda:

text
[ 'email', 'telegram', 'push' ]
[ 'push' ]
[ 'email', 'telegram' ]
[ 'sms' ]
  • Birinchi: push sozlanmagan, sukut true — kiradi.
  • Ikkinchi: email o'chirilgan, Telegram manzili yo'q — faqat push.
  • Uchinchi: sozlama null — sukutlar; SMS sukutda yo'q.
  • To'rtinchi: SMS faqat aniq yoqilganda va so'ralganda.

SMS sukutda o'chiq — u pullik. Kanon sxemasida telefon raqami hali yo'q (28-qism qo'shadi), shuning uchun modulda SMS manzili hozircha doim false.

typeof … === "boolean" — nega ?? SUKUT[k] emas? ?? faqat null/undefined ni almashtiradi. JSONB'ga xato bilan "email": "yo'q" satri tushsa, ?? uni "yoqilgan" (truthy) deb o'tkazardi. Turni tekshirish xavfsizroq.

Tekshirib ko'ring: Parolni tiklash xatini ham shu markaz orqali yuborish kerakmi? Foydalanuvchi email bildirishnomalarini o'chirgan bo'lsa-chi?

Javob

Parol tiklash — bildirishnoma emas, xavfsizlik xati: foydalanuvchi uni o'zi so'radi va email afzalligidan qat'i nazar olishi kerak. Uni markazning afzallik filtridan o'tkazmasdan to'g'ridan Pochta bilan (28-qismda) yuborish to'g'ri. Markaz — "eslatma, izoh, yangilik" kabi xabarlar uchun, foydalanuvchi o'chira oladigan narsalar uchun.

5. Yetkazish: idempotent va limitli

5.1 Ikki worker bitta ish

Navbat "kamida bir marta" (at-least-once) yetkazadi: worker ishni olib, yuborib, tasdiqlashdan oldin o'lsa, ish boshqa worker'ga qayta beriladi. Natija — ikki xat. Bundan tashqari, ikki worker bir xil ishni bir vaqtda olishi mumkin.

Kanon yetkaz() qatorni SELECT … FOR UPDATE OF y bilan qulflaydi (Qulflar). Ikkinchi worker shu qatorga kelganda birinchisi tugaguncha kutadi, keyin holat = 'yuborildi' ni ko'radi va "allaqachon" qaytaradi. Kanon testi: ikki parallel yetkaz → natijalar ["allaqachon", "yuborildi"], yuboruvchi bir marta chaqirilgan.

sequenceDiagram
  participant W1 as Worker 1
  participant DB as Postgres
  participant W2 as Worker 2
  participant T as Yuboruvchi (SMTP)
  W1->>DB: BEGIN, SELECT … FOR UPDATE (qatorni qulfladi)
  W2->>DB: BEGIN, SELECT … FOR UPDATE
  Note over W2,DB: kutadi — qator band
  W1->>T: xatni yuboradi
  T-->>W1: OK
  W1->>DB: holat = yuborildi, COMMIT
  DB-->>W2: qator bo'shadi, holat = yuborildi
  W2->>DB: COMMIT, natija "allaqachon"

Diagrammaga qarang: ikkinchi worker xato bermaydi, faqat kutadi. Qulf bo'shagach u yangilangan holatni ko'radi va hech narsa yubormaydi.

5.2 Soatlik limit

Kanal Soatiga (bitta foydalanuvchiga)
email 10
telegram 30
push 30
sms 3

Limitdan oshgani yuborilmaydi — otkazildi holati bilan qoladi. Hisoblash — oxirgi bir soatda yuborildi qatorlar soni. sql/202 dagi qisman indeks aynan shu so'rov uchun: ON yetkazishlar (kanal, yangilangan_vaqt) WHERE holat = 'yuborildi'.

Bu bitta foydalanuvchi uchun himoya: bug tufayli sikl 1 000 ta bildirishnoma yaratsa ham, Aziz soatiga 3 tadan ortiq SMS olmaydi va sizning hisobingiz bo'shamaydi.

5.3 Holatlar

stateDiagram-v2
  [*] --> navbatda
  navbatda --> yuborildi: yuboruvchi OK
  navbatda --> navbatda: qayta (429, 5xx)
  navbatda --> xato: doimiy xato yoki eskirgan manzil
  navbatda --> otkazildi: soatlik limit
  yuborildi --> [*]

Holatlar kanon CHECK cheklovidan: navbatda, yuborildi, xato, otkazildi. Har kanal mustaqil: email yuborildi, Telegram xato bo'lishi mumkin — ma'mur panelda aniq ko'rinadi. Noto'g'ri kanal nomi bazaga kirmaydi:

sql
\set VERBOSITY terse
INSERT INTO yetkazishlar (bildirishnoma_id, kanal)
SELECT id, 'whatsapp' FROM bildirishnomalar LIMIT 1;
text
ERROR:  new row for relation "yetkazishlar" violates check constraint "yetkazishlar_kanal_check"

5.4 Eskirgan manzil

Yuboruvchi eskirgan desa (Telegram 403 — bot bloklangan, push 410), markaz manzilni o'zi tozalaydi: Telegram ulanishi faol = false, push obunasi o'chiriladi. Keyingi bildirishnomada bu kanal tanlanmaydi — kanon testi buni tekshiradi.

5.5 Kanon tan olgan kamchilik

yetkaz() tashqi so'rovni (SMTP, Telegram API) tranzaksiya ichida, qulf ushlab turib qiladi. Afzalligi — takror yuborish imkonsiz. Narxi — yuborish davomida bitta baza ulanishi band. Hovuzda (pool) 10 ulanish bo'lsa va yuboruvchi timeout'i 10 s bo'lsa, eng yomon holatda 10 ta osilgan yuborish butun hovuzni egallaydi (Connection pooling).

Muqobil — "claim" naqshi: qisqa tranzaksiyada holatni yuborilmoqda ga o'zgartirib, darhol COMMIT; yuborish — tranzaksiyasiz; natija — ikkinchi qisqa tranzaksiyada. Bunda ulanish band bo'lmaydi, lekin jarayon yuborishdan keyin o'lsa, "yuborilmoqda" da qolgan ishni qayta tiklash mantiqi kerak. Kanon soddalikni tanladi va bu kamchilikni ochiq yozdi.

5.6 Navbatga tushmay qolganlar

bildir() avval COMMIT qiladi, keyin navbatga qo'shadi. Ikkalasi orasida jarayon o'lsa — bazada navbatda qatori bor, navbatda esa ish yo'q. Kanon qolibKetganlar(300) — 5 daqiqadan eski navbatda qatorlarni topadi; rejali ish ularni qayta navbatga qo'yadi. Ya'ni yetkazishlar jadvalining o'zi outbox vazifasini bajaradi — bu naqsh Transactional outbox darsida to'liq.

6. In-app: qo'ng'iroqcha

Bildirishnomalar ro'yxati ilovada ham ko'rinadi. Kanon uchta funksiya berdi:

  • oqilmaganSoni(fId) — qo'ng'iroqchadagi raqam; qisman indeks WHERE oqilgan_vaqt IS NULL bilan tez.
  • royxat(fId, limit, oldin) — keyset sahifalash ((yaratilgan_vaqt, id) < ($2, $3)) — Keyset pagination.
  • oqildi(fId, idlar | "hammasi") — o'qilgan deb belgilash; qayta chaqiruv 0 qaytaradi.

Har so'rovda foydalanuvchi_id sharti bor, ustiga sxemada RLS (Row Level Security — 27-qism) — ilova roli faqat o'z qatorlarini ko'radi. yetkazishlar ni esa ilova roli faqat o'qiydi: unga faqat worker yozadi.

Real vaqtda yangi bildirishnomani ko'rsatish — Server-Sent Events darsida.

7. Hujumchi nigohi

Hujum Natijasi Himoya
bildirishnomaga tashqi havola fishing yo'naltirish CHECK (havola ~ '^/[^/]')
boshqa foydalanuvchining ro'yxatini so'rash ma'lumot sizishi foydalanuvchi_id sharti + RLS
"izoh yozish" orqali 1 000 ta bildirishnoma spam, SMS xarajati soatlik limit, SMS sukutda o'chiq
matnda HTML XSS ilovada yoki emailda matn sifatida chiqarish, ekranlash

Uchinchi qator: hujumchi Azizning vazifasiga 1 000 ta izoh yozadi, har izoh — bildirishnoma. Limitsiz tizim Azizning telefonini va sizning SMS hisobingizni portlatadi. Limit kanal bo'yicha, foydalanuvchi bo'yicha — eng arzon himoya.

8. Ko'p uchraydigan xatolar

Xato Natija To'g'risi
har modul o'zi yuboradi afzalliklar va limitlar har joyda boshqacha bitta markaz
HTTP so'rov ichida SMTP'ni kutish sekin javob, timeout navbat + worker
navbatga COMMIT'dan oldin qo'yish worker qatorni topmaydi COMMIT'dan keyin
dedup kalitsiz cron takror bildirishnomalar UNIQUE (foydalanuvchi_id, dedup_kalit)
yuborildi ni tekshirmasdan yuborish ikki xat FOR UPDATE + holat tekshiruvi
bloklangan botga yuborishda davom etish doimiy xatolar faol = false
chat_id int katta ID sig'maydi bigint

9. Mashqlar

Mashqlar kurs/mashqlar/29/18-bildirishnoma/ da.

1-mashq (oson): bo'shliqlarni to'ldiring

  • Takroriy bildirishnomani to'xtatuvchi ustun —
  • Ikki worker bir qatorni bir vaqtda yubormasligi uchun SQL — SELECT … FOR
  • Limitdan oshgan yetkazish holati —
  • Bot bloklanganda telegram_ulanishlar dagi o'zgaradigan ustun —
  • Sukutda o'chiq kanal —
Yechim
  • dedup_kalit — UNIQUE (foydalanuvchi_id, dedup_kalit).
  • UPDATE — FOR UPDATE OF y: faqat yetkazishlar qatori qulflanadi.
  • otkazildi — xato emas, ataylab yuborilmadi.
  • faol — false; ulanish o'chirilmaydi.
  • sms — pullik, faqat aniq yoqilsa.

2-mashq (o'rta): jim soatlar

Foydalanuvchi "22:00 dan 08:00 gacha bezovta qilmang" deydi. jimSoatmi(soat, daqiqa, jim) yozing: jim = { boshi: "22:00", oxiri: "08:00" }. Oraliq yarim tundan o'tishi ham, o'tmasligi ham mumkin (13:00–14:00). boshi — kiradi, oxiri — kirmaydi. Ishora: vaqtni "kun boshidan daqiqalar"ga aylantiring; boshi > oxiri — yarim tundan o'tgan oraliq.

Yechim
js
function jimSoatmi(soat, daqiqa, jim) {
  const hozir = soat * 60 + daqiqa;
  const [bs, bd] = jim.boshi.split(":").map(Number);
  const [os, od] = jim.oxiri.split(":").map(Number);
  const boshi = bs * 60 + bd;
  const oxiri = os * 60 + od;
  if (boshi <= oxiri) return hozir >= boshi && hozir < oxiri;
  return hozir >= boshi || hozir < oxiri;
}

const tun = { boshi: "22:00", oxiri: "08:00" };
const tush = { boshi: "13:00", oxiri: "14:00" };
console.log(jimSoatmi(23, 30, tun), jimSoatmi(7, 59, tun));
console.log(jimSoatmi(8, 0, tun), jimSoatmi(12, 0, tun));
console.log(jimSoatmi(13, 30, tush), jimSoatmi(14, 0, tush));

Konsolda:

text
true true
false false
true false

Yarim tundan o'tgan oraliqda shart yoki bilan: 23:30 — "22:00 dan keyin", 07:59 — "08:00 dan oldin". Oddiy oraliqda va bilan. Bu mantiq kanonda yo'q — kengaytma. Jim soatda bildirishnoma o'chmaydi: in-app yozuv qoladi, push va SMS esa ertalabgacha kechiktiriladi (navbatda kechiktirilgan ish — BullMQ asoslari). Soat foydalanuvchining vaqt mintaqasida hisoblanadi.

3-mashq (qiyin): soatlik limit

yubor(tarix, kanal, hozir, limit) yozing: tarix — { kanal, vaqt } yozuvlar massivi (vaqt — millisekund). Oxirgi bir soatda shu kanal bo'yicha limit ta yoki ko'p yozuv bo'lsa — "otkazildi", aks holda yozuv qo'shilib "yuborildi". Oltita yuborishni sinang: SMS limiti 3, to'rtinchi SMS — o'tkaziladi, email — boshqa kanal, 61 daqiqadan keyingi SMS — yana ruxsat. Ishora: filter(…).length (filter).

Yechim
js
const SOAT = 60 * 60 * 1000;

function limitTekshir(tarix, kanal, hozir, limit) {
  const soni = tarix.filter(
    (y) => y.kanal === kanal && y.vaqt > hozir - SOAT,
  ).length;
  return soni < limit;
}

function yubor(tarix, kanal, hozir, limit) {
  if (!limitTekshir(tarix, kanal, hozir, limit)) return "otkazildi";
  tarix.push({ kanal, vaqt: hozir });
  return "yuborildi";
}

const tarix = [];
const t0 = Date.UTC(2026, 9, 28, 9, 0);
const daq = 60 * 1000;
console.log(yubor(tarix, "sms", t0, 3));
console.log(yubor(tarix, "sms", t0 + 10 * daq, 3));
console.log(yubor(tarix, "sms", t0 + 20 * daq, 3));
console.log(yubor(tarix, "sms", t0 + 30 * daq, 3));
console.log(yubor(tarix, "email", t0 + 30 * daq, 3));
console.log(yubor(tarix, "sms", t0 + 61 * daq, 3));
console.log(tarix.length);

Konsolda:

text
yuborildi
yuborildi
yuborildi
otkazildi
yuborildi
yuborildi
5

61-daqiqada birinchi SMS (09:00) "bir soat ichida" dan chiqdi — oyna siljidi (sliding window). otkazildi tarixga yozilmaydi: aks holda o'tkazilganlar ham limitni yeb, oyna hech qachon bo'shamasdi. Kanon SQL'da xuddi shunday: faqat holat = 'yuborildi' sanaladi. Bitta jarayon xotirasidagi massiv ko'p nusxali serverda ishlamaydi — shuning uchun kanonda hisob bazada (Rate limiting — Redis varianti).

10. Loyiha qadami: markaz.ts va sql/202

Kanon modul — modullar/src/bildirishnoma/markaz.ts. U Postgres'ga (pg Pool) va Navbat interfeysiga tayanadi; navbatning o'zi (BullMQ) BullMQ asoslari darsida ulanadi. Turlar:

ts
export type Kanal = "email" | "telegram" | "push" | "sms";
export type YetkazishIshi = { bildirishnomaId: number; kanal: Kanal };
export interface Navbat {
  qosh(ishlar: YetkazishIshi[]): Promise<void>;
}
export type YuborishNatija =
  | { holat: "yuborildi" }
  | { holat: "eskirgan"; pushId?: number }
  | { holat: "qayta"; sabab: string }
  | { holat: "xato"; sabab: string };
export type Yuboruvchi =
  (q: Qabul, x: Xabar) => Promise<YuborishNatija>;

Yuboruvchi — oddiy funksiya: qabul qiluvchi va xabarni olib, to'rt natijadan birini qaytaradi. Pochta, WebPush, Telegram boti — har biri shu shaklga o'raladi (loyihaga ulanganda). Markaz ular haqida hech narsa bilmaydi.

Klassning asosiy qismi — bildir, kanal tanlash va yetkaz. Telefon uchun uzun qatorlarni va SQL matnlarini bo'ldik, ma'nosi kanondagidek:

ts
export class BildirishnomaMarkazi {
  readonly #db: Pool;
  readonly #navbat: Navbat;
  readonly #limit: Record<Kanal, number>;
  constructor(
    db: Pool,
    navbat: Navbat,
    limit: Partial<Record<Kanal, number>> = {},
  ) {
    this.#db = db;
    this.#navbat = navbat;
    this.#limit = { ...SOATLIK_LIMIT, ...limit };
  }

  /** Bildirishnoma yaratadi. `dedupKalit` bilan takror chaqiruv —
   * yangi yozuv ham, yangi yuborish ham yo'q (`yangi: false`). */
  async bildir(fId: number, x: BildirKirish): Promise<BildirNatija> {
    const c = await this.#db.connect();
    let natija: BildirNatija;
    try {
      await c.query("BEGIN");
      const r = await c.query<{ id: string }>(
        `INSERT INTO bildirishnomalar
           (foydalanuvchi_id, tur, sarlavha, matn, havola,
            dedup_kalit)
         VALUES ($1, $2, $3, $4, $5, $6)
         ON CONFLICT (foydalanuvchi_id, dedup_kalit) DO NOTHING
         RETURNING id`,
        [fId, x.tur, x.sarlavha, x.matn, x.havola ?? null,
          x.dedupKalit ?? null],
      );
      const yangiId = r.rows[0]?.id;
      if (yangiId === undefined) {
        const eski = await c.query<{ id: string }>(
          `SELECT id FROM bildirishnomalar
           WHERE foydalanuvchi_id = $1 AND dedup_kalit = $2`,
          [fId, x.dedupKalit],
        );
        await c.query("COMMIT");
        const id = Number(eski.rows[0]?.id);
        return { id, yangi: false, kanallar: [] };
      }
      const kanallar = await this.#kanallarniTanla(
        c, fId, x.kanallar ?? KANALLAR,
      );
      if (kanallar.length > 0) {
        await c.query(
          `INSERT INTO yetkazishlar (bildirishnoma_id, kanal)
           SELECT $1, unnest($2::text[])`,
          [yangiId, kanallar],
        );
      }
      await c.query("COMMIT");
      natija = { id: Number(yangiId), yangi: true, kanallar };
    } catch (e) {
      await c.query("ROLLBACK");
      throw e;
    } finally {
      c.release();
    }
    // COMMIT'dan KEYIN: navbat bazadagi qatorni ko'rishi aniq.
    // Bu yerda jarayon o'lsa — qolibKetganlar() topadi.
    if (natija.kanallar.length > 0) {
      await this.#navbat.qosh(natija.kanallar.map(
        (kanal) => ({ bildirishnomaId: natija.id, kanal }),
      ));
    }
    return natija;
  }

  async #kanallarniTanla(
    c: PoolClient, fId: number, soralgan: readonly Kanal[],
  ): Promise<Kanal[]> {
    const r = await c.query<{
      sozlama: Record<string, unknown> | null;
      telegram: boolean;
      push: boolean;
    }>(
      `SELECT f.sozlamalar->'bildirishnoma' AS sozlama,
              EXISTS (SELECT 1 FROM telegram_ulanishlar t
                      WHERE t.foydalanuvchi_id = f.id AND t.faol)
                AS telegram,
              EXISTS (SELECT 1 FROM push_obunalar p
                      WHERE p.foydalanuvchi_id = f.id) AS push
         FROM foydalanuvchilar f WHERE f.id = $1`,
      [fId],
    );
    const q = r.rows[0];
    if (!q) return [];
    const yoqilgan = (k: Kanal) => {
      const v = q.sozlama?.[k];
      return typeof v === "boolean" ? v : SUKUT[k];
    };
    // telefon raqami sxemada hali yo'q (28-qism)
    const manzil: Record<Kanal, boolean> = {
      email: true, telegram: q.telegram, push: q.push, sms: false,
    };
    return soralgan.filter((k) => yoqilgan(k) && manzil[k]);
  }

  /** Worker chaqiradi: bitta (bildirishnoma, kanal) ni yetkazadi.
   * Idempotent: allaqachon `yuborildi` bo'lsa — hech narsa
   * qilmaydi. */
  async yetkaz(
    ish: YetkazishIshi, yuboruvchilar: Yuboruvchilar,
  ): Promise<YetkazNatija> {
    const c = await this.#db.connect();
    try {
      await c.query("BEGIN");
      // Qatorni qulflaymiz: ikki worker bir ishni bir vaqtda olsa —
      // ikkinchisi kutadi va "allaqachon" ni ko'radi
      const r = await c.query<{
        holat: string; foydalanuvchi_id: string; tur: string;
        sarlavha: string; matn: string; havola: string | null;
        email: string; chat_id: string | null;
      }>(
        `SELECT y.holat, b.foydalanuvchi_id, b.tur, b.sarlavha,
                b.matn, b.havola, f.email, t.chat_id
           FROM yetkazishlar y
           JOIN bildirishnomalar b ON b.id = y.bildirishnoma_id
           JOIN foydalanuvchilar f ON f.id = b.foydalanuvchi_id
           LEFT JOIN telegram_ulanishlar t
             ON t.foydalanuvchi_id = f.id AND t.faol
          WHERE y.bildirishnoma_id = $1 AND y.kanal = $2
          FOR UPDATE OF y`,
        [ish.bildirishnomaId, ish.kanal],
      );
      const q = r.rows[0];
      if (!q || q.holat === "yuborildi" || q.holat === "otkazildi") {
        await c.query("COMMIT");
        return "allaqachon";
      }
      const fId = Number(q.foydalanuvchi_id);
      const soni = await c.query<{ n: number }>(
        `SELECT count(*)::int AS n FROM yetkazishlar y
           JOIN bildirishnomalar b ON b.id = y.bildirishnoma_id
          WHERE b.foydalanuvchi_id = $1 AND y.kanal = $2
            AND y.holat = 'yuborildi'
            AND y.yangilangan_vaqt > now() - interval '1 hour'`,
        [fId, ish.kanal],
      );
      const yangila = (holat: string, xato: string | null) =>
        c.query(
          `UPDATE yetkazishlar
              SET holat = $3, urinishlar = urinishlar + 1,
                  oxirgi_xato = $4, yangilangan_vaqt = now()
            WHERE bildirishnoma_id = $1 AND kanal = $2`,
          [ish.bildirishnomaId, ish.kanal, holat, xato],
        );
      const limit = this.#limit[ish.kanal];
      if ((soni.rows[0]?.n ?? 0) >= limit) {
        await yangila("otkazildi", `soatlik limit ${String(limit)}`);
        await c.query("COMMIT");
        return "otkazildi";
      }
      const yub = yuboruvchilar[ish.kanal];
      if (!yub) {
        await yangila("xato", "yuboruvchi yo'q");
        await c.query("COMMIT");
        return "xato";
      }
      const pushlar = ish.kanal === "push"
        ? (await c.query<{
            id: string; endpoint: string;
            p256dh: string; auth: string;
          }>(
            `SELECT id, endpoint, p256dh, auth FROM push_obunalar
             WHERE foydalanuvchi_id = $1`,
            [fId],
          )).rows.map((p) => ({ ...p, id: Number(p.id) }))
        : [];
      // Tashqi so'rov tranzaksiya ichida — qulf yetkazish davomida
      // ushlanadi (ataylab: takroriy yuborish yo'q). Uzoq kutish —
      // yuboruvchi timeout'i bilan cheklangan.
      const n = await yub(
        {
          foydalanuvchiId: fId, email: q.email,
          chatId: q.chat_id, pushlar,
        },
        {
          id: ish.bildirishnomaId, tur: q.tur, sarlavha: q.sarlavha,
          matn: q.matn, havola: q.havola,
        },
      );
      if (n.holat === "yuborildi") await yangila("yuborildi", null);
      else if (n.holat === "eskirgan") {
        if (ish.kanal === "telegram") {
          await c.query(
            `UPDATE telegram_ulanishlar SET faol = false
             WHERE foydalanuvchi_id = $1`,
            [fId],
          );
        }
        if (ish.kanal === "push" && n.pushId !== undefined) {
          await c.query("DELETE FROM push_obunalar WHERE id = $1",
            [n.pushId]);
        }
        await yangila("xato", "manzil eskirgan");
      } else {
        const holat = n.holat === "qayta" ? "navbatda" : "xato";
        await yangila(holat, n.sabab);
      }
      await c.query("COMMIT");
      return n.holat === "eskirgan" ? "xato" : n.holat;
    } catch (e) {
      await c.query("ROLLBACK");
      throw e;
    } finally {
      c.release();
    }
  }
  // … qolibKetganlar, oqilmaganSoni, royxat, oqildi
}

Testlar — modullar/test/bildirishnoma.test.ts, yettita. Ular haqiqiy Postgres talab qiladi: har test fayli shablon bazadan vaqtinchalik nusxa oladi. Biz Docker'siz, o'z PostgreSQL 17.6 klasterimizda (sql/001–204 qo'llangan) ishga tushirdik (formatlangan modul nusxasi, tsc va eslint 0, Node 24.21.0):

bash
DATABASE_URL=postgres://postgres@127.0.0.1:<port>/vazifalar \
  node --test test/bildirishnoma.test.ts
text
▶ bildirishnoma markazi (Postgres)
  ✔ kanallar: afzallik + manzil + sukut (SMS yo'q) (30.8347ms)
  ✔ dedup: bir xil kalit — yangi yozuv ham navbat ham yo'q (7.1283ms)
  ✔ yetkazish: yuborildi, idempotent, parallel ikki worker — bitta yuborish (73.9967ms)
  ✔ rate limit: soatiga email 2 ta — uchinchisi o'tkaziladi (29.667ms)
  ✔ eskirgan manzil: Telegram 403 → ulanish faolsiz, keyingi bildirishnomada telegram tanlanmaydi; push 410 → obuna o'chadi (18.8995ms)
  ✔ qayta urinish holati navbatda qoladi; qolibKetganlar topadi (10.772ms)
  ✔ in-app: o'qilmaganlar soni, keyset ro'yxat, o'qildi (9.7393ms)
✔ bildirishnoma markazi (Postgres) (1247.576ms)
ℹ tests 7
ℹ suites 1
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1507.5349

Yetti test. Birinchisi — kanal tanlash: Aziz — email + telegram, Dilnoza — hech narsa, Gulnora — email + push, Sardor — email. Keyin dedup, parallel ikki worker, soatlik limit (testda email limiti 2), eskirgan manzillar, qayta urinish va qolibKetganlar, in-app ro'yxat va havola cheklovi.

Commit xabari (modul va sxema uchun):

bash
git commit -m "feat(bildirishnoma): markaz — kanallar, dedup, limit"

vazifalar-api ga ulash 28-qism tugagach: vazifa muddati va yangi izoh hodisalari bildir() ni chaqiradi, ilovada esa qo'ng'iroqcha va ro'yxat marshrutlari paydo bo'ladi.

11. Real ishda

  • Tayyor xizmatlar: Novu (ochiq kodli), Knock, Courier — kanal tanlash, afzalliklar sahifasi, shablonlar va in-app vidjetni beradi. Kanon ularni sinamagan — faqat tanishuv uchun. Bu darsdagi tuzilma ularning ichida ham bor — tanlashda nimaga qarashni bilasiz.
  • Katta kompaniyalarda bildirishnoma — alohida xizmat (microservice) va alohida jamoa.
  • Intervyuda (tizim dizayni): "Bildirishnoma tizimini loyihalang" — klassik savol. Javob tuzilmasi: hodisa → markaz → navbat → kanal worker'lari; dedup, afzalliklar, limit, qayta urinish, kuzatuv.

Xulosa

  • Bitta markaz: bildir() — in-app yozuv + har kanal uchun yetkazishlar qatori, bitta tranzaksiyada.
  • Navbatga COMMIT'dan keyin; tushmay qolgani qolibKetganlar() bilan topiladi (outbox g'oyasi).
  • Kanal = afzallik (yoki sukut) + manzil; SMS sukutda o'chiq.
  • Dedup kaliti (ON CONFLICT DO NOTHING) takrorni to'xtatadi; havola — faqat ichki yo'l.
  • FOR UPDATE + holat tekshiruvi: ikki worker — bitta yuborish; soatlik limit — otkazildi.
  • Kanon kamchiligi: yuborish tranzaksiya ichida — ulanish band; muqobil — claim naqshi.

Keyingi dars: Telegram Bot API asoslari — to'rtinchi kanal: bot Telegram serveri bilan HTTP orqali qanday gaplashadi, polling va webhook farqi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!