Tech stekas
Architektūra, stekas, skriptai, diegimas, sauga, observability.
TL;DR
Last-mile Delivery Platform — tai Turborepo monorepozitorija su vienuolika programų ir dviem shared-paketais. Backend parašytas su NestJS 10 (trisdešimt šeši moduliai, keturiasdešimt keturi kontroleriai, vienas WebSocket-gateway, keturiasdešimt trys entity ant TypeORM virš PostgreSQL 16 ir Redis 7). Web vitrina sukurta su Next.js 14 ir App Router, ISR caching kataloge bei i18n trims lokalėms. Admin panelė — atskira Vite 6 SPA su penkiasdešimt puslapių ir RBAC keturioms rolėms. Trys Telegram mini-app (sales, dispatch, driver) sukurti su Vite 7 + React 19 ir validuoja initData per HMAC-SHA-256. Trys Python botai su aiogram 3 bendrauja su API per Redis pub/sub. Real-time perduodamas per Socket.IO su penkiais verslo įvykiais ir kambariais per-store, per-dispatcher, per-driver, per-customer. Prodas sukasi Docker compose viename VPS, devyni servisai plius nginx su Let's Encrypt. CI/CD ant GitHub Actions su path-based filtravimu perrenka tik paliestus servisus ir paleidžia juos per GHCR. Saugumas — bcrypt, JWT su trumpais access-tokenais ir rotation refresh tokenams, throttler, Helmet, HSTS, HMAC Telegramui, OTP per Twilio A2P ir SMS-Gate Android. Observability srityje — sava įvykių analitika ant client_events (90 dienų) plius rrweb session replays (30 dienų), audit-trail ir checkout/login funnel admin panelėje. Techninė skola sąžiningai užfiksuota: testų nėra, inventory-log užsakymo metu kol kas nerašomas, o refresh token rotation reikalauja patobulinimo.
Turinys
- Aukšto lygio architektūra
- Monorepo ir jo ribos
- API: NestJS, moduliai, migracijos
- Web vitrina: Next.js 14, ISR, i18n
- Admin panelė: Vite SPA, RBAC, ataskaitos
- Telegram mini-app: trys paviršiai
- Botai: Python, aiogram, Redis pub/sub
- Duomenų bazė ir cache
- Real-time: WebSocket gateway ir kambariai
- Order state machine
- Infrastruktūra: dev ir prod
- CI/CD: path-based filtering ir GHCR
- Skriptai ir operacinės pagalbinės priemonės
- Sauga
- Observability ir analitika
- Integracijos
- Architektūriniai sprendimai ir kodėl būtent taip
- Techninė skola kaip brandi inžinerinė praktika
- Nuorodos
Aukšto lygio architektūra
Platforma — tai pilnavertis last-mile delivery stekas vietiniam prekių pristatymui iki galutinio vartotojo durų. Vienoje monorepozitorijoje surinkti klientinis web storefront, operatoriaus admin panelė, trys Telegram mini-app (pirkėjams, dispetčeriams ir vairuotojams), vienas bendras API ir trys Python botai. Išorėje — nginx su SSL ir išorinių servisų rinkinys: Twilio SMS A2P, SMS-Gate Android dubliuojančiam OTP kanalui, Pushover kritiniams operaciniams pranešimams, Telegram Bot API, kriptomokėjimų piniginės ir Nominatim geokodavimui. Pagrindinis principas — minimum judančių dalių ir maksimalus jų atsiejimas per aiškiai apibrėžtus komunikacijos taškus: REST endpointai, WebSocket kambariai ir Redis pub/sub kanalai.
Žemiau — bendra sistemos schema su domenų ribomis ir duomenų srautais.
flowchart LR
subgraph "Clients"
Web[Web storefront<br/>Next.js 14]
AdminUI[Admin panel<br/>React + Vite]
SalesMA[Sales mini-app<br/>Telegram]
DispatchMA[Dispatch mini-app<br/>Telegram]
DriverMA[Driver mini-app<br/>Telegram]
end
subgraph "Edge"
NGINX[nginx + SSL<br/>Let's Encrypt]
end
subgraph "Backend"
API[NestJS API<br/>36 modules]
WS[WebSocket gateway]
end
subgraph "Bots (Python)"
BotC[Customer bot]
BotD[Driver bot]
BotS[Sales bot]
end
subgraph "Data"
PG[(PostgreSQL 16<br/>43 entities)]
Redis[(Redis 7<br/>cache + pub/sub)]
end
subgraph "External"
Twilio[Twilio A2P]
SMSGate[SMS Gate<br/>Android]
Pushover[Pushover]
TG[Telegram Bot API]
Crypto[Crypto wallets]
Maps[Nominatim]
end
Web & AdminUI & SalesMA & DispatchMA & DriverMA --> NGINX
NGINX --> API
NGINX --> WS
API <--> PG
API <--> Redis
WS <--> Redis
Redis -.pub/sub.-> BotC & BotD & BotS
BotC & BotD & BotS --> TG
API --> Twilio
API --> SMSGate
API --> Pushover
API --> Crypto
API --> MapsČia gerai matosi pagrindinė idėja: API nieko nežino apie Telegram pranešimus, o botai nieko nežino apie duomenų bazę. Bet koks pranešimas pirkėjui — tai tik publikavimas Redis kanale, o jo pristatymu klientui rūpinasi atskiras procesas. Tas pats su dispečerio ir vairuotojo kanalais. Tai leido atsieti operacinius ir produktinius pokyčius vienus nuo kitų: vienas žmogus gali keisti užsakymų workflow API, o kitas — UX bote, ir jie netrukdo vienas kitam.
Monorepo ir jo ribos
Projekto šaknis sutvarkyta maksimaliai paprastai:
delivery-platform/
├── apps/ 11 programų
│ ├── api/ NestJS + TypeORM + Postgres + Redis + WebSocket
│ ├── web/ Next.js storefront — portas 3001
│ ├── admin/ Vite SPA — portas 3002
│ ├── telegram-mini-app/ Vite + React (dispečeris) — portas 3003
│ ├── driver-miniapp/ Vite + React (vairuotojas) — portas 3004
│ ├── sales-miniapp/ Vite + React (pardavimai)
│ ├── telegram-bot/ Python + aiogram (dispečeris)
│ ├── telegram-driver-bot/ Python + aiogram (vairuotojas)
│ ├── telegram-sales-bot/ Python + aiogram (pardavimai)
│ ├── dispatcher/ Expo / React Native (placeholder)
│ └── driver/ Expo / React Native (placeholder)
└── packages/ 2 shared paketai
├── shared/ tipai, enumai, konstantos
└── ui/ React komponentai (Button, Input, Card, Badge, Spinner)
Build sistema — Turborepo virš npm workspaces. Tai duoda du praktiškai svarbius dalykus. Pirmas — bendras tsconfig ir vieningas tipų rinkinys packages/shared (User, Order, Store, OrderStatus, ORDER_TRANSITIONS, SOCKET_EVENTS), kuriais naudojasi tiek frontendai, tiek API. Tai reiškia, kad pridėjus naują lauką į order ar naują reikšmę į statuso enumą, TypeScript jau kitoje kompiliacijoje parodys, kur šio lauko neapėmiau. Antras — Turbo moka skaičiuoti paliestų failų hash'us ir praleisti tų workspace'ų buildinimą, kurių pakeitimai nepalietė. CI atveju tai virsta reikšminga laiko ekonomija, nes po trivialių pataisymų, tarkime, web, mes neperrenkame botų ir mini-app.
Paketas packages/ui sąmoningai nedidelis ir turi tik neutralius elementus, kurie turi prasmę bet kuriame iš vartotojiškų programų: mygtukas, įvesties laukas, kortelė, badge ir spinner. Tai kompromisas tarp „nedubliuoti" ir „nepaversti UI paketo lopeta, per kurią tempiama visa dizaino kalba". Realus vizualinis kalbėjimas — Tailwind tokenai ir sudėtingesnės kompozicijos — gyvena atskirai kiekvienoje programoje, nes admin panelei ir vitrinai iš principo skiriasi užduotys ir skirtingas UX.
Šakniniai package.json ir turbo.json sukonfigūruoti taip, kad dev metu darau vieną npm run api:dev, npm run web:dev, npm run admin:dev ir gaunu hot reload kiekvienai programai nepriklausomai. VPS aplinkoje dev serveriai sukasi per systemd (dev-api, dev-web, dev-admin, dev-sales), o hot reload automatiškai pagauna pakeitimus — perkrovimas reikalingas tik keičiant .env arba užsikabinus procesui.
API: NestJS, moduliai, migracijos
Backend — didžiausia programa monorepe: trisdešimt šeši moduliai, keturiasdešimt keturi kontroleriai ir vienas WebSocket-gateway. Trisdešimt šeši moduliai — tai ne „dėl gražaus skaičiaus", o realios domeninės dekompozicijos atspindys. Kiekvienas modulis atitinka savo atsakomybę ir valdo nedidelį entity, servisų ir kontrolerių rinkinį.
Modulių sąrašas duoda gerą įspūdį, kas vyksta platformos viduje:
- domeniniai užsakymų ir katalogo moduliai:
orders,products,categories,brands,cart,time-slots,delivery,stores,reviews; - identifikacija ir prieiga:
auth,users,roles,employees,customer-plans; - operacinė panelė:
admin,admin-promotions,dispatcher,driver-personal,vehicles; - marketingas ir išlaikymas:
banners,campaigns,rewards; - integracijos ir mokėjimai:
crypto-payments,email,messaging,sms,whatsapp,notifications; - infrastruktūriniai:
database,geocoding,health,realtime,events,uploads.
Keturiasdešimt trys entity apima visą platformos verslo modelį: vartotojai, parduotuvės, produktai su variantais, krepšelis, užsakymai, užsakymų pozicijos, statusų logai, laiko slotai, promokodai, baneriai, kampanijos, atsiliepimai, vairuotojai, pamainos, piniginės, kriptomokėjimų depozitai ir taip toliau. Ypač iliustratyvus poddomenis admin — jame gyvena vienuolika pagalbinių entity: cash-drop, checkout-log, client-event, driver-shift, inventory-log, login-log, order-status-history, otp-log, product-audit-log, session-recording, wallet-transaction. Iš esmės tai mini-storage visai operacinei analitikai, audit-trail ir observability.
Modulių failai guli apps/api/src/modules/<module>/, o konkretūs servisai ir controllers kiekviename modulyje sutvarkyti pagal standartinį NestJS paterną: *.module.ts, *.controller.ts, *.service.ts, entities/*.entity.ts. Priklausomybės sutvarkomos per DI, kas pastebimai palengvina lokalų priklausomybių keitimą ir mock'inimą derinant.
TypeORM ir migracijos
Sąmoningai nesirėmiau TypeORM auto-sync kritiniams schemos pokyčiams. Auto-sync geras ankstyvosiose stadijose, bet prode jis pavojingas: vienas neatsargus stulpelio pervadinimas virsta drop stulpelio kartu su duomenimis. Todėl visi svarbūs schemos pokyčiai suformuoti kaip įprastos SQL migracijos apps/api/src/database/migrations:
20260121-add-cashback.sql20260329-add-plan-switch-logs.sql20260330-premium-tier.sql20260403-premium-tier-settings.sql20260404-vehicles.sql20260417-order-item-cost-price.sql
Jas paleidžiu rankiniu būdu aiškia tvarka dev'e, o paskui prode. Tai duoda du privalumus. Pirmas — migracijos turi vardus su data, kurios gerai skaitomos code-review metu ir gite. Antras — aš tiksliai žinau, kas migruoja, o kas ne, ir galiu sustoti, jei kažkas eina ne taip. Seed duomenims yra atskiras skriptas apps/api/src/database/seeds/seed.ts, kuris užpildo dev bazę pagrindinėmis kategorijomis, testinėmis parduotuvėmis ir vienu dev vartotoju.
Kontroleriai ir REST
Keturiasdešimt keturi kontroleriai — paprastai tai ne vienas-prie-vieno su moduliais. Kai kurie moduliai turi po du kontrolerius: vienas viešas (/api/v1/orders), vienas admin (/api/v1/admin/orders). Kai kur kontroleriai padalinti pagal semantiką: pavyzdžiui, events modulyje yra vieši endpointai POST /events (klientinių įvykių paketai) ir POST /events/replay (rrweb chunkai), bei atskiras admin-kontroleris su filtrais, statistika, sesijų paieška ir search analitika.
Globalus ValidationPipe pakeltas main.ts, validacija — per class-validator DTO. Tai pašalina visą klasę bugų „atėjo nesąmoningas JSON" ir kartu duoda aiškias klaidas klientui: kur tiksliai laukas nepraėjo, kokio buvo tikėtasi tipo, kokios reikšmės leistinos.
Web vitrina: Next.js 14, ISR, i18n
Storefront pastatytas ant Next.js 14 su App Router, ir jo užduotys prasideda nuo greito katalogo pakrovimo ir baigiasi pilnaverčiu checkout, autorizacija, pinigine ir referalų programa. Žemiau — tai, kas šiame penkių tūkstančių žodžių puslapyje iš tiesų svarbu.
App Router ir i18n
Puslapių šaknis — apps/web/src/app/[locale]/, o kiekviena [locale] reikšmė — tai en, ru arba es. Lokalizacija prijungta per next-intl, žodynai guli apps/web/messages/<locale>.json. Layout lygmenyje pridedu NextIntlClientProvider, ir visi komponentai gauna prieigą prie vertimų per useTranslations. Maršrutų segmentavimas pagal lokalę duoda teisingus kanoninius URL ir hreflang be šamanizmo, plius leidžia statyti preload prioritetus numatytajai lokalei.
ISR katalogui
Katalogas — karštas kelias. Pirkėjai produktų ir kategorijų puslapius atidaro dažniau už bet ką kitą, o šie puslapiai retai keičiasi tarp inventoriaus atnaujinimų. Uždedu revalidate: 60 katalogo segmentams, ir Next.js perleidžia HTML kartą per minutę. Kartu su nginx edge cache tai duoda praktiškai statinį greitį populiariuose puslapiuose, o turinio redaktoriui nereikia perkrauti buildo redaguojant prekės aprašymą.
Dinaminiams puslapiams (krepšelis, checkout, kabinetas, piniginė) ISR nenaudojamas — jie renderinami per-request su aktualiais duomenimis.
Dinaminiai importai ir sunkūs komponentai
Pagrindinis ir dalis produkto puslapių naudoja next/dynamic sunkiems blokams: TestimonialsSection, QRCodeSVG referalų kodams, lazy-load motion animacijoms. Tai sumažina pirminį JS bundle ir pagerina LCP — ypač slow 3G ir desktop su lėtu CPU. Po epizodo su broken dynamic import (žr. 0f6e19b fix(web)) grąžinau statinį importą viename iš blokų, nes jis iš tikrųjų neduodavo bundle pelno.
Žemėlapiai ir geoduomenys
Žemėlapiams naudoju Leaflet (atviras sprendimas, be komercinių kvotų). Tile'us imu pas OSM tiekėją, geokodavimas — per Nominatim. Tai pilnai padengia mūsų užduotis: pristatymo adreso pasirinkimas, padengimo zonos vaizdavimas ir vairuotojo žymeklis realiu laiku.
Autorizacija
Vitrina palaiko tris prisijungimo būdus: email + slaptažodis su OTP, Google OAuth 2.0 ir Telegram WebApp. Google OAuth svarbu realizuotas taip, kad affiliateCode parametras pasiekia kitą pusę per redirect — tai sutvarkyta 311fce3 fix: pass affiliate code through Google OAuth registration. Telegram WebApp validuojamas per HMAC-SHA-256: backend ima sorted query params, prasuka per bot secret ir palygina su hash. Jei sutampa — vartotojas laikomas autentifikuotu, ir API grąžina access + refresh.
Animacijos ir prieinamumas
Naudojama motion/react biblioteka. Visos pagrindinės animacijos apvyniotos prefers-reduced-motion patikrinimu, kad vartotojai su įjungtu reduced-motion gautų statinius interfeisus. Kartu su teisingu focus-management ir aria atributais tai duoda skaitomą prieinamumą be antkainio dizainui.
rrweb ir analitika
Frontende įjungtas rrweb su protingais nustatymais: įrašymas tik autorizuotiems vartotojams, maskAllInputs: true (jokių slaptažodžių ir adresų įraše), ir laiko limitas 15 minučių vienai sesijai. Tai — detaliau žr. Observability skiltyje žemiau — užtikrina probleminių sesijų atkūrimą iš admin panelės be asmeninių duomenų nutekėjimo rizikos.
Lygiagrečiai veikia kastominė analitika. Failas apps/web/src/lib/analytics.ts saugo sessionId localStorage, batchina įvykius kas penkias sekundes ir siunčia juos į POST /events. Jei vartotojas uždaro skirtuką, naudojame navigator.sendBeacon, kad neprarastume paskutinio paketo. Serveryje įjungtas rate limit 10 užklausų per minutę šiam endpointui, kas vienu metu apsaugo nuo botų ir palieka legitimius scenarijus be problemų.
Admin panelė: Vite SPA, RBAC, ataskaitos
Admin panelė — tai atskira Vite 6 SPA ant React 18, su penkiasdešimt puslapių ir vidine maršrutizacijos sistema ant React Router v6. Kodėl atskira programa, o ne bendras kodas su vitrina: admin panelei ir vitrinai iš principo skirtingos užduotys. Admin panelė — tai operatoriaus darbo vieta, kur svarbus duomenų tankis, sudėtingos lentelės, grafikai ir veiksmai. Vitrina — tai marketingas ir pardavimai, kur svarbi estetika, pakrovimo greitis, marketingo metrikos. Atskyrimas padeda abiem produktams.
State ir serverio cache
Lokalus state — Zustand, be perteklinių slice'ų ir apvyniojimo. Serverio state — React Query: invalidation, refetching, optimistic updates. Šis atskyrimas duoda švarų duomenų modelį: viskas, kas ateina iš serverio, praeina per React Query (su teisingu staleTime ir retry strategijomis), o viskas, kas valdo UI (lentelės filtrai, atviras modalas, aktyvi kortelė), gyvena Zustand.
Grafikai ir analitika
Recharts — analitikai ir dashboardams: BarChart Dashboard ir Sales puslapyje, LineChart Conversion Analytics puslapyje. Tai ne gražiausias produktas rinkoje, bet lengvas, išplečiamas ir padengia 95% užduočių be išorinių priklausomybių. Kur trūksta — rašau kastomines SVG vizualizacijas ant Recharts wrapper'ių.
PDF sąskaitos
Sąskaitoms naudoju jsPDF + autoTable. Kai operatorius paspaudžia „Atsisiųsti PDF", kliente suformuojamas dokumentas su pilna užsakymo sudėtimi, mokesčiais ir parašu. Be serverinio PDF rendering ir be headless Chrome — kas svarbu prode, kur kiekvienas papildomas servisas reiškia papildomą gedimo tašką.
Layouts ir rolės
Admin panelės viduje trys skirtingi layout kontekstai: AdminLayout, DriverLayout, DispatcherLayout. Iš esmės tai trys skirtingi produktai viename SPA, perjungimas tarp jų priklauso nuo vartotojo rolės. RBAC realizuotas maršrutų lygmenyje per RoleGuard: komponentas-wrapper, kuris žiūri į einamąją rolę store'e ir arba renderina vaikus, arba redirectina į 403.
Keturios rolės:
admin— pilnas priėjimas, parduotuvės konfigūracija, darbuotojų valdymas, billing.dispatcher— operacinis darbas: gaunami užsakymai, vairuotojų priskyrimas, pirkėjų palaikymas.driver— savas layout savų užsakymų, maršrutų ir uždarbio peržiūrai.manager— apribotas admin, dažniausiai be prieigos prie billingo ir sisteminių nustatymų.
Ikonos visur — lucide-react. Dizaino sistema laikosi ant Tailwind 3 su savais tokenais tailwind.config.ts. Sąmoningai nenaudoju gatavų komponentų kitų tipo Material UI, nes admin panelė turi atrodyti kaip mūsų brendo dalis, o ne kaip dar viena Material svetainė.
Telegram mini-app: trys paviršiai
Telegrame platforma turi tris atskiras mini programas, ir kiekviena sprendžia savo užduotį:
- sales mini-app — anoniminis katalogas, lazy-auth ir greitas checkout pirkėjams, atėjusiems per botą.
- dispatch mini-app — dispetčerio darbo vieta judant: užsakymų sąrašas, filtrai, veiksmai.
- driver mini-app — vairuotojo interfeisas: einamas pristatymas, maršrutas, pickup/delivered atžymos.
Visos trys parašytos su Vite 7 + React 19 + Tailwind 3 ir kompiliuojamos į įprastus SPA, kuriuos atiduoda nginx. Ne Next.js, nes mini-app — tai išskirtinai client-side kontekstas Telegram WebView'e, ir SSR čia neturi prasmės.
Telegram WebApp HMAC
Autorizacija mini-app pastatyta ant initData validacijos. Telegram perduoda vartotojo duomenis eilutėje, pasirašytoje HMAC-SHA-256 su bot secret. Patikrinimo algoritmas serveryje: išskaidyti query string, sortuoti raktus, sujungti į formatą key=value\n..., suskaičiuoti HMAC ir palyginti su hash. Jei sutapo — duomenys tikri, galima pasitikėti user.id ir automatiškai registruoti vartotoją arba pakelti jo egzistuojančią paskyrą.
Auto-registracija sutvarkyta paprastai: jei pagal tg_id vartotojo nėra, sukuriame naują su phone formoje tg_{id} (šis šablonas skiriasi nuo įprastų numerių, todėl nėra kolizijų) ir išduodame tokeną. Vitrinos pusėje šis vartotojas vėliau gali prisirišti tikrą numerį.
Anoniminis katalogas ir lazy auth
Sales mini-app sąmoningai duoda anoniminę prieigą prie katalogo. Jokių registracijų, jokių formų pradžioje. Krepšelis gyvena localStorage. Autorizacijos prašoma tik per checkout — čia mini-app patikrina Telegram.WebApp.initData ir pakelia tokeną. Tai sumažina frikcijas vorone ir duoda gerą konversiją.
Real-time per Socket.IO
Mini-app viduje aktyviai naudojamas Socket.IO klientas, jungiantis prie API per WebSocket gateway. Tai leidžia dispetčeriui ir vairuotojui gauti įvykius ORDER_CREATED, ORDER_STATUS_CHANGED, ORDER_ASSIGNED, DRIVER_LOCATION_UPDATED, DRIVER_STATUS_CHANGED be papildomo polling. Detaliau — Real-time skiltyje žemiau.
Botai: Python, aiogram, Redis pub/sub
Trys botai — tai turbūt „daugiakalbiškiausia" steko dalis, ir ji specialiai tokia. Telegram botai — tai long-polling procesai, kuriuos lengviau ir švariau realizuoti su Python ir aiogram, nei tempti atskirą Node.js procesą su tg-grammy ir adapterių uodega. Kiekvienas botas — atskira programa apps/telegram-bot, apps/telegram-driver-bot, apps/telegram-sales-bot. Stekas visiems vienodas:
- Python 3.12;
aiogram 3.x— modernus async botų framework;asyncpg— natyvus async PostgreSQL driver be ORM overhead;redis-pypub/sub.
Virtualus environment renkamas Docker image viduje; dev versijoje — atskirame venv boto kataloge.
Atsiejimas per Redis
Pagrindinis architektūrinis sprendimas čia — API nieko nežino apie Telegram pranešimus, o botai nieko nežino apie bazę. Kai užsakymas pristatytas, API publikuoja įvykį į notifications:customer, ir pirkėjo botas ištraukia pranešimą iš kanalo, suformatuoja tekstą ir nusiunčia pirkėjui į Telegram. Jei operatorius nori pridėti naują pranešimą (pavyzdžiui, „Ačiū už įvertinimą"), jis prideda publikaciją Redis, ir botas per sekundes pradeda pristatyti be API release.
Kanalų pas mus trys:
notifications:customer— pranešimai pirkėjui.notifications:dispatcher— fan-out visiems dispetčeriams (dabar keturiems).notifications:driver— vairuotojo kanalas.
Dispetčerių kanale botas perskaito pranešimą ir išdalina jį visoms aktyvioms dispatcher paskyroms Telegrame. Taip realizuotas „visiems operatoriams per garsiakalbį", kai ateina skubus užsakymas.
Paleidimas
Prode kiekvienas botas paleidžiamas savame Docker konteineryje, dev'e — per PM2 (fork mode, kad nedublicuoti long-poll ir negauti „conflict: terminated by other getUpdates request" iš Telegram). Šis pasirinkimas irgi neatsitiktinis: cluster mode long-poll botams beveik visada reiškia dubliuotus pranešimus, čia geriau fork.
Duomenų bazė ir cache
Duomenų bazė — PostgreSQL 16, single instance, lokaliai VPS'e. Duomenų apimtis ir apkrova kol kas leidžia neišskirti skaitymo ir rašymo, ir aš neįnešu ten sudėtingumo per anksti. Schema — keturiasdešimt trys entity plius keletas kastominių vaizdų (views) ataskaitoms admin panelėje.
Pagrindiniai konstrukcijos principai:
- Daugiatentiškumas per
store_id. Visi pagrindiniai entitetai (produktai, užsakymai, krepšeliai, vartotojai parduotuvės kliento rolėje) turistore_id. Serviso lygmenyje filtravimas pagal storeId — privaloma bet kurios užklausos dalis. Tai leidžia talpinti viename instance keletą parduotuvių be atskirų schemų ar bazių. Detaliau „Architektūrinių sprendimų" skiltyje. - Indeksai. Ant karštų laukų —
user_id,order_id,store_id,created_at— visur stovi sudėtiniai indeksai. Ypač svarbu client_events: tipinėms užklausoms pagal laiko segmentą ir vartotoją ascending skenavimas tampa pigus. - Tranzakcijos nurašant inventorių. Race condition vienu metu vykdant užsakymus buvo užfiksuotas ankstyvoje fazėje ir sutvarkytas per DB tranzakciją su sąlyga
WHERE inventory >= qty— vienoje tranzakcijoje ir tikriname, ir atnaujiname. Jei sąlyga neįvykdoma, užsakymas grąžina 409 Conflict.
Redis 7
Redis užima kelias roles iš karto:
- Cache. Vartotojo session duomenys, dažnos dictionary užklausos (pavyzdžiui, aktyvūs store-config) ir cachinti geokodavimo rezultatai dažnai naudojamiems adresams.
- Pub/sub. Kanalai botams (žr. ankstesnę skiltį).
- Rate limiting. NestJS throttler naudoja Redis store limitų dalinimui tarp kelių worker procesų.
- Real-time. Socket.IO adapter naudoja Redis pranešimų koordinavimui tarp kelių WebSocket gateway egzempliorių.
Viename Docker konteineryje Redis aptarnauja visus keturis scenarijus. Tai veikia, nes apkrova Redis pas mus sudaro procentus nuo jo galimybių.
Real-time: WebSocket gateway ir kambariai
WebSocket gateway pastatytas ant Socket.IO ir gyvena apps/api/src/modules/realtime. Penki pagrindiniai įvykiai apima visą real-time kontraktą tarp serverio ir klientų:
ORDER_CREATED— užsakymas sukurtas;ORDER_STATUS_CHANGED— pasikeitė statusas;ORDER_ASSIGNED— priskirtas vairuotojas;DRIVER_LOCATION_UPDATED— atsinaujino vairuotojo geopozicija;DRIVER_STATUS_CHANGED— vairuotojo statusas.
Kiekvienas įvykis lekia į vieną arba keletą kambarių pagal principą „gavėjas pats užsiprenumeravo":
store:{storeId}— bendras parduotuvės kambarys visiems jos darbuotojams;dispatcher:{userId}— asmeninis konkretaus dispetčerio kambarys (pavyzdžiui, užsakymų priskyrimams jam);driver:{userId}— asmeninis vairuotojo kambarys;customer:{userId}— asmeninis pirkėjo kambarys pranešimams apie statusą.
Tai duoda skaidrią pranešimų semantiką: galiu publikuoti ORDER_ASSIGNED į driver:42 ir tiksliai žinoti, kad pranešimą gaus tik šis vairuotojas, o ne visa komanda. Ir tuo pačiu — dubliuoti į store:7, kad visi šios parduotuvės dispetčeriai pamatytų atnaujinimą savo sąrašuose.
Geokodavimas ir ETA skaičiavimas — atskiras servisas API, naudojantis Nominatim (OSM). Kai užsakymas sukuriamas su koordinatėmis, ETA skaičiuojama iš atstumo, transporto tipo ir bazinių koeficientų, o paskui atnaujinama per kiekvieną DRIVER_LOCATION_UPDATED.
Order state machine
Užsakymo būsenos aprašytos packages/shared/src/orders.ts konstanta ORDER_TRANSITIONS, kuri veikia kaip deklaratyvi state machine. Kiekvienas perėjimas patikrinamas orders servise prieš keičiant statusą. Jei bandymas pereiti nelegalus — kviečiama domeninė klaida, ir API atsako 422. Tai apsaugo nuo bugų tipo „dispetčeris atsitiktinai pratęsė jau pristatytą užsakymą" ir nuo race-condition, kai du skirtingi kandidatai keičia statusą lygiagrečiai.
stateDiagram-v2 [*] --> PENDING: sukurtas kliento PENDING --> CONFIRMED: mokėjimas patvirtintas PENDING --> CANCELLED CONFIRMED --> READY: supakuotas READY --> ASSIGNED: priskirtas vairuotojas ASSIGNED --> PICKED_UP: vairuotojas paėmė PICKED_UP --> DELIVERED: pristatytas DELIVERED --> [*] CONFIRMED --> CANCELLED READY --> CANCELLED ASSIGNED --> CANCELLED PICKED_UP --> CANCELLED: refund flow
Kiekvienam perėjimui į order_status_history rašomas įrašas su from_status, to_status, actor_id ir actor_role. Tai fiksuoja pilną užsakymo chronologiją, ir admin panelėje mes ją renderiname vizualia tampnaline. Naudinga ir analitikai (kiek užsakymas kabo kiekviename statuse), ir incidentų tyrimui.
Infrastruktūra: dev ir prod
Dev: VPS7
Programuotojo aplinka — atskiras VPS su Ubuntu, kur gyvena systemd vienetai visiems pagrindiniams dev serveriams:
dev-api— portas 3000, NestJS watch režime;dev-web— portas 3001, Next.js dev serveris;dev-admin— portas 3002, Vite dev serveris;dev-sales— portas 3005, Vite sales mini-app.
PostgreSQL 16 sukasi lokaliai ant 5432, Redis — Docker viduje ant 6379. Hot reload veikia visose programose, todėl pakeitus kodą nieko neperleidžiu rankiniu būdu. Perkrovimas reikalingas tik keičiant .env arba užsikabinus procesui.
Build lokaliai nedaromas. Iš viso. Dev serveryje sąmoningai nėra TypeScript kompiliatoriaus produkcijos režime ir nėra vite/next build. Bet koks npm run build ar tsc ėda atmintį ir lėtina kitus procesus. Build — CI užduotis.
Prod: VPS6 su Docker compose
Prod serveris — atskiras VPS, kuriame viskas sukasi Docker compose. Failas — /opt/delivery/docker-compose.prod.yml. Servisų devyni, visi pull'inami iš GHCR:
| Service | Aprašymas |
|---|---|
api | NestJS API |
web | Next.js storefront |
admin | Vite SPA admin panelės |
sales-app | Vite sales mini-app, portas 4005 |
sales-bot | Python sales-bot |
dispatch | Vite dispatcher mini-app, portas 3003 |
driver-miniapp | Vite driver mini-app, portas 3004 |
telegram-bot | Python customer/dispatcher-bot |
telegram-driver-bot | Python driver-bot |
Plius konteineriai postgres, redis ir nginx (80/443) su Let's Encrypt sertifikatais. Nginx — reverse proxy į docker servisus ir SSL terminatorius. Jis ir realizuoja HTTP/2 bei Brotli statikai.
Pagrindinis niuansas: docker compose restart NEpergiesti .env. Tai spąstai, į kuriuos lengva pakliūti. Po bet kokio aplinkos kintamųjų pataisymo reikia daryti docker compose -f docker-compose.prod.yml down <service> ir paskui up -d <service> konkrečiai šiam servisui. Užfiksavau tai projekto CLAUDE.md, kad pakartotinai nelipčiau ant grėblio.
Image'ai ir registras
Visi image'ai publikuojami į GitHub Container Registry: ghcr.io/<org>/delivery-platform/<service>. Kiekvienas servisas žymimas dviem žymėmis — latest ir sha-<short>. Pinname latest compose faile, nes mūsų apkrovai ir SLA tai normalus kompromisas tarp lankstumo ir nuspėjamumo; jei kada nors tai taps problema, persijungsime į pin pagal sha.
Dockerfiles
Kiekvienoje programoje guli savas Dockerfile (devyni iš viso). Tai įprasti multi-stage buildai: stage build su node:20-alpine JS servisams ir python:3.12-slim botams, paskui — runtime stage be dev priklausomybių. Image'ai gaunasi kompaktiški ir greitai diegiami.
Compose failai
Repozitorijoje gyvena trys compose failai — skirtingoms užduotims:
docker/docker-compose.yml— minimalus dev stekas: tik PostgreSQL ir Redis. Patogu, jei programuotojas dirba lokaliai ir nori pakelti tik infrastruktūrą.docker-compose.yml(šaknyje) — pilnas lokalus stekas: postgres, redis ir visos devynios programos. Naudojama integraciniams patikrinimams.docker-compose.prod.yml— produkcijos konfigūracija su realiais portais, healthcheck ir nginx.
Nginx ir domenai
Nginx konfigūracija aprašo septynis domenus ir proxina juos į konkrečius docker servisus:
app.platform.com— web storefront;admin.platform.com— admin panelė;api.platform.com— API;shop.platform.com— sales mini-app;dispatch.platform.com— dispatcher mini-app;driver.platform.com— driver mini-app.
(Reali domenai praleisti dėl NDA. Faktinėje konfigūracijoje kiekvienas vardas — atskiras server-blokas su Let's Encrypt SSL sertifikatu ir proxy_pass į reikiamą upstream.)
CI/CD: path-based filtering ir GHCR
Failas — .github/workflows/docker-build.yml. Trigger — push į main. Kas viduje:
flowchart TB
Dev[VPS7 dev<br/>Hot reload]
PR[git push to main]
GHA[GitHub Actions<br/>Smart path filter]
CHANGED{Changed services?}
Build[Docker build per service]
GHCR[(GHCR registry)]
SSH[SSH to VPS6<br/>via appleboy/ssh-action]
DC[docker compose pull + up -d<br/>only changed services]
Prod[VPS6 prod<br/>9 containers + nginx]
Dev --> PR
PR --> GHA
GHA --> CHANGED
CHANGED -->|yes| Build
CHANGED -->|no| End([skip])
Build --> GHCR
GHCR --> SSH
SSH --> DC
DC --> ProdWorkflow prasideda nuo dorny/paths-filter@v3, kuris žiūri į commit pakeitimus ir sako, kuriuos iš devynių buildable servisų realiai reikia perrinkti (api, web, admin, dispatch, driver-miniapp, telegram-bot, telegram-driver-bot, sales-app, sales-bot). Pavyzdžiui, pataisymas apps/web/src/... palies tik web, ir tada CI perrinks lygiai web ir paliks visa kita ta pačia versija. Tai taupo apie 5–10 minučių kiekvienam push'ui ir kartu supaprastina rollback: kiekvienas servisas versionuojamas nepriklausomai.
Pagrindiniai žingsniai po path-filter:
- Build per service. Kiekvienam paliestam servisui GitHub Actions paleidžia
docker buildsu teisinguDockerfileir žymeghcr.io/<org>/delivery-platform/<service>:latestplius:sha-<short>. - Push į GHCR. Image'ai publikuojami į GitHub Container Registry. CI prieiga prie jo sukonfigūruota per GITHUB_TOKEN su
packages: write. - SSH į VPS6.
appleboy/ssh-actioneina į prod serverį per raktą, vykdodocker compose -f /opt/delivery/docker-compose.prod.yml pull <service>ir paskuiup -d <service>— tik paliestiems servisams. - Healthcheck. Po restart'o compose duoda servisui laiko atlikti healthcheck. Jei jis ne žalias — prode iš karto matome tai Pushover.
Saugumo taškai šiame pipeline:
- secrets saugomi GitHub Actions (
GHCR_TOKEN,VPS_SSH_KEY,DEPLOY_HOST,DEPLOY_USER); niekas nekoduojama tiesiogiai repe; - ssh-action naudoja ed25519 raktą, kuris provižinamas tik į CI runner ir niekur kitur nešviečia;
- kelias
docker-compose.prod.ymlir servisų sąrašas — vienintelis dalykas, kurį workflow liečia prode.
Skriptai ir operacinės pagalbinės priemonės
Šakniniame scripts/ kataloge gyvena nedidelis naudingų pagalbinių priemonių rinkinys:
backfill-geocoding.sh— paketinis geokodavimo duomenų backfill. Ima visus užsakymus, kurie neturi lat/lng, eina į Nominatim, normalizuoja adresą ir įrašo koordinates atgal. Naudojama vieną kartą po adresų schemos pakeitimų arba importuojant senus duomenis.screenshot.jsirhelp-screenshots.mjs— ekrano nuotraukų generavimas help puslapiams. Jungiasi prie dev stendo, daro snapshot UI ir išsaugo PNG.inject-help-images.mjs— šių paveikslėlių įterpimas į help turinį po rendering.rescale-menu-icons.js— meniu ikonų apdorojimas į standartinį dydį visoms programoms.setup-api.sh— API įdiegimas: migracijos, seed ir bazinis.envsetup.test-flows.sh— smoke-test scenarijų rinkinys: sukurti vartotoją, suformuoti užsakymą, pažymėti pristatytu.
Tai nepretenduoja į pilną automatizaciją, bet pašalina rutiną iš 10–15 pasikartojančių užduočių, kurios būna kartą per sprintą.
Sauga
Sauga užtikrinta keliuose lygmenyse. Punktus dalinsiu pagal sluoksnius, nes nesisteminis sąrašas „pas mus yra Helmet" paprastai nieko nepaaiškina.
Identifikacija ir slaptažodžiai
- Slaptažodžiai hešinami bcrypt su cost factor 10. Tai konkretus kompromisas tarp prisijungimo greičio (~50ms šiuolaikiniame CPU) ir atsparumo brute-force.
- JWT access-tokenai — trumpaamžiai, 15 minučių (istoriškai buvo 30 dienų; sutvarkyta po audito, žr. „Techninės skolos" skiltį).
- Refresh-tokenai ilgaamžiai, su rotation: kiekvienas panaudojimas generuoja naują refresh ir invaliduoja ankstesnįjį. Tai sumažina atakos langą nutekant refresh.
- OTP kodai per Twilio A2P (registruota kampanija po TCR vetting) ir SMS-Gate Android kaip backup kanalas. Round-robin tarp dviejų įrenginių, retry esant failure.
- Telegram WebApp HMAC-SHA-256 — aprašyta mini-app skiltyje.
Transportas ir antraštės
- HTTPS visur, be išimčių. Let's Encrypt išduoda sertifikatus visiems septyniems domenams.
- HSTS įjungtas su
max-age=31536000irincludeSubDomains. Tai įtvirtina HTTPS metams į priekį, ir backend taip pat grąžina antraštę su kiekvienu atsakymu per Helmet. - Helmet pakeltas
main.ts:X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Strict-Transport-Security,Referrer-Policy: strict-origin-when-cross-origin. - CORS — whitelist konkretiems domenams (įskaitant dispatch.platform.com ir driver.platform.com mini-app, plius admin panelė ir storefront). Jokių
*.
Rate limit
@nestjs/throttler— throttling jautriems endpointams: auth (login/register/forgot-password), events public (10/min), events replay (6/min) ir kitos vietos, kur kitaip galima užversti API užklausomis.- Globalus limitas API stovi švelnesnis, kad legitimūs klientai neatsitrenktų.
Kriptomokėjimai
Tai atskira istorija, nes invalidacija ir parašai kriptosrityje paprastai — rizikiausia dalis. Požiūris:
- BTC — derivation iš xpub rakto skristi, naujas adresas kiekvienam depozitui. Privatus raktas niekur nesaugomas API; xpub guli apsaugotame config skirsnyje.
- ETH/USDT-ERC20/USDC-ERC20 — piniginės generuojamos programiškai per bibliotekas ir saugomos su privačiu raktu, šifruotu AES-256 nuo ENV master-rakto kintamojo.
- TRC-20 (USDT TRON) — atskiras kanalas pagal analogiją su ETH.
- Į kiekvieną įplaukimą API monitoringuoja atitinkamą blockchain per RPC tiekėją ir rašo
wallet_transactionsu patvirtinimais.
Veiksmų logai
Bet koks jautrus veiksmas logginamas:
login_log— kas, kada, iš kokio IP, sėkmė/klaida;otp_log— OTP siuntimas ir validacija;product_audit_log— kas keitė kokius produkto laukus;wallet_transaction— visi depozitai ir nurašymai.
Audit-trail admin panelėje naudoja product_audit_log gražios prekės pakeitimų istorijos renderinimui su „smart grouping": pataisymų serija vieno vartotojo per trumpą laiką sutraukiama į vieną bloką, o reikšmėms renderinamas kontekstinis formatavimas (price → $12.99, category_id → Tinctures).
Observability ir analitika
Stebimumas — atskiras grafas, kurį statyte statiau pagal projekto augimą. Dabar jis apima tris uždavinių klases: produkto analitiką, incidentų tyrimą ir operacinius pranešimus.
client_events — klientinis trackinimas (90 dienų)
Lentelė client_events saugo visus klientinius įvykius per paskutines 90 dienų:
page_view— perėjimai;product_view— kortelės peržiūra;cart_add,cart_remove,cart_update_qty— krepšelis;favorite_add,favorite_remove— mėgstamiausi;search,search_no_results— paieška (antrasis ypač vertingas praleistų užklausų aptikimui);checkout_start,place_order,success,error,zone_unavailable— checkout voronė;api_error,js_error— klientinės klaidos.
Tai — pamatas visam kitam: produkto analitikai, voronių konversijai, klaidų stebėjimui. Viešas endpointas POST /events priima paketus iki 50 įvykių ir rate-limit 10/min, kad apsaugotų lentelę nuo bot trafiko. Cron job valo įvykius senesnius nei 90 dienų kiekvieną dieną 3:00.
session_recordings — rrweb (30 dienų)
session_recordings saugo rrweb chunkus autorizuotų vartotojų sesijų. Chunkai ateina į endpointą POST /events/replay (rate limit 6/min). Visi įvesties laukai užmaskuoti (maskAllInputs: true), o vienas įrašas apribotas 15 minučių aktyvumo. Cron valo senesnius nei 30 dienų 4:00.
Admin panelėje yra puslapis /activity-log su keturiais skirtukais:
- All events — filtravimas pagal vartotoją, įvykio tipą, periodą;
- Errors — JS klaidos ir API klaidos su kontekstu;
- Search Analytics — populiariausios užklausos ir top-10 užklausų be rezultatų;
- User Journey + Replay — konkretaus vartotojo kelias ir mygtukas „Atkurti sesiją", atidarantis rrweb player ant chunkų iš storage.
Tai stipriai paspartina incidentų tyrimą. Kai operatorius sako „mano užsakymas krito", atidarau jo sesiją ir matau žingsnius, kuriuose įvyko js_error arba 422 iš API.
Audit trail ir checkout funnel
Be client_events, mes turime keletą specializuotų log lentelių:
checkout_log— kiekvienas checkout žingsnis su kontekstu (adresas, pasirinktas metodas, galutinė suma);login_log,otp_log— aprašyti aukščiau;product_audit_log— prekių pakeitimai;wallet_transaction— finansinės operacijos.
Admin panelėje šių duomenų pagrindu renkama keletas funnel ataskaitų:
- registration_funnel — konversija nuo telefono įvedimo iki OTP patvirtinimo;
- login_funnel — konversija nuo prisijungimo bandymo iki sėkmingo prisijungimo;
- checkout_funnel — pagrindinis piniginis funnel: cart → address → payment → place_order → success.
Pridėjau atskirą „Issues monitor" — ekraną, kuriame susumuojamos top priežastys checkout failure (zone unavailable, payment declined, OTP expired), JS klaidos ir API klaidos per paskutines 24 valandas. Tai greitas būdas pastebėti regresiją po deploy.
Pushover
Operatyviems pranešimams naudoju Pushover. Hardkodinam tiksliai tuos, kam reikia — tai keturi dispetčeriai ir du vairuotojai apps/api/src/modules/notifications/pushover.service.ts. Triggeriai:
- Naujas užsakymas —
priority=2(loud, skambina iki patvirtinimo, 30 sekundžių retry). Užsakymai NETURI dingti. - Užsakymas priskirtas vairuotojui —
priority=2konkrečiam vairuotojui. - Užsakymas pristatytas —
priority=1(normal) dispetčeriams.
Tai uždaro operatyvinę SLA be sudėtingos alertų sistemos: jei užsakymas sukurtas, bet nepatvirtintas per 30 sekundžių, dispetčeriai gauna skambutį-žadintuvą.
Cron jobs
API viduje gyvena du paprasti kasdieniniai cron jobai:
- 3:00 —
DELETE FROM client_events WHERE created_at < NOW() - INTERVAL '90 days'; - 4:00 —
DELETE FROM session_recordings WHERE created_at < NOW() - INTERVAL '30 days'.
Tai išlaiko lenteles protingo dydžio ir nuspėjamame greityje.
Integracijos
Į platformą įvesta keletas išorinių servisų, ir kiekvienas uždaro konkrečią užduotį. Aš nebandau pakeisti vieno kitu: SMS, email, Pushover ir Telegram — tai skirtingi kanalai su skirtingomis SLA ir kainomis.
- Twilio A2P — SMS kanalas produkcijoje. Kampanija registruota ir laukia TCR vetting. Po patvirtinimo taps pagrindiniu maršrutu OTP per SMS.
- SMS-Gate Android — du įrenginiai (2NROCH, M7TXQY) skirtinguose numeriuose. Round-robin apkrovos paskirstymui ir retry, kai vienas iš įrenginių atsisako. Naudojamas OTP registracijai/prisijungimui. Nenaudojamas forgot-password — ten email.
- Email (SMTP) — tik forgot-password. Kitų email pranešimų platforma neturi, ir tai sąmoninga: kiekvienas papildomas kanalas — papildomas spam ir gedimo taškas.
- Pushover — aprašyta aukščiau.
- Telegram Bot API — trys botai, visi per aiogram.
- Google OAuth 2.0 — register/login webui. Praneša
affiliateCodeper redirect. - Crypto wallets — BTC per xpub derivation, ETH, USDT-ERC20, USDC-ERC20, TRC-20.
- WhatsApp — multi-node gateway: iki 5 mazgų, round-robin su sticky-session (tas pats dialogas eina per vieną mazgą).
existsCache: Mapsaugo telefono patikrinimus dėl WhatsApp buvimo, kad nedraskytume mazgo kiekvienam pranešimui. (Čia pat — žinoma techninė skola: cache be eviction, žr. „Techninės skolos" skiltį.) - Geocoding (Nominatim/OSM) — visiems adresams ir pristatymo zonoms. Be komercinių kvotų.
messaging modulio viduje sukonfigūruota vieninga logika „WhatsApp first, SMS fallback": jei vartotojas turi WhatsApp, siunčiame ten; jei ne — SMS. Tai sumažina pranešimų kainą ir pagerina delivery-rate.
Architektūriniai sprendimai ir kodėl būtent taip
Čia — dešimt pagrindinių sprendimų ir jų pagrindimas. Didžioji dalis iš jų — tai sprendimai, kuriuos priėmiau vieną kartą ir prie jų daugiau nebegrįžau, nes jie veikia.
1. Monorepo ant Turborepo
Alternatyva — keletas atskirų repozitorijų API, web, admin ir kiekvienai mini-app. Pasirinkau monorepo dėl trijų priežasčių:
- bendri tipai (Order, User, OrderStatus, SOCKET_EVENTS) gyvena
packages/sharedir automatiškai sinchronizuojasi tarp API ir frontų; vienas enum pakeitimas iš karto parodo visas TypeScript klaidas; - Turbo path-filter pagreitina CI: pataisius
webneperleidžiame botų ir API; - viena komanda
npm installir suderintos priklausomybių versijos; nėra pragaro su konfliktuojančiomis React ir Tailwind versijomis.
Kaina — šiek tiek sunkesnis repo ir būtinybė apmokyti komandą Turbo komandų. Mano mastelyje tai atsiperka per pirmą savaitę.
2. TypeORM + rankinės migracijos
TypeORM — brandžiausias ORM Node ekosistemoje su TypeScript-first tipizacija. Auto-sync nenaudoju: vietoj jo — rankinės SQL migracijos. Tai duoda du privalumus:
- aš tiksliai žinau, kas migruoja, ir galiu sustoti;
- migracijų diff'ai skaitomi review metu.
Alternatyvos (Prisma, Drizzle) pas mane nelaimėjo pagal visumą. Prisma — puikus builder, bet su migracijomis aš teikiu pirmenybę rankiniam darbui. Drizzle gražus, bet projekto pradžioje jis dar buvo jaunas.
3. Multi-tenant per store_id
Paprasčiausias sprendimas multi-tenant — atskiros bazės ar schemos. Pasirinkau stulpelį store_id pagrindinėse lentelėse, nes:
- vienas serveris aptarnauja keletą parduotuvių ir fizinė izoliacija nereikalinga;
- filtravimas pagal
store_idvykdomas query lygmenyje ir lengvai testuojamas; - bekapai ir migracijos — viena komanda visai bazei.
Kaina — reikia disciplinuotai filtruoti. Uždarau tai per guard servisų lygmenyje: bet kuris findOrders priima storeId privalomu argumentu, ir užmiršti jo negalima.
4. Redis pub/sub botams
Botai — atskiri procesai. Jie galėtų kreiptis į API per REST, bet tada teku tempti pilnavertį auth tarp servisų (cross-service tokenai) ir apdoroti eventual-consistency. Pub/sub paprasčiau:
- API publikuoja įvykį į
notifications:customer; - botas gauna, formatuoja ir siunčia;
- API nežino apie Telegram, botas nežino apie bazę.
Minusas — reikia aiškiai aprašyti pranešimų schemą. Saugau ją packages/shared/src/notifications.ts kaip TypeScript tipus ir pinneru Python pusėje per JSON schemą.
5. rrweb sesijų atkūrimui
Atkurti bugus iš logų ir vartotojų aprašymų — silpnas sprendimas. Kai operatorius rodo „štai, pas klientą niekas neveikia", atidarau jo sesiją rrweb ir matau, kas įvyko. Su maskAllInputs: true tai saugu PII, o laiko limitas 15 minučių užkerta kelią gigabaitiniams įrašams.
6. Kastominis audit-trail admin panelėje (o ne TypeORM-history)
TypeORM moka saugoti istoriją per subscriber'ius, bet „neapdorota" istorija — tai diff log, kurį sunku skaityti. Pasirinkau savo product_audit_log su smart-grouping (apjungia to paties vartotojo pataisymus trumpu laiku) ir kontekstiniu formatavimu (price → $12.99, category_id → kategorijos pavadinimas). Tai duoda adminui patogų ekraną „štai šios prekės istorija", o ne JSON-diff'ų krūvą.
7. Pushover priority=2 kritiniams įvykiams
priority=2 reiškia, kad Pushover skambins vartotojui kas 30 sekundžių, kol tas nepatvirtins. Tai — vienintelis kanalas, kuris realiai garantuoja operatyvinio pranešimo pristatymą. SMS dingsta, push Telegram gali būti nutildyti, email — tai apskritai ne pranešimas. Pushover priority=2 pas mus stovi ant „naujas užsakymas" ir „vairuotojo priskyrimas", ir per visą laiką nei vieno užsakymo nepraleidome.
8. Path-based filtering CI
Be jo CI perrinktų visus devynis servisus kiekvienam push. Su juo — tik tuos, kurie realiai pasikeitė. Pataisymuose web tai sutrumpina pipeline nuo ~12 minučių iki ~3.
9. Docker compose su named services ir nginx
Docker compose — paprastas, skaitomas ir pakankamas mūsų masteliui. Alternatyvos (Kubernetes, Nomad, ECS) — tai kita sudėtingumo klasė, ir nematau jose naudos esant devyniems servisams viename serveryje. Nginx terminuoja SSL ir proxina į upstream'us pagal domeną, docker compose healthcheck'ais užtikrina graceful restart push'uojant.
10. NestJS modules + DI
NestJS — iš esmės Angular-style dekompozicija backendui. Kiekvienas modulis izoliuotas, priklausomybės perduodamos per DI. Tai duoda:
- lengva testuoti (nors dabar testų nėra — žr. techninę skolą);
- lengva mock'inti priklausomybes derinant;
- aiškios ribos: „orders priklauso nuo products, ne atvirkščiai".
Alternatyvos (Express + rankinis servisų rinkimas, Fastify + DIY DI) pigesnės pradinėje kainoje, bet brangesnės masteliu su trisdešimt šešiais moduliais.
Techninė skola kaip brandi inžinerinė praktika
Tobulo projekto nebūna. Brandi komanda neapsimeta, kad pas ją viskas švaru, o veda atvirą techninių skolų sąrašą ir prioritizuoja jas. Pas mane — tas pats.
Critical (uždaryta)
JWT access-tokenas 30 dienų. Sutvarkyta po audito: dabar 15 minučių, refresh-tokenai su rotacija.OTP kodas loguose. Pašalintasconsole.logsu pačiu koduauth.service.ts.Math.random slaptažodžiams. Pakeista įcrypto.randomBytesvisose penkiose vietose.Inventory race condition. Uždaryta per DB tranzakcijas su sąlygaWHERE inventory >= qty.PromoStatus enum DELETE→ DELETED.Dubliuoti variantai kuriant prekę.
High (darbuose)
- Refresh token be rotation — iš dalies uždaryta, reikia baigti ankstesnio tokeno invalidaciją jo panaudojimo metu.
- Refresh endpoint be rate limit — vienintelis auth endpointas be
@Throttle. Paprastas pataisymas, eilėje. - WhatsApp existsCache memory leak — Map be eviction strategijos. Reikia arba LRU, arba padaryti eviction pagal TTL.
- TypeORM synchronize dalyje modulių — verčiame į švarias migracijas.
Medium (techninė skola disciplinai)
- Testų nėra. Jie buvo, bet ištrinti dar ankstyvuose etapuose dėl iteracijų greičio. Tai matoma skola, ir aš sąžiningai ją pripažįstu. Planas — pridėti unit testus kritiniams servisams (auth, orders, inventory) ir e2e checkoutui.
- Tylus
.catch(() => {})orders/notifications. Kažkur tai padaryta sąmoningai (fire-and-forget pranešimams), bet orders tai užmaskuoja realias klaidas. Reikia review kiekvienam case. - Inventory log užsakymui — lentelė yra, bet įrašas nurašant kol kas neįrašomas. Tai matomas gap audit-trail.
- Session TTL 30 dienų — per ilgai security-jautrioms paskyroms. Reikia sumažinti bent iki 14 dienų arba įvesti roles su skirtingais TTL.
Principai
Laikausi kelių paprastų taisyklių dirbant su technine skola:
- Kiekviena skola užrašyta su prioritetu ir kontekstu, kad komanda galėtų ją paimti.
- Critical skola uždaroma iki kito release. Jokių „pataisysim kitame sprint".
- High ir Medium eina į backlog ir prioritizuojamos kartu su fičėmis; negalima begaliniai atidėlioti.
- Techninė skola viešai matoma: aš jos nepaslepiu privačiame trackeryje nuo stakeholderių. Tai skaidrios inžinerinės kultūros dalis.
Baigiamosios mintys
Jei reziumuoti viena eilute: paprasti blokai, aiškios ribos, apsaugoti komunikacijos taškai. Platforma išgyvena ne dėl stebuklingų technologijų, o todėl, kad kiekvienas sluoksnis daro savo darbą ir nelenda į svetimą. API nežino apie Telegram, botai nežino apie bazę, admin panelė nekartoja frontendo, mini-app — tai atskira SPA tinkamame paviršiuje (Telegram WebView). CI perrinkliai tik tai, kas pasikeitė. Prod — tai devyni docker konteineriai už nginx, ir kiekvieną iš jų galima perleisti nepriklausomai. Observability renka įvykius ir rrweb sesijas be perteklinės infrastruktūros. Sauga — keliuose sluoksniuose, ir kiekvienas iš jų — trumpas ir aiškus kodas.
Šis stekas gerai dera prie „boring tech principle": renkuosi išbandytas priemones, minimizuoju judančių dalių skaičių ir atidžiai stebiu, kas brandu, o kas — kol kas ne. Kuo mažiau netikėtumų infrastruktūroje, tuo daugiau laiko lieka produktui.
Nuorodos
- Verslo projekto puslapis: Last-mile Delivery — kazusas
- Admin funkcionalumas: Platformos admin panelė
- Šis skyrius išeities tekstuose:
apps/api/src/modules/,apps/web/src/app/[locale]/,.github/workflows/docker-build.yml,docker-compose.prod.yml,package.json,turbo.json