Tech stekas
Inžinieriams: devynios pristatymo platformos paslaugos, diegimas be rankinių žingsnių, karštasis rezervas ir mano sprendimai su kiekvieno kaina.
TL;DR
Last-mile Delivery Platform — tai TypeScript ir Python monorepozitorija: NestJS API su PostgreSQL ir Redis, Next.js svetainė, administravimo skydelis ir trys Telegram mini programėlės su React ir Vite, trys aiogram botai. Produkcijoje veikia devynios paslaugos Docker konteineriuose. Kiekvienas push į pagrindinę šaką praeina testus (API jų turi 4 037), perrenka tik paliestas paslaugas ir jas įdiegia; nuo 2026 m. vasario tokių diegimų buvo 513. Duomenų bazės schema per tą laiką keitėsi 110 migracijų. Pagrindinis serveris turi karštąjį rezervą su srautine replikacija. Spalio 12 d. rezervo prireikė: perjungimas užtruko 52 minutes, duomenys neprarasti.
Iš ko sudaryta
svetainė (Next.js) ──────────────┐
parduotuvė Telegram viduje ──────┤
admin skydelis (React + Vite) ───┤
personalo mini programėlės ──────┼──► API (NestJS) ──► PostgreSQL 16
kitos vitrinos ──────────────────┤ │ └──► Redis 7: podėlis, pub/sub
pagalbos pokalbių robotas ───────┘ │
├──► Socket.IO: dispečerio žemėlapis, pirkėjo sekimas
Telegram botai (Python) ◄── Redis pub/sub
└──► WhatsApp, SMS, skambučiai per maskuotus numerius
| Paslauga | Kam | Technologijos |
|---|---|---|
| API | visi kanalai | NestJS 10, TypeORM, PostgreSQL 16, Redis 7, Socket.IO; 65 moduliai, 79 esybės |
| Svetainė | pirkėjai | Next.js 14, React 18, trys kalbos |
| Administravimo skydelis | savininkas ir vadybininkai | React 18, Vite 6, 81 maršrutas |
| Personalo mini programėlė | dispečeriai ir vairuotojai | React 19, Vite 7, Telegram WebApp |
| Ankstesnė vairuotojo programėlė | vairuotojai, kol pereina į bendrąją | React 19, Vite 7 |
| Parduotuvė Telegram viduje | pirkėjai | React 19, Vite 7 |
| Trys botai: dispečerių, vairuotojų, parduotuvės | personalas ir pirkėjai | Python, aiogram 3 |
Botai patys neklausinėja duomenų bazės apie įvykius: API skelbia juos Redis, o botai prenumeruoja savo kanalus. Dispečerio žemėlapis ir pirkėjo sekimas gauna vairuotojo vietą per Socket.IO.
Kaip kodas patenka į serverį
- Prieš push vietinis hook'as tikrina tipus paliestose programėlėse ir visose, kurios naudoja pakeistus bendrus paketus.
- GitHub Actions kelių filtras nustato, kurios paslaugos paliestos. Testai vyksta lygiagrečiai: API, botai, duomenų bazė.
- Kiekviena paliesta paslauga surenkama į Docker atvaizdą su commit'o žyme.
- Žingsnis promote vienu metu perkelia žymę
latestvisiems surinktiems atvaizdams. Jei nepavyko perkelti bent vienos, jau perkeltos grąžinamos į ankstesnius atvaizdus, ir paleidimas krenta. Sėkmė tikrinama skaitant registrą, o ne pagal komandos grąžinimo kodą: ji gali grąžinti nulį, nepajudinusi žymės. - Žingsnis deploy prisijungia prie serverio ir atnaujina tik savo paleidimo paslaugas.
Paleidimai vyksta griežtai paeiliui, be atšaukimo. Pakartotinai paleistas senas paleidimas negali perkelti latest į seną commit'ą: tam yra atskira šviežumo patikra. Jei reikia seno kodo, daromas revert ir naujas push.
Duomenų bazės schemos konvejeris neliečia. Migracijas taikau rankomis pagrindinėje duomenų bazėje prieš push ir rašau jas taip, kad senas kodas toliau veiktų su nauja schema: pirma išplėsti, įdiegti kodą, paskui pašalinti nebereikalinga. Rezervas schemos pakeitimus gauna per replikaciją.
Karštasis rezervas
Rezervinis serveris gauna duomenų bazę per PostgreSQL srautinę replikaciją, Redis jame veikia kaip replika. Nustatymai, atvaizdai ir aplinkos failai sinchronizuojami kas valandą, įkelti failai — naktį. Kol rezervas netapo pagrindiniu, konteinerių jame nėra. Atskirai kiekvieną dieną daromos duomenų bazių kopijos į objektų saugyklą ir į dar vieną serverį.
Perjungimas — tai scenarijų rinkinys: atitverti senąjį pagrindinį serverį, pakelti paslaugas rezerve, perjungti DNS, iš naujo išduoti sertifikatus, nukreipti diegimą į naują adresą. Senasis serveris paleidžiamas pats klausia, kuris dabar pagrindinis, ir jei ne jis, Docker nepaleidžia.
Spalio 12 d. pagrindinis serveris dingo, nes pas talpinimo paslaugų teikėją sugedo fizinė mašina. Po 52 minučių parduotuvė veikė rezerve, paskutinė transakcija spėjo nukeliauti į repliką. Po dviejų dienų senasis serveris pasileido, pamatė, kad pagrindinis dabar kitas, ir liko išjungtas. Naujas rezervas naujam pagrindiniam serveriui buvo paruoštas tą pačią dieną.
Sprendimai ir kodėl
Vienas API visiems kanalams
Svetainė, administravimo skydelis, mini programėlės, botai, kitos vitrinos ir pagalbos pokalbių robotas kreipiasi į vieną API. Alternatyva — atskiras backend'as kiekvienam kanalui. Pasirinkau vieną, nes kaina, likutis, pristatymo zona ir užsakymo būsena turi sutapti visur, o dubliuota logika išsiskiria. Sprendimo kaina: API didelis, 65 moduliai, ir bet koks jo pakeitimas tikrinamas visais testais.
Telegram vietoj mobiliųjų programėlių
Sausį repozitorijoje buvo pradėtos dvi Expo programėlės dispečeriui ir vairuotojui. Jas sustabdžiau ir perkėliau personalą į Telegram mini programėles: nereikia programėlių parduotuvių ir jų patikrų, prisijungiama Telegram paskyra, pranešimai ateina įprastomis žinutėmis, atnaujinimas atkeliauja su diegimu. Kaina: priklausomybė nuo Telegram WebView ir jo apribojimų, visų pirma fono kortelė užmiega.
Vieta iš Telegram transliacijos
Dėl tos pačios fono kortelės vairuotojo vietą imu iš paties Telegram vietos transliacijos: botas kas kelias sekundes gauna tašką ir deda jį į Redis. Kol transliacija šviežia (ne senesnė nei 180 sekundžių), ji svarbesnė už taškus iš kitų įrenginių. Taškas, nutolęs nuo jos toliau, nei galima nuvažiuoti per juos skiriantį laiką (40 m/s, bet ne mažiau nei 1 km), atmetamas visiškai: nepatenka nei į podėlį, nei į istoriją, nei į žemėlapį. Jei Redis nepasiekiamas, taisyklė tašką praleidžia, kaip iki jos atsiradimo.
Sandėlį parenka viena bendra taisyklė
Kuris sandėlis aptarnauja adresą, sprendžia atskiras monorepozitorijos paketas be priklausomybių nuo karkasų. Jį importuoja ir API, ir svetainė. Alternatyva — taisyklės kopija kiekvienoje programėlėje. Kaina: paketo negalima susieti nei su NestJS, nei su Next.js, o jo pakeitimai tikrinami abiejų naudotojų testais.
Prekė priklauso pagrindiniam sandėliui
Prekės kortelę, kainą ir išpardavimą tvarko pagrindinis sandėlis, kiti juos paveldi ir saugo tik savo likučius. Anksčiau sandėliai turėjo savo kainų kopijas, ir prieš perėjimą viename iš jų išsiskyrė 8 kainos ir 35 išpardavimai. Sprendimo kaina: sandėlis negali nustatyti savo kainos, ir tai sąmoningas apribojimas.
Migracijos rankomis ir prieš push
Migracijų žingsnio konvejeryje nėra tyčia. Schemos keitimas gyvoje duomenų bazėje — momentas, kai reikia žmogaus: patikrinti planą, sustoti ties pirmąja klaida, sutikrinti rezultatą pagrindinėje duomenų bazėje ir rezerve. Kaina: rankinis žingsnis ir disciplina. Prieš push pamiršta migracija sugadintų diegimą, todėl kodas, kuris skaito naujus stulpelius, diegiamas tik patikrinus schemą.
Žymė latest juda visiems arba niekam
Anksčiau kiekvienas surinkimas pats kabino latest. Jei vienas nukrisdavo, jo kaimynai jau rodė į naują commit'ą, ir kitas žalias kitos paslaugos diegimas pakeldavo juos kartu. Spąstai užsitaisydavo vienu push, o suveikdavo kitu. Dabar žymę perkelia atskiras žingsnis viena operacija su atšaukimu, o diegimas atnaujina tik savo paslaugas. Kaina: paslauga, kurios promote pavyko, o deploy nukrito, produkcijos pakeliui nepasivys ir lauks savo kito surinkimo.
Karštasis rezervas vietoj vien atsarginių kopijų
Kasdienės duomenų bazės kopijos pakanka, kad duomenys nebūtų prarasti visam laikui, bet atkūrimas iš jos — tai valandos ir prarasti dienos užsakymai. Replika duoda minutes ir nulį nuostolių. Kaina: antras serveris, konfigūracijos sinchronizavimas ir senojo pagrindinio serverio atitvėrimas, be kurio du serveriai galėtų priimti užsakymus vienu metu.
SMS: pirmiausia savi telefonai, debesis atsargai
SMS pranešimai siunčiami per šliuzą Android telefonuose. Pirmiausia per savus telefonus savo šliuzo serveryje, o debesijos šliuzas įsijungia, tik jei savi nesugebėjo išsiųsti. Debesijos šliuzas strigdavo, o vairuotojas ekrane laukia siuntimo rezultato. Kaina: per šliuzo API neįmanoma nustatyti, ar telefonas gyvas, būsenos klysta į abi puses, todėl naujas telefonas į rotaciją patenka tik po bandomosios žinutės, kurios gavimas patvirtintas.
Sąsaja su pokalbių robotu aprašyta sutartimi su patikra
Pagalbos pokalbių robotas kreipiasi į API pagal sutartį: atskiras dokumentas aprašo kiekvieną užklausą, atsakymą ir taisyklę su nuorodomis į kodą failas:eilutė, jų jame 90. Rugpjūtį matavimas parodė, kad tokios nuorodos pasensta maždaug per tris savaites: 16 iš 44 jau rodė ne ten. Dabar testas tikrina, kad kiekviena nuoroda rodo į gyvą kodą, o nuorodos, kuriomis remiasi argumentas, — dar ir į eilutę su reikiamu turiniu. Kaina: keičiant kodą nuorodas tenka perskaičiuoti, užtat dokumentas nepastebimai neatsilieka nuo kodo.
Kaip tikrinama
- API: 4 037 testai 235 rinkiniuose (Jest). Vairuotojų botas: 97 testai (pytest). Personalo mini programėlė: 32 testai (Vitest). Atskiri duomenų bazės testai CI.
- Tipų patikra prieš kiekvieną push paliestose programėlėse.
- Didesnis pakeitimas prasideda nuo plano, kurį nepriklausomai patikrina antrasis AI agentas. Tada kodas, testai ir pakartotinė patikra.
- Nauja taisyklė pirmiausia paleidžiama su tikrais duomenimis. Vietos filtras prieš diegimą patikrintas su trijų dienų vieno vairuotojo taškais: 1 480 klaidingų atmesta, 39 abejotini praleisti.
Ko platforma nedaro
- Mikropaslaugų nėra: API — vienas NestJS procesas, viduje padalytas į modulius.
- Migracijų konvejeryje nėra, ir taip sumanyta.
- Natūralių mobiliųjų programėlių nėra.
- Maršruto optimizavimo per kelis adresus nėra.