{"id":478842,"date":"2026-05-06T19:58:10","date_gmt":"2026-05-06T19:58:10","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=478842"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=478842","title":{"rendered":"TeachTrack: NestJS + Telegram-\u0431\u043e\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439 + \u0420\u041a\u041d \u2014 \u043a\u0430\u043a \u044f \u0432 \u043e\u0434\u0438\u043d\u043e\u0447\u043a\u0443 \u0441\u043e\u0431\u0440\u0430\u043b CRM \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u0440\u0435\u043f\u0435\u0442\u0438\u0442\u043e\u0440\u043e\u0432"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>\u041c\u0435\u0441\u044f\u0446 \u043d\u0430\u0437\u0430\u0434 \u044f \u0432\u044b\u043b\u043e\u0436\u0438\u043b \u043d\u0430 \u0425\u0430\u0431\u0440 \u0441\u0442\u0430\u0442\u044c\u044e \u043f\u0440\u043e <a href=\"https:\/\/habr.com\/p\/1024896\" rel=\"noopener noreferrer nofollow\">TripTrack<\/a> \u2014 GPS-\u0442\u0440\u0435\u043a\u0435\u0440 \u0434\u043b\u044f \u043c\u0430\u0448\u0438\u043d\u044b \u043d\u0430 iOS, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0441\u043e\u0431\u0440\u0430\u043b \u0431\u0443\u0434\u0443\u0447\u0438 \u0431\u044d\u043a\u0435\u043d\u0434\u0435\u0440\u043e\u043c \u0431\u0435\u0437 \u043e\u043f\u044b\u0442\u0430 \u0432 Swift. \u0421\u0442\u0430\u0442\u044c\u044f \u043d\u0435\u043e\u0436\u0438\u0434\u0430\u043d\u043d\u043e \u043d\u0430\u0431\u0440\u0430\u043b\u0430 7.4\u041a (\u043d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u044f) \u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u043e\u0432. \u041d\u043e, \u043c\u043d\u0435 \u043f\u043e\u0441\u0447\u0430\u0441\u0442\u043b\u0438\u0432\u0438\u043b\u043e\u0441\u044c \u043f\u043e\u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043f\u043e \u0441\u0432\u043e\u0435\u0439 \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438, \u043d\u0435 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u0434 IOS-\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0430 \u043f\u043e\u0434 NestJS \u0431\u0435\u043a\u0435\u043d\u0434.<\/p>\n<p>\u041f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e \u0441 TripTrack \u044f \u043f\u0438\u0441\u0430\u043b \u0432\u0442\u043e\u0440\u043e\u0439 \u043f\u0440\u043e\u0435\u043a\u0442 \u2014 \u043d\u0430 \u044d\u0442\u043e\u0442 \u0440\u0430\u0437 \u0440\u043e\u0432\u043d\u043e \u0432 \u0437\u043e\u043d\u0435 \u043a\u043e\u043c\u0444\u043e\u0440\u0442\u0430 (NestJS + PostgreSQL), \u0438 \u044d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u043b\u043e \u0440\u0430\u0437\u0432\u0435\u0440\u043d\u0443\u0442\u044c\u0441\u044f \u043f\u043e-\u0441\u0435\u0440\u044c\u0451\u0437\u043d\u043e\u043c\u0443: \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u043e\u043d\u043d\u044b\u0439 outbox \u0434\u043b\u044f \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u044b\u0445 \u043e\u0442\u043f\u0440\u0430\u0432\u043e\u043a \u0432 Telegram, single-use invite-\u0442\u043e\u043a\u0435\u043d\u044b \u0441 \u0437\u0430\u0449\u0438\u0442\u043e\u0439 \u043e\u0442 enumeration, timezone-aware scheduler, partial unique indexes \u2014 \u0441\u043b\u043e\u0432\u043e\u043c, \u0432\u0441\u0451 \u0442\u043e, \u0447\u0442\u043e \u0434\u043b\u044f \u0431\u044d\u043a\u0435\u043d\u0434\u0435\u0440\u0430 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e \u0441\u0430\u043c\u043e \u043f\u043e \u0441\u0435\u0431\u0435.<\/p>\n<p>\u041f\u043e\u0434 \u043a\u0430\u0442\u043e\u043c \u2014 \u043f\u0440\u043e \u0442\u043e, \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0435\u043d Telegram-\u0431\u043e\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439 \u0432 <a href=\"https:\/\/teachtrack.ru\" rel=\"noopener noreferrer nofollow\">TeachTrack<\/a>, \u0447\u0442\u043e \u044f \u043f\u043e\u043d\u044f\u043b \u043f\u0440\u043e <code>pessimistic_write<\/code> \u0438 <code>FOR UPDATE SKIP LOCKED<\/code>, \u0437\u0430\u0447\u0435\u043c pet-\u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0441 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u043c\u0438 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c\u0438 \u0438\u0437 \u0420\u0424 \u043d\u0443\u0436\u043d\u043e \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0432 \u0420\u041a\u041d, \u0438 \u043f\u043e\u0447\u0435\u043c\u0443 \u0445\u043e\u043b\u043e\u0434\u043d\u044b\u0439 \u0430\u0443\u0442\u0440\u0438\u0447 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f\u043c \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u043e\u0433\u043e \u043d\u0430\u0443\u0447\u0438\u043b \u043c\u0435\u043d\u044f \u0432\u0430\u0436\u043d\u043e\u0439 \u0432\u0435\u0449\u0438 \u043f\u0440\u043e \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u043c\u0435\u043d\u0442\u0430\u043b\u0438\u0442\u0435\u0442.<\/p>\n<h3>\u0417\u0430\u0447\u0435\u043c \u044d\u0442\u043e \u0432\u043e\u043e\u0431\u0449\u0435<\/h3>\n<p>\u041c\u043e\u044f \u0434\u0435\u0432\u0443\u0448\u043a\u0430 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0451\u0442 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0438\u0439. \u0423\u0447\u0435\u043d\u0438\u043a\u043e\u0432 \u0434\u0435\u0441\u044f\u0442\u043e\u043a, \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0440\u0430\u0437 \u0432 \u043d\u0435\u0434\u0435\u043b\u044e, \u0438 \u043a\u0430\u0436\u0434\u043e\u0435 \u043d\u0430\u0447\u0430\u043b\u043e \u043c\u0435\u0441\u044f\u0446\u0430 \u2014 \u043e\u0434\u043d\u0430 \u0438 \u0442\u0430 \u0436\u0435 \u043a\u0430\u0440\u0442\u0438\u043d\u0430: \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f Excel-\u0444\u0430\u0439\u043b, \u0440\u044f\u0434\u043e\u043c \u0431\u043b\u043e\u043a\u043d\u043e\u0442, \u0440\u044f\u0434\u043e\u043c \u043a\u0430\u043b\u0435\u043d\u0434\u0430\u0440\u044c \u0432 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0435. \u00ab\u0422\u0430\u043a, \u0443 \u041c\u0430\u0448\u0438 \u0431\u044b\u043b\u043e \u0447\u0435\u0442\u044b\u0440\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f, \u043d\u043e \u043e\u0434\u043d\u043e \u043e\u043d\u0430 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0438\u043b\u0430\u2026 \u0430 \u0418\u0432\u0430\u043d \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b \u0437\u0430 \u043f\u0430\u043a\u0435\u0442 \u043d\u0430 \u043f\u044f\u0442\u044c, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043b \u0442\u0440\u0438, \u043e\u0441\u0442\u0430\u043b\u043e\u0441\u044c \u0434\u0432\u0430\u2026 \u0430 \u041e\u043b\u0435\u0433\u0443 \u044f \u043d\u0435 \u043d\u0430\u043f\u0438\u0441\u0430\u043b\u0430, \u0447\u0442\u043e \u0443 \u043d\u0430\u0441 \u0437\u0430\u0432\u0442\u0440\u0430 \u0432 18:00\u2026\u00bb<\/p>\n<p>\u0420\u0430\u0437 \u0432 \u043c\u0435\u0441\u044f\u0446 \u044d\u0442\u043e\u0442 \u0443\u0447\u0451\u0442 \u043b\u043e\u043c\u0430\u043b\u0441\u044f. \u0422\u043e \u0443\u0447\u0435\u043d\u0438\u043a \u00ab\u043d\u0435 \u043f\u043e\u043c\u043d\u0438\u043b\u00bb, \u0447\u0442\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b \u0442\u043e\u043b\u044c\u043a\u043e \u0437\u0430 \u0434\u0432\u0430 \u0437\u0430\u043d\u044f\u0442\u0438\u044f. \u0422\u043e \u043e\u043d\u0430 \u043f\u0440\u0435\u0434\u043b\u0430\u0433\u0430\u043b\u0430 \u043f\u0435\u0440\u0435\u043d\u043e\u0441 \u2014 \u0438 \u0437\u0430\u0431\u044b\u0432\u0430\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c, \u0447\u0442\u043e \u043e\u043d\u043e \u043f\u0435\u0440\u0435\u043d\u0435\u0441\u0435\u043d\u043e. \u0422\u043e \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0435 \u0443\u0447\u0435\u043d\u0438\u043a\u0443 \u043d\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u043b\u043e\u0441\u044c \u0432\u043e\u0432\u0440\u0435\u043c\u044f, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u0432 \u044d\u0442\u043e \u0432\u0440\u0435\u043c\u044f \u0431\u044b\u043b \u0437\u0430\u043d\u044f\u0442, \u0438 \u043e\u043d \u043e\u043f\u0430\u0437\u0434\u044b\u0432\u0430\u043b \u043d\u0430 15 \u043c\u0438\u043d\u0443\u0442.<\/p>\n<p>\u042f \u0431\u044d\u043a\u0435\u043d\u0434-\u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a. \u041f\u043e\u0441\u043b\u0435 TripTrack \u0441\u0442\u0430\u043b\u043e \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e: \u0430 \u0435\u0441\u043b\u0438 \u0432\u0437\u044f\u0442\u044c \u0437\u0430\u0434\u0430\u0447\u0443, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u0443 \u043c\u0435\u043d\u044f \u0435\u0441\u0442\u044c \u043f\u043e\u043b\u043d\u044b\u0439 domain-expertise \u2014 \u0447\u0442\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u0441\u044f? \u041f\u043e\u043b\u0443\u0447\u0438\u043b\u0441\u044f TeachTrack: \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435, \u0443\u0447\u0451\u0442 \u043e\u043f\u043b\u0430\u0442 \u0438 \u043f\u0430\u043a\u0435\u0442\u043e\u0432 \u0437\u0430\u043d\u044f\u0442\u0438\u0439, \u0430\u0432\u0442\u043e-\u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043b\u043a\u0438 \u0443\u0447\u0435\u043d\u0438\u043a\u0430\u043c \u0432 Telegram \u043f\u0435\u0440\u0435\u0434 \u0443\u0440\u043e\u043a\u043e\u043c. \u0421\u0435\u0439\u0447\u0430\u0441 \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/f8b\/5f2\/294\/f8b5f2294e167434bb2352dec375ee62.jpg\" alt=\"\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u0434\u043b\u044f \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u0447\u0451\u0442\u0447\u0438\u043a\u0438 \u0443\u0447\u0435\u043d\u0438\u043a\u043e\u0432\/\u0433\u0440\u0443\u043f\u043f, \u0431\u043b\u0438\u0436\u0430\u0439\u0448\u0438\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f, \u0431\u044b\u0441\u0442\u0440\u044b\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f. \u0421\u044e\u0434\u0430 \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0448\u044c \u043f\u043e\u0441\u043b\u0435 \u043b\u043e\u0433\u0438\u043d\u0430\" title=\"\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u0434\u043b\u044f \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u0447\u0451\u0442\u0447\u0438\u043a\u0438 \u0443\u0447\u0435\u043d\u0438\u043a\u043e\u0432\/\u0433\u0440\u0443\u043f\u043f, \u0431\u043b\u0438\u0436\u0430\u0439\u0448\u0438\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f, \u0431\u044b\u0441\u0442\u0440\u044b\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f. \u0421\u044e\u0434\u0430 \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0448\u044c \u043f\u043e\u0441\u043b\u0435 \u043b\u043e\u0433\u0438\u043d\u0430\" width=\"2045\" height=\"1229\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/f8b\/5f2\/294\/f8b5f2294e167434bb2352dec375ee62.jpg 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/f8b\/5f2\/294\/f8b5f2294e167434bb2352dec375ee62.jpg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u0434\u043b\u044f \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u0447\u0451\u0442\u0447\u0438\u043a\u0438 \u0443\u0447\u0435\u043d\u0438\u043a\u043e\u0432\/\u0433\u0440\u0443\u043f\u043f, \u0431\u043b\u0438\u0436\u0430\u0439\u0448\u0438\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f, \u0431\u044b\u0441\u0442\u0440\u044b\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f. \u0421\u044e\u0434\u0430 \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0448\u044c \u043f\u043e\u0441\u043b\u0435 \u043b\u043e\u0433\u0438\u043d\u0430<\/figcaption><\/div>\n<\/figure>\n<h3>\u0421\u0442\u0435\u043a<\/h3>\n<p><strong>\u0411\u0435\u0437 \u0441\u044e\u0440\u043f\u0440\u0438\u0437\u043e\u0432:<\/strong><\/p>\n<ul>\n<li>\n<p><strong>Backend<\/strong>: NestJS + TypeORM + PostgreSQL. \u041e\u0434\u0438\u043d \u0441\u0435\u0440\u0432\u0435\u0440, \u043e\u0434\u0438\u043d \u0438\u043d\u0441\u0442\u0430\u043d\u0441, \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u043e\u043c \u043f\u0440\u0438 \u0441\u0442\u0430\u0440\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 <code>DB_MIGRATE=true<\/code>.<\/p>\n<\/li>\n<li>\n<p><strong>Frontend<\/strong>: React + Vite + React Router 7, Tailwind, react-query \u0434\u043b\u044f cache, sonner \u0434\u043b\u044f \u0442\u043e\u0441\u0442\u043e\u0432.<\/p>\n<\/li>\n<li>\n<p><strong>Telegram<\/strong>: <code>node-telegram-bot-api<\/code> \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 long polling (webhook \u043d\u0435 \u043d\u0443\u0436\u0435\u043d \u2014 \u043d\u0430 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e\u043c VPS \u0443 \u043c\u0435\u043d\u044f \u043d\u0435\u0442 \u0441\u0442\u0430\u0442\u0438\u0447\u043d\u043e\u0433\u043e IP).<\/p>\n<\/li>\n<li>\n<p><strong>\u0414\u0435\u043f\u043b\u043e\u0439<\/strong>: GitLab CI \u2192 SSH \u0432 VPS \u2192 <code>docker compose pull &amp;&amp; up -d<\/code>.<\/p>\n<\/li>\n<li>\n<p><strong>\u0421\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0438\u043d\u0444\u0440\u044b<\/strong>: ~350\u20bd\/\u043c\u0435\u0441 VPS + 0\u20bd Telegram + 0\u20bd \u0434\u043e\u043c\u0435\u043d (\u0431\u044b\u043b \u043a\u0443\u043f\u043b\u0435\u043d \u0437\u0430\u0440\u0430\u043d\u0435\u0435).<\/p>\n<\/li>\n<\/ul>\n<p>\u041d\u0438\u043a\u0430\u043a\u043e\u0433\u043e Kubernetes, managed-\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0432, \u043e\u0447\u0435\u0440\u0435\u0434\u0435\u0439-\u0431\u0440\u043e\u043a\u0435\u0440\u043e\u0432. \u041e\u0434\u0438\u043d \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 NestJS, \u043e\u0434\u0438\u043d \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 Postgres, \u043e\u0434\u0438\u043d Nginx. \u0415\u0441\u043b\u0438 \u0447\u0442\u043e-\u0442\u043e \u043b\u043e\u043c\u0430\u0435\u0442\u0441\u044f \u2014 SSH \u0432 VPS \u0438 <code>docker compose logs<\/code>. \u041f\u0440\u0438\u043d\u0446\u0438\u043f \u0442\u0430\u043a\u043e\u0439: \u043f\u043e\u043a\u0430 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043c\u0435\u043d\u044c\u0448\u0435 \u0442\u044b\u0441\u044f\u0447\u0438, \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0441\u043b\u043e\u0438 \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u044b \u2014 \u044d\u0442\u043e \u043e\u0432\u0435\u0440\u0438\u043d\u0436\u0438\u043d\u0438\u0440\u0438\u043d\u0433, \u0437\u0430\u043c\u0435\u0434\u043b\u044f\u044e\u0449\u0438\u0439 \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u0438.<\/p>\n<p>\u0414\u0430\u043b\u044c\u0448\u0435 \u2014 \u043f\u0440\u043e \u0442\u043e, \u0447\u0442\u043e \u0432 \u044d\u0442\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e \u0441 \u0438\u043d\u0436\u0435\u043d\u0435\u0440\u043d\u043e\u0439 \u0442\u043e\u0447\u043a\u0438 \u0437\u0440\u0435\u043d\u0438\u044f.<\/p>\n<h3>Telegram-\u0431\u043e\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439<\/h3>\n<p>\u042d\u0442\u043e \u0441\u0435\u0440\u0434\u0446\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430. \u041f\u0440\u0435\u043f\u043e\u0434 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442 \u0443\u0447\u0435\u043d\u0438\u043a\u0430 \u043a \u0431\u043e\u0442\u0443 \u0447\u0435\u0440\u0435\u0437 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443, \u043f\u043e\u0441\u043b\u0435 \u044d\u0442\u043e\u0433\u043e \u0437\u0430 \u0447\u0430\u0441 (\u0438\u043b\u0438 \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u043b) \u0434\u043e \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442 \u0432 \u0441\u0432\u043e\u0439 Telegram \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0432\u0438\u0434\u0430 \u00ab\u041d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0435: \u0437\u0430\u043d\u044f\u0442\u0438\u0435 \u0441\u0435\u0433\u043e\u0434\u043d\u044f \u0432 18:00 \u2014 19:00, \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0438\u0439\u00bb.<\/p>\n<p>\u0417\u0432\u0443\u0447\u0438\u0442 \u043f\u0440\u043e\u0441\u0442\u043e. \u041d\u0430 \u0434\u0435\u043b\u0435 \u0437\u0434\u0435\u0441\u044c \u0441\u0438\u0434\u0438\u0442 \u0448\u0442\u0443\u043a \u043f\u044f\u0442\u044c \u0440\u0430\u0437\u043d\u044b\u0445 \u043a\u043b\u0430\u0441\u0441\u043e\u0432 \u043f\u0440\u043e\u0431\u043b\u0435\u043c, \u0438 \u044f \u0440\u0430\u0437\u0431\u0435\u0440\u0443 \u043a\u0430\u0436\u0434\u0443\u044e.<\/p>\n<h4>1. Single-use invite-\u0442\u043e\u043a\u0435\u043d\u044b \u0431\u0435\u0437 enumeration<\/h4>\n<figure class=\"bordered \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/2f9\/16d\/e2a\/2f916de2abb533deb25e227982d70ef9.jpg\" alt=\"\u0422\u043e\u0447\u043a\u0430 \u0432\u0445\u043e\u0434\u0430 \u0432 invite-flow: \u043f\u0440\u0435\u043f\u043e\u0434 \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443 \u0443\u0447\u0435\u043d\u0438\u043a\u0430, \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 &quot;\u041f\u0440\u0438\u0433\u043b\u0430\u0441\u0438\u0442\u044c \u0432 Telegram&quot;, \u0431\u044d\u043a \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443 \u2014 \u043e\u043d\u0430 \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442\u0441\u044f \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0438 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0438\u0433\u0434\u0435 \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0432\u0438\u0434\u0435 \u043d\u0435 \u043b\u0435\u0436\u0438\u0442\" title=\"\u0422\u043e\u0447\u043a\u0430 \u0432\u0445\u043e\u0434\u0430 \u0432 invite-flow: \u043f\u0440\u0435\u043f\u043e\u0434 \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443 \u0443\u0447\u0435\u043d\u0438\u043a\u0430, \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 &quot;\u041f\u0440\u0438\u0433\u043b\u0430\u0441\u0438\u0442\u044c \u0432 Telegram&quot;, \u0431\u044d\u043a \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443 \u2014 \u043e\u043d\u0430 \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442\u0441\u044f \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0438 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0438\u0433\u0434\u0435 \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0432\u0438\u0434\u0435 \u043d\u0435 \u043b\u0435\u0436\u0438\u0442\" width=\"518\" height=\"881\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/2f9\/16d\/e2a\/2f916de2abb533deb25e227982d70ef9.jpg 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/2f9\/16d\/e2a\/2f916de2abb533deb25e227982d70ef9.jpg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>\u0422\u043e\u0447\u043a\u0430 \u0432\u0445\u043e\u0434\u0430 \u0432 invite-flow: \u043f\u0440\u0435\u043f\u043e\u0434 \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443 \u0443\u0447\u0435\u043d\u0438\u043a\u0430, \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 &#171;\u041f\u0440\u0438\u0433\u043b\u0430\u0441\u0438\u0442\u044c \u0432 Telegram&#187;, \u0431\u044d\u043a \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443 \u2014 \u043e\u043d\u0430 \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442\u0441\u044f \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0438 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0438\u0433\u0434\u0435 \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0432\u0438\u0434\u0435 \u043d\u0435 \u043b\u0435\u0436\u0438\u0442<\/figcaption><\/div>\n<\/figure>\n<div class=\"floating-image\">\n<p>\u041a\u0430\u0436\u0434\u044b\u0439 \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443:<\/p>\n<\/div>\n<pre><code class=\"typescript\">https:\/\/t.me\/teachtrackbot?start=&lt;token&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>&lt;token&gt;<\/code> \u2014 32 \u0431\u0430\u0439\u0442\u0430 URL-safe base64. \u0412 \u0411\u0414 \u0445\u0440\u0430\u043d\u0438\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e SHA-256 \u043e\u0442 \u043d\u0435\u0433\u043e; plaintext \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f \u0444\u0440\u043e\u043d\u0442\u0443 \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0432 \u043e\u0442\u0432\u0435\u0442 \u043d\u0430 <code>createInvite<\/code> \u0438 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0438\u0433\u0434\u0435 \u043d\u0435 \u043e\u0441\u0442\u0430\u0451\u0442\u0441\u044f (\u0434\u0430\u0436\u0435 \u0432 \u043b\u043e\u0433\u0430\u0445).<\/p>\n<pre><code class=\"typescript\">const rawToken = randomBytes(32).toString('base64url');const tokenHash = createHash('sha256').update(rawToken).digest('hex');const expiresAt = new Date(Date.now() + INVITE_TTL_MS); \/\/ 14 \u0434\u043d\u0435\u0439<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p> \u0417\u0430\u0447\u0435\u043c \u0445\u044d\u0448 \u0432 \u0411\u0414, \u0430 \u043d\u0435 plaintext? \u0415\u0441\u043b\u0438 \u0437\u043b\u043e\u0443\u043c\u044b\u0448\u043b\u0435\u043d\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442 read-\u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0442\u0430\u0431\u043b\u0438\u0446\u0435 (\u0440\u0435\u0437\u0435\u0440\u0432\u043d\u0430\u044f \u043a\u043e\u043f\u0438\u044f, \u0434\u0430\u043c\u043f, SQL-\u0438\u043d\u044a\u0435\u043a\u0446\u0438\u044f \u0432 \u043a\u0430\u043a\u043e\u043c-\u0442\u043e \u0431\u0443\u0434\u0443\u0449\u0435\u043c \u043a\u043e\u0434\u0435) \u2014 \u043e\u043d \u043d\u0435 \u0441\u043c\u043e\u0436\u0435\u0442 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0440\u0430\u0431\u043e\u0447\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443. \u041f\u0440\u0438\u043d\u0446\u0438\u043f \u0442\u043e\u0442 \u0436\u0435, \u0447\u0442\u043e \u0441 \u043f\u0430\u0440\u043e\u043b\u044f\u043c\u0438: \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 <code>hash(input) === stored_hash<\/code>, \u0430 \u043d\u0435 \u0447\u0435\u0440\u0435\u0437 <code>input === stored_plain<\/code>.<\/p>\n<p>\u0414\u0430\u043b\u044c\u0448\u0435 \u2014 partial unique index, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043d\u0435 \u0434\u0430\u0451\u0442 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u043c \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u043c \u0438\u043d\u0432\u0430\u0439\u0442\u0430\u043c \u043d\u0430 \u043e\u0434\u043d\u043e\u0433\u043e \u0443\u0447\u0435\u043d\u0438\u043a\u0430 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e:<\/p>\n<pre><code class=\"sql\">CREATE UNIQUE INDEX student_telegram_invite_activeON student_telegram_invite (student_id)WHERE consumed_at IS NULL AND revoked_at IS NULL;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u042d\u0442\u043e \u0434\u0430\u0451\u0442 \u0441\u0438\u043b\u044c\u043d\u0443\u044e \u0438\u043d\u0432\u0430\u0440\u0438\u0430\u043d\u0442 \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u0430 \u043d\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f. \u0415\u0441\u043b\u0438 \u043f\u0440\u0435\u043f\u043e\u0434 \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 \u00ab\u0432\u044b\u0434\u0430\u0442\u044c \u043d\u043e\u0432\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443\u00bb, \u043a\u043e\u0433\u0434\u0430 \u0441\u0442\u0430\u0440\u0430\u044f \u0435\u0449\u0451 \u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u2014 \u043c\u044b \u0441\u043d\u0430\u0447\u0430\u043b\u0430 revoke, \u043f\u043e\u0442\u043e\u043c insert, \u0432 \u043e\u0434\u043d\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438:<\/p>\n<pre><code class=\"typescript\">await this.em.transaction(async (trx) =&gt; {const inviteRepo = trx.getRepository(StudentTelegramInviteEntity);\/\/ Revoke any currently-active invite for this student so the\/\/ partial unique index doesn't reject the insert.await inviteRepo    .createQueryBuilder()    .update()    .set({ revokedAt: () =&gt; 'now()' })    .where('student_id = :studentId', { studentId })    .andWhere('consumed_at IS NULL')    .andWhere('revoked_at IS NULL')    .execute();await inviteRepo.save(  new StudentTelegramInviteEntity({        studentId,            teacherId,            tokenHash,            expiresAt,    }),  );});<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u0421\u0430\u043c\u043e\u0435 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e\u0435 \u2014 \u043d\u0430 \u0441\u0442\u043e\u0440\u043e\u043d\u0435 <code>consume()<\/code>. \u041a\u043e\u0433\u0434\u0430 \u0443\u0447\u0435\u043d\u0438\u043a \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 \u0432 Telegram \u043a\u043d\u043e\u043f\u043a\u0443 Start, \u0431\u043e\u0442 \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442 \u044d\u0442\u043e\u0442 \u043c\u0435\u0442\u043e\u0434. \u0422\u0443\u0442 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u0430 \u0433\u043e\u043d\u043a\u0430: \u0434\u0432\u043e\u0435 \u0440\u043e\u0434\u0438\u0442\u0435\u043b\u0435\u0439 \u0441 \u043e\u0434\u043d\u043e\u0439 \u0441\u0441\u044b\u043b\u043a\u0438 \u043a\u043b\u0438\u043a\u0430\u044e\u0442 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e. \u0411\u0435\u0437 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u0430 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u044e\u0442 <code>consumed_at IS NULL<\/code>, \u043e\u0431\u0430 \u0441\u043e\u0437\u0434\u0430\u0434\u0443\u0442 binding, \u043e\u0431\u0430 \u0443\u0432\u0438\u0434\u044f\u0442 \u0443\u0441\u043f\u0435\u0445 \u2014 \u043d\u043e binding-row \u0431\u0443\u0434\u0435\u0442 \u0434\u0432\u0430, \u0441 \u0440\u0430\u0437\u043d\u044b\u043c\u0438 <code>chat_id<\/code>.<\/p>\n<p>\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u2014 <code>pessimistic_write<\/code> (<code>SELECT ... FOR UPDATE<\/code>):<\/p>\n<pre><code class=\"typescript\">public async consume(  rawToken: string,    chatId: string,    from: { username?: string; firstName?: string; lastName?: string },): Promise&lt;ConsumeResult | null&gt; {  const tokenHash = createHash('sha256').update(rawToken).digest('hex');  return this.em.transaction(async (trx) =&gt; {    const inviteRepo = trx.getRepository(StudentTelegramInviteEntity);    \/\/ Pessimistic lock so concurrent \/start attempts don't double-        \/\/ consume the same invite. \"FOR UPDATE\" blocks the second txn        \/\/ until the first commits; second then sees consumed_at and bails.        const invite = await inviteRepo      .createQueryBuilder('i')      .where('i.token_hash = :tokenHash', { tokenHash })      .setLock('pessimistic_write')      .getOne();        if (!invite) return null;        if (invite.consumedAt) return null;        if (invite.revokedAt) return null;        if (invite.expiresAt.getTime() &lt; Date.now()) return null;        \/\/ ... \u0441\u043e\u0437\u0434\u0430\u0451\u043c binding, \u043f\u043e\u043c\u0435\u0447\u0430\u0435\u043c consumed_at = now() ...  });}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u041f\u0435\u0440\u0432\u0430\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442 \u0441\u0442\u0440\u043e\u043a\u0443. \u0412\u0442\u043e\u0440\u0430\u044f \u0436\u0434\u0451\u0442 \u043a\u043e\u043c\u043c\u0438\u0442\u0430 \u043f\u0435\u0440\u0432\u043e\u0439, \u043f\u043e\u0441\u043b\u0435 \u0447\u0435\u0433\u043e \u0447\u0438\u0442\u0430\u0435\u0442 <code>consumed_at != null<\/code> \u0438 \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u0442 <code>null<\/code>. \u0413\u043e\u043d\u043a\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u0430 \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u0431\u0435\u0437 in-memory mutex&#8217;\u043e\u0432.<\/p>\n<p>\u0412\u043e\u0437\u0432\u0440\u0430\u0442 <code>null<\/code> \u0434\u043b\u044f \u0432\u0441\u0435\u0445 \u0441\u043b\u0443\u0447\u0430\u0435\u0432 (expired, revoked, unknown, consumed) \u2014 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u044b\u0439 \u0432\u044b\u0431\u043e\u0440. \u0411\u043e\u0442 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u044b\u043c \u00ab\u0421\u0441\u044b\u043b\u043a\u0430 \u0443\u0441\u0442\u0430\u0440\u0435\u043b\u0430 \u0438\u043b\u0438 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0430\u00bb. \u042d\u0442\u043e \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043e\u0440\u0430\u043a\u0443\u043b: \u0430\u0442\u0430\u043a\u0443\u044e\u0449\u0438\u0439, \u043f\u0435\u0440\u0435\u0431\u0438\u0440\u0430\u044e\u0449\u0438\u0439 \u0442\u043e\u043a\u0435\u043d\u044b, \u043d\u0435 \u0434\u043e\u043b\u0436\u0435\u043d \u043f\u043e \u0440\u0430\u0437\u043d\u043e\u043c\u0443 \u043e\u0442\u0432\u0435\u0442\u0443 \u043f\u043e\u043d\u044f\u0442\u044c, \u043a\u0430\u043a\u0430\u044f \u0441\u0441\u044b\u043b\u043a\u0430 \u0431\u044b\u043b\u0430 \u0432\u0430\u043b\u0438\u0434\u043d\u0430 \u043a\u043e\u0433\u0434\u0430-\u0442\u043e, \u0430 \u043a\u0430\u043a\u0430\u044f \u043d\u0438\u043a\u043e\u0433\u0434\u0430 \u043d\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u043e\u0432\u0430\u043b\u0430.<\/p>\n<h4>2. Anti-enumeration \u043d\u0430 bare \/start<\/h4>\n<p>\u0421\u0432\u044f\u0437\u0430\u043d\u043d\u0430\u044f \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430: Telegram \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043a\u043e\u043c\u0443 \u0443\u0433\u043e\u0434\u043d\u043e \u043d\u0430\u0447\u0430\u0442\u044c \u0447\u0430\u0442 \u0441 \u0431\u043e\u0442\u043e\u043c \u0431\u0435\u0437 \u0442\u043e\u043a\u0435\u043d\u0430 \u2014 \u043f\u0440\u043e\u0441\u0442\u043e <code>\/start<\/code>. \u041f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u044d\u0442\u043e\u0433\u043e \u0441\u043b\u0443\u0447\u0430\u044f \u043c\u043e\u0436\u043d\u043e \u0443\u0433\u0430\u0434\u044b\u0432\u0430\u0442\u044c \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0435 <code>chat_id<\/code>&#8216;\u044b (\u0435\u0441\u043b\u0438 \u0431\u043e\u0442 \u043f\u043e-\u0440\u0430\u0437\u043d\u043e\u043c\u0443 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u00ab\u043d\u0435\u0437\u043d\u0430\u043a\u043e\u043c\u0446\u0443\u00bb \u0438 \u00ab\u0443\u0436\u0435 \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e\u00bb \u2014 \u044d\u0442\u043e side-channel).<\/p>\n<p>\u0412 TeachTrack \u0431\u043e\u0442 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u044b\u043c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043c \u0438 \u0432 \u0442\u043e\u043c, \u0438 \u0432 \u0434\u0440\u0443\u0433\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435, \u043f\u043b\u044e\u0441 \u0435\u0441\u0442\u044c rate-limit:<\/p>\n<pre><code class=\"typescript\">if (!rawToken) {  \/\/ Bare \/start \u2014 first-time visitor \/ returning user \/ attacker probing    \/\/ chatIds. Reply with one generic message either way: distinguishing    \/\/ \"already bound\" from \"new\" was an enumeration oracle. Non-bound chats    \/\/ bump the fail counter so bare \/start can't be used to keep a    \/\/ cool-down alive while guessing with another path.  const existing = await this.em        .getRepository(StudentTelegramBindingEntity)        .findOne({ where: { chatId }, select: ['id'] });    if (!existing) {      if (!this.noteStartFailure(msg.chat.id)) {          await this.bot.sendMessage(msg.chat.id, '\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 \u0447\u0430\u0441.');            return;        }    }    await this.bot.sendMessage(      msg.chat.id,        '\u0427\u0442\u043e\u0431\u044b \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0443 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443 \u0438 \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u043d\u0435\u0439.\\n\/help \u2014 \u0441\u043f\u0438\u0441\u043e\u043a \u043a\u043e\u043c\u0430\u043d\u0434.',        );    return;}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>noteStartFailure<\/code> \u2014 \u043f\u0440\u043e\u0441\u0442\u043e\u0439 in-memory bucket \u0441 TTL: \u0447\u0430\u0442, \u043d\u0435 \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0439 \u043d\u0438 \u043a \u043a\u043e\u043c\u0443, \u043c\u043e\u0436\u0435\u0442 \u0441\u0434\u0435\u043b\u0430\u0442\u044c N \u0431\u0435\u0441\u043f\u043e\u043b\u0435\u0437\u043d\u044b\u0445 <code>\/start<\/code> \u0432 \u0447\u0430\u0441, \u0434\u0430\u043b\u044c\u0448\u0435 \u043e\u0442\u0432\u0435\u0442 \u0437\u0430\u043c\u0435\u043d\u044f\u0435\u0442\u0441\u044f \u043d\u0430 \u00ab\u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a\u00bb. \u041f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0435 \u0447\u0430\u0442\u044b \u043d\u0435 \u0441\u0447\u0438\u0442\u0430\u044e\u0442\u0441\u044f \u2014 \u0438\u043d\u0430\u0447\u0435 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0438\u0439 \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u0441\u043b\u0435 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438 \u0431\u043e\u0442\u0430 \u0438 \u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0431\u0430\u043d.<\/p>\n<h4>3. Atomic claim \u0432 scheduler<\/h4>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/912\/3a7\/96e\/9123a796e92bf95235f00afffc6ba448.jpg\" alt=\"\u0422\u043e\u0442 \u0436\u0435 outbox-\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c, \u0447\u0442\u043e \u043e\u0431\u0441\u043b\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f, \u0434\u0435\u043b\u0430\u0435\u0442 \u0438 \u0440\u0443\u0447\u043d\u044b\u0435 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u043f\u0440\u0430\u0432\u0430 preview \u0438\u043c\u0435\u043d\u043d\u043e \u0442\u043e\u0433\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0443\u0439\u0434\u0451\u0442 \u0432 Telegram\" title=\"\u0422\u043e\u0442 \u0436\u0435 outbox-\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c, \u0447\u0442\u043e \u043e\u0431\u0441\u043b\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f, \u0434\u0435\u043b\u0430\u0435\u0442 \u0438 \u0440\u0443\u0447\u043d\u044b\u0435 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u043f\u0440\u0430\u0432\u0430 preview \u0438\u043c\u0435\u043d\u043d\u043e \u0442\u043e\u0433\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0443\u0439\u0434\u0451\u0442 \u0432 Telegram\" width=\"2025\" height=\"1218\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/912\/3a7\/96e\/9123a796e92bf95235f00afffc6ba448.jpg 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/912\/3a7\/96e\/9123a796e92bf95235f00afffc6ba448.jpg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>\u0422\u043e\u0442 \u0436\u0435 outbox-\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c, \u0447\u0442\u043e \u043e\u0431\u0441\u043b\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f, \u0434\u0435\u043b\u0430\u0435\u0442 \u0438 \u0440\u0443\u0447\u043d\u044b\u0435 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u043f\u0440\u0430\u0432\u0430 preview \u0438\u043c\u0435\u043d\u043d\u043e \u0442\u043e\u0433\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0443\u0439\u0434\u0451\u0442 \u0432 Telegram<\/figcaption><\/div>\n<\/figure>\n<p>\u0421\u0430\u043c\u0430\u044f \u043d\u0435\u0442\u0440\u0438\u0432\u0438\u0430\u043b\u044c\u043d\u0430\u044f \u0447\u0430\u0441\u0442\u044c \u2014 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439. Cron-\u0442\u0438\u043a \u043a\u0430\u0436\u0434\u0443\u044e \u043c\u0438\u043d\u0443\u0442\u0443:<\/p>\n<pre><code class=\"typescript\">@Cron(CronExpression.EVERY_MINUTE)public async sendUpcomingReminders(): Promise&lt;void&gt; {  if (!this.telegram.isEnabled()) return;    if (this.isRunning) {      this.logger.warn({ event: 'reminder.tick_skipped', reason: 'previous tick still running' });      return;    }    this.isRunning = true;  try {    await this.runTick();  } finally {    this.isRunning = false;    }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>isRunning<\/code> \u2014 in-memory \u043c\u044c\u044e\u0442\u0435\u043a\u0441. NestJS Schedule \u043d\u0435 \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442 cron, \u0438 \u0435\u0441\u043b\u0438 \u0442\u0438\u043a \u0434\u043b\u0438\u043d\u043d\u0435\u0435 60 \u0441\u0435\u043a\u0443\u043d\u0434 (\u0447\u0442\u043e \u0431\u044b\u0432\u0430\u0435\u0442: 200 \u0443\u0440\u043e\u043a\u043e\u0432 \u0441 relations \u0447\u0435\u0440\u0435\u0437 TypeORM = \u0434\u0435\u0441\u044f\u0442\u043a\u0438 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432), \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 \u0442\u0438\u043a \u0441\u0442\u0430\u0440\u0442\u0443\u0435\u0442 \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e. \u0411\u0435\u0437 \u0444\u043b\u0430\u0433\u0430 \u0434\u0432\u0435 \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u0438 \u0447\u0438\u0442\u0430\u044e\u0442 <code>hasNotificationSent=false<\/code> \u0438 \u0441\u0442\u0430\u0432\u044f\u0442 \u0432 \u043e\u0447\u0435\u0440\u0435\u0434\u044c \u043e\u0434\u043d\u0443 \u0438 \u0442\u0443 \u0436\u0435 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043b\u043a\u0443 \u0434\u0432\u0430\u0436\u0434\u044b. \u0414\u043b\u044f multi-instance \u0434\u0435\u043f\u043b\u043e\u044f \u043d\u0443\u0436\u0435\u043d \u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b\u0451\u043d\u043d\u044b\u0439 lock (Redis), \u043d\u043e \u043f\u043e\u043a\u0430 \u0434\u0435\u043f\u043b\u043e\u0439 single-instance \u2014 in-memory \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e.<\/p>\n<p>\u0412\u043d\u0443\u0442\u0440\u0438 \u0442\u0438\u043a\u0430 \u2014 \u043e\u0441\u043d\u043e\u0432\u043d\u0430\u044f \u043b\u043e\u0433\u0438\u043a\u0430:<\/p>\n<pre><code class=\"typescript\">const lessons = await this.em.getRepository(LessonEntity).find({  where: {      status: LessonStatus.Scheduled,        hasNotificationSent: false,        startTime: Between(now, cutoff), \/\/ 24-\u0447\u0430\u0441\u043e\u0432\u043e\u0435 \u043e\u043a\u043d\u043e    },    relations: {      teacher: true,        participants: { student: { telegramBindings: true } },    },    order: { startTime: 'ASC' },    take: 200,});<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u041a\u0430\u0436\u0434\u044b\u0439 \u043f\u0440\u0435\u043f\u043e\u0434 \u0437\u0430\u0434\u0430\u0451\u0442 \u0441\u0432\u043e\u0439 <code>reminderLeadMinutes<\/code> (\u043e\u0442 15 \u043c\u0438\u043d\u0443\u0442 \u0434\u043e \u0441\u0443\u0442\u043e\u043a \u0434\u043e \u043d\u0430\u0447\u0430\u043b\u0430). \u0414\u0435\u043b\u0430\u0442\u044c N \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043a \u0411\u0414 \u0441 \u0440\u0430\u0437\u043d\u044b\u043c\u0438 \u043e\u043a\u043d\u0430\u043c\u0438 \u2014 \u043f\u0443\u0441\u0442\u0430\u044f \u0442\u0440\u0430\u0442\u0430 \u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432. \u041f\u043e\u044d\u0442\u043e\u043c\u0443 \u0441\u0440\u0430\u0437\u0443 \u0442\u044f\u043d\u0435\u043c \u0432\u0441\u0435 Scheduled-\u0443\u0440\u043e\u043a\u0438 \u0432 \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u043c \u043e\u043a\u043d\u0435 (24 \u0447\u0430\u0441\u0430), \u0430 per-lesson \u043e\u0442\u0441\u0435\u043a\u0430\u0435\u043c \u0442\u0435, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0435\u0449\u0451 \u00ab\u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0440\u0430\u043d\u043e\u00bb \u0434\u043b\u044f \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u043e\u0433\u043e \u043f\u0440\u0435\u043f\u043e\u0434\u0430.<\/p>\n<p>\u0414\u0430\u043b\u044c\u0448\u0435 \u2014 \u0441\u0430\u043c\u043e\u0435 \u0432\u0430\u0436\u043d\u043e\u0435. Atomic claim \u0447\u0435\u0440\u0435\u0437 <code>UPDATE WHERE<\/code>:<\/p>\n<pre><code class=\"typescript\">const claim = await this.em.getRepository(LessonEntity).update(  {    id: lesson.id,        status: LessonStatus.Scheduled,        hasNotificationSent: false,  },  { hasNotificationSent: true },);if ((claim.affected ?? 0) === 0) {  \/\/ \u041f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044c \u043e\u0442\u043c\u0435\u043d\u0438\u043b \u0443\u0440\u043e\u043a \u043c\u0435\u0436\u0434\u0443 \u0442\u0438\u043a\u043e\u043c SELECT \u0438 \u043d\u0430\u0448\u0438\u043c UPDATE,    \/\/ \u0438\u043b\u0438 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0439 \u0442\u0438\u043a \u0443\u0436\u0435 \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u043b \u2014 UPDATE \u0437\u0430\u0442\u0440\u043e\u043d\u0443\u043b 0 \u0441\u0442\u0440\u043e\u043a,    \/\/ \u043c\u044b \u043f\u0440\u043e\u0441\u0442\u043e \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u043c enqueue.    this.logger.info({ event: 'reminder.skip_lost_race', lesson_id: lesson.id });    continue;}const text = this.formatReminder(lesson);for (const b of bindings) {  await this.outbox.enqueue({ ... });}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u042d\u0442\u043e compare-and-set \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 SQL: \u00ab\u043f\u043e\u0441\u0442\u0430\u0432\u044c <code>hasNotificationSent=true<\/code>, \u043d\u043e <strong>\u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438<\/strong> \u0443\u0440\u043e\u043a \u0432\u0441\u0451 \u0435\u0449\u0451 <code>Scheduled<\/code> \u0438 \u0444\u043b\u0430\u0433 \u0435\u0449\u0451 \u043d\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u00bb. \u0415\u0441\u043b\u0438 \u043f\u0440\u0435\u043f\u043e\u0434 \u0443\u0441\u043f\u0435\u043b \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0437\u0430\u043d\u044f\u0442\u0438\u0435 \u0438\u043b\u0438 \u0434\u0440\u0443\u0433\u043e\u0439 \u0442\u0438\u043a \u0443\u0436\u0435 \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u043b \u2014 <code>UPDATE<\/code> \u0437\u0430\u0442\u0440\u0430\u0433\u0438\u0432\u0430\u0435\u0442 0 \u0441\u0442\u0440\u043e\u043a, \u0438 \u043c\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u043c enqueue. \u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u0433\u043e\u043d\u043a\u0443 \u0441 <code>cancelLesson<\/code> \u0438 double-send \u043f\u0440\u0438 overlapping ticks.<\/p>\n<p>\u041f\u043e\u0441\u043b\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e\u0433\u043e claim&#8217;\u0430 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 enqueue&#8217;\u0438\u0442\u0441\u044f \u0432 outbox, \u043d\u043e \u043e\u0431 \u044d\u0442\u043e\u043c \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e.<\/p>\n<h4>4. Idempotent outbox \u0434\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043e\u043a<\/h4>\n<p>\u0412\u043e\u0442 \u0442\u0443\u0442 \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442\u0441\u044f \u0433\u043b\u0430\u0432\u043d\u043e\u0435.<\/p>\n<p>Telegram API \u043d\u0435 \u0430\u0442\u043e\u043c\u0430\u0440\u0435\u043d \u0441 \u0442\u0432\u043e\u0435\u0439 \u0411\u0414. \u0415\u0441\u043b\u0438 \u0442\u044b \u043f\u0438\u0448\u0435\u0448\u044c:<\/p>\n<pre><code class=\"typescript\">\/\/ \u0411\u0410\u0413: \u0434\u0432\u0430 \u043d\u0435\u0437\u0430\u0432\u0438\u0441\u0438\u043c\u044b\u0445 I\/O \u0431\u0435\u0437 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u043e\u043d\u043d\u044b\u0445 \u0433\u0430\u0440\u0430\u043d\u0442\u0438\u0439await db.markReminderSent(lessonId);await tg.sendMessage(chatId, text);<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u0418 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u043f\u0430\u0434\u0430\u0435\u0442 \u043c\u0435\u0436\u0434\u0443 \u0441\u0442\u0440\u043e\u043a\u0430\u043c\u0438: \u0443\u0447\u0435\u043d\u0438\u043a \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435, \u043d\u043e \u0432 \u0411\u0414 \u2014 \u00ab\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043e\u00bb. \u0415\u0441\u043b\u0438 \u043f\u043e\u043c\u0435\u043d\u044f\u0442\u044c \u043f\u043e\u0440\u044f\u0434\u043e\u043a \u2014 \u0443\u043f\u0430\u0434\u0451\u0442 \u043f\u043e\u0441\u043b\u0435 <code>sendMessage<\/code> \u0434\u043e <code>markSent<\/code> \u2014 \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435, \u043f\u0440\u0438 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c \u0437\u0430\u043f\u0443\u0441\u043a\u0435 cron \u043f\u043e\u0448\u043b\u0451\u0442 \u0432\u0442\u043e\u0440\u043e\u0435.<\/p>\n<p>\u042d\u0442\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430 \u043a\u043b\u0430\u0441\u0441\u0438\u0447\u0435\u0441\u043a\u0430\u044f, \u0438 \u0440\u0435\u0448\u0430\u0435\u0442\u0441\u044f \u043e\u043d\u0430 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u043e\u043d\u043d\u044b\u043c outbox-\u043f\u0430\u0442\u0442\u0435\u0440\u043d\u043e\u043c: \u0437\u0430\u043f\u0438\u0441\u044c \u00ab\u043d\u0430\u0434\u043e \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u0442\u0430\u043a\u043e\u0435-\u0442\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u00bb \u0441\u043e\u0437\u0434\u0430\u0451\u0442\u0441\u044f \u0432 \u043e\u0434\u043d\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438 \u0441 \u0431\u0438\u0437\u043d\u0435\u0441-\u0441\u043e\u0431\u044b\u0442\u0438\u0435\u043c, \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439 \u0432\u043e\u0440\u043a\u0435\u0440 \u0447\u0438\u0442\u0430\u0435\u0442 pending \u0438 \u0448\u043b\u0451\u0442. \u0415\u0441\u043b\u0438 \u0432\u043e\u0440\u043a\u0435\u0440 \u043f\u0430\u0434\u0430\u0435\u0442 \u043c\u0435\u0436\u0434\u0443 \u00ab\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u00bb \u0438 \u00ab\u043f\u043e\u043c\u0435\u0442\u0438\u043b sent\u00bb \u2014 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0430\u044f \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u044f \u0443\u0432\u0438\u0434\u0438\u0442 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0432\u0441\u0451 \u0435\u0449\u0451 pending \u0438\u2026 \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442 \u0432\u0442\u043e\u0440\u043e\u0435. \u041f\u043e\u044d\u0442\u043e\u043c\u0443 \u043d\u0443\u0436\u043d\u0430 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u044c \u043d\u0430 \u0441\u0442\u043e\u0440\u043e\u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u044f \u0438\u043b\u0438 \u0433\u0430\u0440\u0430\u043d\u0442\u0438\u0438 \u00abat least once\u00bb.<\/p>\n<p>Telegram API \u043d\u0435 \u0438\u043c\u0435\u0435\u0442 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u044b\u0445 \u043a\u043b\u044e\u0447\u0435\u0439 (\u043d\u0435\u043b\u044c\u0437\u044f \u0441\u043a\u0430\u0437\u0430\u0442\u044c \u00ab\u043e\u0442\u043f\u0440\u0430\u0432\u044c \u044d\u0442\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435, \u043d\u043e \u0435\u0441\u043b\u0438 \u0443\u0436\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043a\u043b\u044e\u0447\u043e\u043c \u2014 \u043d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u0434\u0435\u043b\u0430\u0439\u00bb). \u0422\u0430\u043a \u0447\u0442\u043e \u043f\u0440\u0430\u0433\u043c\u0430\u0442\u0438\u0447\u043d\u043e\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u2014 at-least-once \u0441 \u0447\u0435\u0441\u0442\u043d\u044b\u043c \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u043e\u043c: \u043c\u0435\u0442\u0438\u043c <code>sent_at<\/code> \u0441\u0440\u0430\u0437\u0443 \u043f\u043e\u0441\u043b\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e\u0433\u043e <code>sendMessage<\/code>, \u0438 \u0432 \u0440\u0435\u0434\u043a\u0438\u0445 \u0441\u043b\u0443\u0447\u0430\u044f\u0445 \u043f\u0430\u0434\u0435\u043d\u0438\u044f \u043c\u0435\u0436\u0434\u0443 \u043d\u0438\u043c\u0438 \u2014 \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043c \u0434\u0443\u0431\u043b\u044c. \u041e\u0434\u0438\u043d \u0434\u0443\u0431\u043b\u044c \u0440\u0430\u0437 \u0432 N \u0442\u044b\u0441\u044f\u0447 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u2014 \u043f\u0440\u0438\u0435\u043c\u043b\u0435\u043c\u043e.<\/p>\n<p>\u0421\u0445\u0435\u043c\u0430 outbox-\u0442\u0430\u0431\u043b\u0438\u0446\u044b:<\/p>\n<pre><code class=\"typescript\">@Entity('telegram_outbox')@Index('idx_telegram_outbox_pending', ['sendAfter'], {  where: '\"sent_at\" IS NULL AND \"dead_at\" IS NULL',})@Index('idx_telegram_outbox_context', ['contextType', 'contextId'])export class TelegramOutboxEntity {  @PrimaryGeneratedColumn('uuid')    public readonly id: string;    @Column({ name: 'chat_id', type: 'bigint' })    public chatId: string;    @Column({ name: 'binding_id', type: 'uuid', nullable: true })    public bindingId: string | null;    @Column({ type: 'text' })    public text: string;    @Column({ name: 'image_url', type: 'text', nullable: true })    public imageUrl: string | null;    @Column({ type: 'varchar', length: 16 })    public kind: OutboxKind; \/\/ 'reminder' | 'broadcast' | 'manual'    @Column({ name: 'context_type', type: 'varchar', length: 16, nullable: true })    public contextType: string | null;    @Column({ name: 'context_id', type: 'uuid', nullable: true })    public contextId: string | null;    @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })    public readonly createdAt: Date;    @Column({ name: 'send_after', type: 'timestamptz', default: () =&gt; 'now()' })    public sendAfter: Date;    @Column({ name: 'sent_at', type: 'timestamptz', nullable: true })    public sentAt: Date | null;    @Column({ type: 'int', default: 0 })    public attempts: number;    @Column({ name: 'last_error', type: 'text', nullable: true })    public lastError: string | null;    @Column({ name: 'dead_at', type: 'timestamptz', nullable: true })    public deadAt: Date | null;    @Column({ name: 'dead_reason', type: 'varchar', length: 32, nullable: true })    public deadReason: OutboxDeadReason | null;}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p> \u0412\u043e\u0440\u043a\u0435\u0440 \u0434\u0451\u0440\u0433\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u0436\u0434\u044b\u0435 30 \u0441\u0435\u043a\u0443\u043d\u0434 (<code>@Cron(CronExpression.EVERY_30_SECONDS)<\/code>) \u0438 \u0437\u0430\u0431\u0438\u0440\u0430\u0435\u0442 batch&#8217;\u043e\u043c 100 \u0441\u0442\u0440\u043e\u043a \u0447\u0435\u0440\u0435\u0437 <code>FOR UPDATE SKIP LOCKED<\/code>:<\/p>\n<pre><code class=\"typescript\">private async lockBatch(): Promise&lt;TelegramOutboxEntity[]&gt; {  return this.em.transaction(async (tx) =&gt; {    return tx      .getRepository(TelegramOutboxEntity)      .createQueryBuilder('o')      .setLock('pessimistic_write')      .setOnLocked('skip_locked')      .where('o.sent_at IS NULL')      .andWhere('o.dead_at IS NULL')      .andWhere('o.send_after &lt;= now()')      .orderBy('o.send_after', 'ASC')      .limit(DRAIN_BATCH)      .getMany();  });}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>SKIP LOCKED<\/code> \u2014 \u0431\u0443\u0434\u0443\u0449\u0438\u0439-proof \u0434\u043b\u044f multi-instance \u0434\u0435\u043f\u043b\u043e\u044f. \u0421\u0435\u0439\u0447\u0430\u0441 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043e\u0434\u0438\u043d, \u043d\u043e \u043a\u043e\u0433\u0434\u0430 \u043f\u043e\u0440\u0430 \u0431\u0443\u0434\u0435\u0442 \u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u0434\u0432\u0430 \u0432\u043e\u0440\u043a\u0435\u0440\u0430 \u0441\u043f\u043e\u043a\u043e\u0439\u043d\u043e \u0440\u0430\u0437\u0431\u0435\u0440\u0443\u0442 \u0440\u0430\u0437\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0431\u0435\u0437 \u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b\u0451\u043d\u043d\u044b\u0445 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043e\u043a.<\/p>\n<p>\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0430 \u043d\u0430 4 outcome&#8217;\u0430:<\/p>\n<pre><code class=\"typescript\">if (result.outcome === 'sent') {  row.sentAt = new Date();  \/\/ ...} else if (result.outcome === 'blocked') {  \/\/ Telegram \u0432\u0435\u0440\u043d\u0443\u043b 403 Forbidden \u2014 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043b \u0431\u043e\u0442\u0430.    \/\/ \u041f\u043e\u043c\u0435\u0447\u0430\u0435\u043c dead \u0441 reason='blocked' + \u0441\u0442\u0430\u0432\u0438\u043c binding.blockedAt,    \/\/ \u0447\u0442\u043e\u0431\u044b \u043d\u043e\u0432\u044b\u0435 reminder'\u044b \u0432 \u043d\u0435\u0433\u043e \u0434\u0430\u0436\u0435 \u043d\u0435 enqueue'\u0438\u043b\u0438\u0441\u044c.    row.deadAt = new Date();    row.deadReason = 'blocked';    if (row.bindingId) {      await this.telegram.markBindingBlocked(row.bindingId);  }} else if (result.outcome === 'permanent') {  \/\/ 400 \u043e\u0442 Telegram: \u0434\u043b\u0438\u043d\u043d\u0430\u044f caption, \u043c\u0443\u0441\u043e\u0440\u043d\u044b\u0439 image URL, parse_mode error.    \/\/ Retry \u0431\u0435\u0441\u0441\u043c\u044b\u0441\u043b\u0435\u043d \u2014 \u0444\u0438\u043a\u0441\u0438\u0440\u0443\u0435\u043c \u043a\u0430\u043a dead \u043d\u0435\u043c\u0435\u0434\u043b\u0435\u043d\u043d\u043e.    row.deadAt = new Date();    row.deadReason = 'permanent_error';} else {  \/\/ 'failed' \u2014 \u0442\u0440\u0430\u043d\u0437\u0438\u0435\u043d\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 (5xx, 429, network timeout).    \/\/ \u0421\u0447\u0438\u0442\u0430\u0435\u043c attempt \u0438 \u0441\u043c\u0435\u0449\u0430\u0435\u043c send_after \u043f\u043e backoff'\u0443.    row.attempts += 1;    if (row.attempts &gt;= MAX_ATTEMPTS) {      row.deadAt = new Date();        row.deadReason = 'max_retries';    } else {      row.sendAfter = new Date(Date.now() + computeBackoff(row.attempts));    }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Backoff \u044d\u043a\u0441\u043f\u043e\u043d\u0435\u043d\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0439, \u0441 \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u043e \u043f\u043e\u0434\u043e\u0431\u0440\u0430\u043d\u043d\u044b\u043c\u0438 \u0448\u0430\u0433\u0430\u043c\u0438:<\/p>\n<pre><code class=\"typescript\">function computeBackoff(attempt: number): number {  const steps = [      60_000,     \/\/  1 min        120_000,    \/\/  2 min        300_000,    \/\/  5 min        900_000,    \/\/ 15 min        1_800_000,  \/\/ 30 min        3_600_000,  \/\/  1 h        10_800_000, \/\/  3 h        21_600_000, \/\/  6 h        43_200_000, \/\/ 12 h        86_400_000, \/\/ 24 h    ];    return steps[Math.min(attempt - 1, steps.length - 1)];}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>10 \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u00d7 \u0448\u0430\u0433\u0438 \u0434\u043e 24 \u0447\u0430\u0441\u043e\u0432 = \u043e\u043a\u043d\u043e ~48 \u0447\u0430\u0441\u043e\u0432 \u0434\u043e dead-letter. \u0414\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e, \u0447\u0442\u043e\u0431\u044b \u0441\u0435\u0442\u044c \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u043b\u0430\u0441\u044c \u043f\u043e\u0441\u043b\u0435 \u0441\u0435\u0440\u044c\u0451\u0437\u043d\u043e\u0433\u043e \u0438\u043d\u0446\u0438\u0434\u0435\u043d\u0442\u0430, \u043d\u043e \u043d\u0435 \u043d\u0430\u0441\u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043e\u043b\u0433\u043e, \u0447\u0442\u043e\u0431\u044b \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u00ab\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u043e\u0441\u044c\u00bb \u0447\u0435\u0440\u0435\u0437 \u043d\u0435\u0434\u0435\u043b\u044e \u0432 \u043c\u043e\u043c\u0435\u043d\u0442, \u043a\u043e\u0433\u0434\u0430 \u043e\u043d\u043e \u0443\u0436\u0435 \u043d\u0435 \u0430\u043a\u0442\u0443\u0430\u043b\u044c\u043d\u043e.<\/p>\n<p>\u0420\u0430\u0437\u043b\u0438\u0447\u0435\u043d\u0438\u0435 transient (failed) vs permanent vs blocked \u2014 \u0432\u0430\u0436\u043d\u0430\u044f \u0432\u0435\u0449\u044c. \u0415\u0441\u043b\u0438 \u0431\u044b \u044f \u0440\u0435\u0442\u0440\u0430\u0438\u043b \u0432\u0441\u0451 \u043f\u043e\u0434\u0440\u044f\u0434 10 \u0440\u0430\u0437 \u2014 \u043a\u0430\u0436\u0434\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043a \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0432\u0448\u0435\u043c\u0443 \u0431\u043e\u0442\u0430 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u0437\u0430\u043d\u0438\u043c\u0430\u043b\u043e \u0431\u044b 10 \u0441\u043b\u043e\u0442\u043e\u0432 \u0432 \u043e\u0447\u0435\u0440\u0435\u0434\u0438 \u0438 10 \u0441\u0442\u0440\u043e\u043a \u0432 \u043b\u043e\u0433\u0430\u0445, \u0438 spec&#8217;\u044b \u043c\u0435\u0442\u0440\u0438\u043a \u0431\u044b\u043b\u0438 \u0431\u044b \u0441\u043e\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e \u0431\u0435\u0441\u043f\u043e\u043b\u0435\u0437\u043d\u044b.<\/p>\n<p>\u041c\u0435\u0442\u0440\u0438\u043a\u0438 \u0432\u044b\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u044b \u0447\u0435\u0440\u0435\u0437 <code>prom-client<\/code> (Prometheus \u0444\u043e\u0440\u043c\u0430\u0442): <code>tgOutboxPending<\/code>, <code>tgOutboxSentTotal<\/code>, <code>tgOutboxRetryTotal<\/code>, <code>tgOutboxDeadTotal{reason}<\/code>. \u041d\u0430 \u0434\u0430\u0448\u0431\u043e\u0440\u0434\u0435 Grafana \u2014 pending \u0432 \u0440\u0435\u0430\u043b\u044c\u043d\u043e\u043c \u0432\u0440\u0435\u043c\u0435\u043d\u0438, retry rate, dead-letter \u0440\u043e\u0441\u0442 \u043f\u043e \u043f\u0440\u0438\u0447\u0438\u043d\u0430\u043c.<\/p>\n<h4>5. Timezone-aware \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435<\/h4>\n<p>\u041f\u0440\u0435\u043f\u043e\u0434 \u0436\u0438\u0432\u0451\u0442 \u0432 \u041c\u043e\u0441\u043a\u0432\u0435. \u0423\u0447\u0435\u043d\u0438\u043a \u2014 \u0432 \u041d\u043e\u0432\u043e\u0441\u0438\u0431\u0438\u0440\u0441\u043a\u0435. \u0411\u044d\u043a\u0435\u043d\u0434 \u2014 \u0432 Docker-\u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440\u0435 \u0441 TZ=UTC. \u0412\u0440\u0435\u043c\u044f \u0443\u0440\u043e\u043a\u0430 \u0445\u0440\u0430\u043d\u0438\u0442\u0441\u044f \u043a\u0430\u043a UTC. \u041a\u043e\u0433\u0434\u0430 \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0435, \u0432 \u0448\u0430\u0431\u043b\u043e\u043d\u0435 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u043e \u00ab\u0437\u0430\u043d\u044f\u0442\u0438\u0435 \u0432 <code>{time}<\/code>\u00bb \u2014 \u043a\u0430\u043a\u043e\u0435 \u0432\u0440\u0435\u043c\u044f \u0442\u0443\u0434\u0430 \u043f\u043e\u0434\u0441\u0442\u0430\u0432\u0438\u0442\u044c?<\/p>\n<p>\u041e\u0447\u0435\u0432\u0438\u0434\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442 \u2014 \u0432\u0440\u0435\u043c\u044f \u0432 \u0447\u0430\u0441\u043e\u0432\u043e\u043c \u043f\u043e\u044f\u0441\u0435 \u043f\u0440\u0435\u043f\u043e\u0434\u0430. \u0423 \u043f\u0440\u0435\u043f\u043e\u0434\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0435 \u0435\u0441\u0442\u044c <code>timezone<\/code>-\u043f\u043e\u043b\u0435 (\u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 IANA: <code>Europe\/Moscow<\/code>, <code>Asia\/Novosibirsk<\/code>).<\/p>\n<pre><code class=\"typescript\">const teacher = lesson.teacher;const timeZone = teacher?.timezone?.trim() || 'Europe\/Moscow';const fmt = new Intl.DateTimeFormat('ru-RU', {  hour: '2-digit',    minute: '2-digit',    timeZone,});const start = fmt.format(lesson.startTime);const end = fmt.format(lesson.endTime);const time = ${start} \u2014 ${end};<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u0411\u0435\u0437 \u044f\u0432\u043d\u043e\u0433\u043e <code>timeZone<\/code> \u0432 \u043e\u043f\u0446\u0438\u044f\u0445 <code>Intl.DateTimeFormat<\/code> \u0431\u0435\u0440\u0451\u0442 \u0437\u043e\u043d\u0443 <strong>\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430<\/strong> \u2014 \u0430 \u0432 Docker-\u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440\u0435 \u044d\u0442\u043e UTC. \u0418 \u0443\u0447\u0435\u043d\u0438\u043a \u0432\u0438\u0434\u0435\u043b \u0431\u044b \u00ab16:50\u00bb \u0432\u043c\u0435\u0441\u0442\u043e \u00ab19:50\u00bb. \u042f \u043b\u043e\u0432\u0438\u043b \u044d\u0442\u043e \u0432 \u043f\u0440\u043e\u0434\u0435 \u043d\u0430 \u0432\u0442\u043e\u0440\u043e\u043c \u0436\u0435 \u0440\u0435\u0430\u043b\u044c\u043d\u043e\u043c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435 \u2014 \u043f\u0440\u0435\u043f\u043e\u0434 \u0432 +3, \u044f \u043d\u0430 dev-\u043c\u0430\u0448\u0438\u043d\u0435 \u0432 +3, \u0432\u0441\u0451 \u0441\u043e\u0432\u043f\u0430\u0434\u0430\u043b\u043e, \u0438 \u043d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0430\u043b\u043e. \u041d\u0430 VPS \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 \u2014 UTC, \u0438 \u0441\u0440\u0430\u0437\u0443 \u043f\u0440\u043e\u0432\u0430\u043b.<\/p>\n<p>\u0414\u0435\u0444\u043e\u043b\u0442 <code>Europe\/Moscow<\/code> \u2014 \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u0432 PoW \u0442\u0430\u0440\u0433\u0435\u0442 \u0420\u0424, \u0438 \u0431\u043e\u043b\u044c\u0448\u0430\u044f \u0447\u0430\u0441\u0442\u044c \u043f\u0440\u0435\u043f\u043e\u0434\u043e\u0432 \u0432 \u041c\u0421\u041a-\u0437\u043e\u043d\u0435. \u0415\u0441\u043b\u0438 \u043f\u0440\u0435\u043f\u043e\u0434 \u043d\u0435 \u0437\u0430\u0434\u0430\u043b timezone (\u043d\u043e\u0432\u044b\u0439 \u0430\u043a\u043a\u0430\u0443\u043d\u0442, \u043e\u043f\u0446\u0438\u044f \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445) \u2014 \u043b\u0443\u0447\u0448\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043c\u043e\u0441\u043a\u043e\u0432\u0441\u043a\u043e\u0435 \u0432\u0440\u0435\u043c\u044f, \u0447\u0435\u043c UTC.<\/p>\n<h4>6. \u0428\u0430\u0431\u043b\u043e\u043d\u043d\u044b\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0435<\/h4>\n<p>\u041f\u0440\u0435\u043f\u043e\u0434 \u043c\u043e\u0436\u0435\u0442 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c \u0442\u0435\u043a\u0441\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f. \u0414\u0435\u0444\u043e\u043b\u0442\u043d\u044b\u0439 \u0448\u0430\u0431\u043b\u043e\u043d:<\/p>\n<pre><code>\u041d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0435 \u043e \u0437\u0430\u043d\u044f\u0442\u0438\u0438 \u0441\u0435\u0433\u043e\u0434\u043d\u044f \u0432 {time}.{subject}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0435: <code>{time}<\/code> (\u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d 18:00 \u2014 19:00), <code>{start}<\/code> (18:00), <code>{end}<\/code> (19:00), <code>{subject}<\/code> (\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u0435\u0434\u043c\u0435\u0442\u0430), <code>{student}<\/code> (\u0438\u043c\u044f \u0443\u0447\u0435\u043d\u0438\u043a\u0430), <code>{duration}<\/code> (1 \u0447 \/ 45 \u043c\u0438\u043d).<\/p>\n<p>\u041f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u2014 \u043f\u0440\u043e\u0441\u0442\u043e\u0439 regex replace:<\/p>\n<pre><code class=\"typescript\">return template  .replace(\/\\{time\\}\/g, time)  .replace(\/\\{start\\}\/g, start)  .replace(\/\\{end\\}\/g, end)  .replace(\/\\{subject\\}\/g, lesson.name ?? '')  .replace(\/\\{student\\}\/g, primaryName)  .replace(\/\\{duration\\}\/g, duration);<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u0412\u043d\u0435\u0448\u043d\u0435 \u043f\u0440\u0438\u043c\u0438\u0442\u0438\u0432, \u043d\u043e \u0437\u0430 \u044d\u0442\u0438\u043c \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0434\u0443\u043a\u0442\u043e\u0432\u043e\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u0435: <code>{time}<\/code> \u0438\u0441\u0442\u043e\u0440\u0438\u0447\u0435\u0441\u043a\u0438 \u0431\u044b\u043b \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u043e\u043c \u00ab18:00 \u2014 19:00\u00bb. \u041f\u0440\u0435\u043f\u043e\u0434-\u043f\u0438\u043b\u043e\u0442\u043d\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0441\u043a\u0430\u0437\u0430\u043b: \u00ab\u0441\u043b\u0443\u0448\u0430\u0439, \u044f \u0445\u043e\u0447\u0443 \u043f\u0438\u0441\u0430\u0442\u044c \u043f\u0440\u043e\u0441\u0442\u043e &#8216;\u0437\u0430\u043d\u044f\u0442\u0438\u0435 \u0441 18:00&#8217;, \u0430 \u043d\u0435 &#8216;\u0441 18:00 \u2014 19:00&#8217;\u00bb. \u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c <code>{time}<\/code> \u0432 \u043f\u0440\u043e\u0441\u0442\u043e-\u043d\u0430\u0447\u0430\u043b\u043e \u2014 \u0441\u043b\u043e\u043c\u0430\u0442\u044c \u0443\u0436\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0451\u043d\u043d\u044b\u0435 \u0448\u0430\u0431\u043b\u043e\u043d\u044b. \u041f\u043e\u044d\u0442\u043e\u043c\u0443 \u0434\u043e\u0431\u0430\u0432\u0438\u043b <code>{start}<\/code> \u0438 <code>{end}<\/code> \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e, \u0430 <code>{time}<\/code> \u043e\u0441\u0442\u0430\u0432\u0438\u043b \u043a\u0430\u043a range. Backward compatibility \u0446\u0435\u043d\u043e\u0439 \u043e\u0434\u043d\u043e\u0439 \u043b\u0438\u0448\u043d\u0435\u0439 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0439 \u2014 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e.<\/p>\n<h4>7. \u041e\u0434\u0438\u043d \u0447\u0435\u043b\u043e\u0432\u0435\u043a = \u0434\u0432\u0430 student-record (cloned binding)<\/h4>\n<p>\u0418 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u0440\u043e\u0434\u0443\u043a\u0442\u043e\u0432\u044b\u0439 \u043a\u0435\u0439\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0442\u0440\u0435\u0431\u043e\u0432\u0430\u043b \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0433\u043e API-\u043c\u0435\u0442\u043e\u0434\u0430.<\/p>\n<p>\u0423 \u043f\u0440\u0435\u043f\u043e\u0434\u0430 \u0435\u0441\u0442\u044c \u0434\u0432\u0435 student-\u0437\u0430\u043f\u0438\u0441\u0438: \u00ab\u041c\u0430\u0448\u0430 \u0438\u043d\u0434\u0438\u0432\u0438\u0434\u0443\u0430\u043b\u044c\u043d\u044b\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f\u00bb \u0438 \u00ab\u041c\u0430\u0448\u0430 \u0433\u0440\u0443\u043f\u043f\u043e\u0432\u044b\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f\u00bb. \u042d\u0442\u043e <strong>\u043e\u0434\u0438\u043d \u0438 \u0442\u043e\u0442 \u0436\u0435 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0439 \u0447\u0435\u043b\u043e\u0432\u0435\u043a<\/strong> (\u041c\u0430\u0448\u0430), \u043d\u043e \u0441 \u0440\u0430\u0437\u043d\u043e\u0439 \u0441\u0442\u0430\u0432\u043a\u043e\u0439 \u0438 \u0440\u0430\u0437\u043d\u044b\u043c \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043c. \u0425\u043e\u0447\u0435\u0442\u0441\u044f, \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f \u043e\u0431 <strong>\u043e\u0431\u043e\u0438\u0445<\/strong> \u0442\u0438\u043f\u0430\u0445 \u0437\u0430\u043d\u044f\u0442\u0438\u0439 \u043f\u0440\u0438\u0445\u043e\u0434\u0438\u043b\u0438 \u0432 \u043e\u0434\u0438\u043d \u0438 \u0442\u043e\u0442 \u0436\u0435 Telegram-\u0447\u0430\u0442 \u2014 \u0442\u043e\u0442, \u0447\u0442\u043e \u041c\u0430\u0448\u0430 \u0443\u0436\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u043b\u0430 \u043e\u0434\u043d\u0430\u0436\u0434\u044b.<\/p>\n<p>\u0410\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u043e \u044d\u0442\u043e \u0443\u0436\u0435 \u0431\u044b\u043b\u043e \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u043e: <code>UNIQUE(student_id, chat_id)<\/code> \u0437\u0430\u043f\u0440\u0435\u0449\u0430\u0435\u0442 \u0434\u0443\u0431\u043b\u0438 binding&#8217;\u0430 \u043d\u0430 \u043e\u0434\u043d\u043e\u0433\u043e \u0443\u0447\u0435\u043d\u0438\u043a\u0430, \u043d\u043e <strong>\u043d\u0435<\/strong> \u0437\u0430\u043f\u0440\u0435\u0449\u0430\u0435\u0442 \u0440\u0430\u0437\u043d\u044b\u043c \u0443\u0447\u0435\u043d\u0438\u043a\u0430\u043c \u0434\u0435\u043b\u0438\u0442\u044c \u043e\u0434\u0438\u043d chat_id. \u041d\u0435 \u0445\u0432\u0430\u0442\u0430\u043b\u043e UI-\u0444\u043b\u043e\u0443 \u0438 API-\u043c\u0435\u0442\u043e\u0434\u0430.<\/p>\n<p>\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u2014 <code>cloneTelegramBinding<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043a\u043e\u043f\u0438\u0440\u0443\u0435\u0442 binding \u0441 \u043e\u0434\u043d\u043e\u0433\u043e student&#8217;\u0430 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u0442\u043e\u0433\u043e \u0436\u0435 \u043f\u0440\u0435\u043f\u043e\u0434\u0430:<\/p>\n<pre><code class=\"typescript\">public async cloneTelegramBinding(  teacherId: string,    targetStudentId: string,    sourceBindingId: string,): Promise&lt;StudentTelegramBindingEntity&gt; {  await this.getStudentOrThrow(teacherId, targetStudentId);  const sourceBinding = await this.em    .getRepository(StudentTelegramBindingEntity)    .findOne({      where: { id: sourceBindingId },      relations: { student: true },    });  if (!sourceBinding || sourceBinding.student.teacherId !== teacherId) {    throw new NotFoundException('Binding not found');  }  if (sourceBinding.studentId === targetStudentId) {      throw new ConflictException('\u042d\u0442\u043e\u0442 \u0447\u0430\u0442 \u0443\u0436\u0435 \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d \u043a \u044d\u0442\u043e\u043c\u0443 \u0443\u0447\u0435\u043d\u0438\u043a\u0443');  }  const existing = await this.em    .getRepository(StudentTelegramBindingEntity)    .findOneBy({ studentId: targetStudentId, chatId: sourceBinding.chatId });  if (existing) {    throw new ConflictException('\u042d\u0442\u043e\u0442 \u0447\u0430\u0442 \u0443\u0436\u0435 \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d \u043a \u044d\u0442\u043e\u043c\u0443 \u0443\u0447\u0435\u043d\u0438\u043a\u0443');  }  return this.em.transaction(async (tx) =&gt; {    \/\/ Atomic: revoke active invite + create binding \u0432 \u043e\u0434\u043d\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438,        \/\/ \u0447\u0442\u043e\u0431\u044b \u0438\u0437\u0431\u0435\u0436\u0430\u0442\u044c \u0433\u043e\u043d\u043a\u0438 \u00abbinding \u0441\u043e\u0437\u0434\u0430\u043d, \u043d\u043e \u0430\u043a\u0442\u0438\u0432\u043d\u0430\u044f invite-\u0441\u0441\u044b\u043b\u043a\u0430        \/\/ \u0436\u0438\u0432\u0430\u044f \u2192 \u0443\u0447\u0435\u043d\u0438\u043a \u043a\u043b\u0438\u043a\u0430\u0435\u0442 \u0435\u0451 \u0438 \u043f\u043b\u043e\u0434\u0438\u0442\u0441\u044f \u0432\u0442\u043e\u0440\u0430\u044f binding \u043d\u0430 \u0447\u0443\u0436\u043e\u0439 chatId\u00bb.    await tx.getRepository(StudentTelegramInviteEntity).update(      { studentId: targetStudentId, consumedAt: IsNull(), revokedAt: IsNull() },      { revokedAt: new Date() },    );    return tx.getRepository(StudentTelegramBindingEntity).save(      new StudentTelegramBindingEntity({        studentId: targetStudentId,                chatId: sourceBinding.chatId,                username: sourceBinding.username,                firstName: sourceBinding.firstName,                lastName: sourceBinding.lastName,      }),    );  });}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u0414\u0432\u043e\u0439\u043d\u043e\u0439 ownership-check (target student + source binding \u043e\u0431\u0430 \u0434\u043e\u043b\u0436\u043d\u044b \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0442\u044c \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0449\u0435\u043c\u0443 \u043f\u0440\u0435\u043f\u043e\u0434\u0443) \u2014 \u043a\u0440\u0438\u0442\u0438\u0447\u043d\u043e. \u0418\u043d\u0430\u0447\u0435 \u043c\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u0431\u044b \u043f\u043e \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u043e\u043c\u0443 <code>bindingId<\/code> \u0447\u0443\u0436\u043e\u0433\u043e \u043f\u0440\u0435\u043f\u043e\u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0442\u0430\u0440\u0433\u0435\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0441\u043f\u0430\u043c \u0432 \u0447\u0443\u0436\u043e\u0439 \u0447\u0430\u0442. UNIQUE-\u0438\u043d\u0434\u0435\u043a\u0441 \u043d\u0430 <code>(student_id, chat_id)<\/code> \u0433\u0430\u0440\u0430\u043d\u0442\u0438\u0440\u0443\u0435\u0442, \u0447\u0442\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u044b\u0435 \u043a\u043b\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0432\u0434\u0440\u0443\u0433 \u043f\u0440\u0435\u043f\u043e\u0434 \u043d\u0430\u0436\u0430\u043b \u0434\u0432\u0430\u0436\u0434\u044b) \u043d\u0435 \u043f\u043b\u043e\u0434\u044f\u0442 \u0434\u0443\u0431\u043b\u0438\u043a\u0430\u0442\u043e\u0432.<\/p>\n<p>\u0412 TG-\u0447\u0430\u0442 \u043f\u0440\u0438 clone <strong>\u043d\u0438\u043a\u0430\u043a\u0438\u0445 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439<\/strong> \u043d\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f. \u042d\u0442\u043e \u0442\u0430 \u0436\u0435 \u0440\u0435\u0430\u043b\u044c\u043d\u0430\u044f \u041c\u0430\u0448\u0430; \u0435\u0439 \u0443\u0436\u0435 \u043f\u0440\u0438\u0445\u043e\u0434\u044f\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f \u0437\u0430 \u00ab\u0438\u043d\u0434\u0438\u0432\u0438\u0434-\u041c\u0430\u0448\u0443\u00bb, \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u043e\u0442\u043e\u043a\u0430 \u00ab\u0433\u0440\u0443\u043f\u043f-\u041c\u0430\u0448\u0430\u00bb \u2014 \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0435\u0435 \u0434\u0435\u043b\u043e \u043f\u0440\u0435\u043f\u043e\u0434\u0430.<\/p>\n<h3>\u0420\u041a\u041d \u0438 152-\u0424\u0417 \u2014 \u0447\u0442\u043e \u043d\u0443\u0436\u043d\u043e \u0437\u043d\u0430\u0442\u044c pet-\u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0432 \u0420\u0424<\/h3>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/a67\/d8a\/dc2\/a67d8adc2547ecaa36189ff160ce1c0f.jpg\" alt=\"\u0427\u0442\u043e \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0442 \u043f\u043e\u0434 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435: \u0438\u043c\u044f, \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u0430\u044f \u0441\u0443\u043c\u043c\u0430, \u0438\u0441\u0442\u043e\u0440\u0438\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439. \u0411\u0435\u0437 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0432 \u0420\u041a\u041d \u0442\u0430\u043a\u0430\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u2014 \u043d\u0430\u0440\u0443\u0448\u0435\u043d\u0438\u0435 152-\u0424\u0417\" title=\"\u0427\u0442\u043e \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0442 \u043f\u043e\u0434 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435: \u0438\u043c\u044f, \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u0430\u044f \u0441\u0443\u043c\u043c\u0430, \u0438\u0441\u0442\u043e\u0440\u0438\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439. \u0411\u0435\u0437 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0432 \u0420\u041a\u041d \u0442\u0430\u043a\u0430\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u2014 \u043d\u0430\u0440\u0443\u0448\u0435\u043d\u0438\u0435 152-\u0424\u0417\" width=\"2059\" height=\"880\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/a67\/d8a\/dc2\/a67d8adc2547ecaa36189ff160ce1c0f.jpg 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/a67\/d8a\/dc2\/a67d8adc2547ecaa36189ff160ce1c0f.jpg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>\u0427\u0442\u043e \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0442 \u043f\u043e\u0434 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435: \u0438\u043c\u044f, \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u0430\u044f \u0441\u0443\u043c\u043c\u0430, \u0438\u0441\u0442\u043e\u0440\u0438\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439. \u0411\u0435\u0437 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0432 \u0420\u041a\u041d \u0442\u0430\u043a\u0430\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u2014 \u043d\u0430\u0440\u0443\u0448\u0435\u043d\u0438\u0435 152-\u0424\u0417<\/figcaption><\/div>\n<\/figure>\n<p>\u041a\u043e\u0440\u043e\u0442\u043a\u0438\u0439, \u043d\u043e \u0432\u0430\u0436\u043d\u044b\u0439 \u0440\u0430\u0437\u0434\u0435\u043b \u0434\u043b\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0434\u0443\u043c\u0430\u044e\u0442 \u00ab\u043b\u0430\u0434\u043d\u043e, \u043c\u043e\u0439 \u043f\u0440\u043e\u0435\u043a\u0442 \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u0430\u043b\u0435\u043d\u044c\u043a\u0438\u0439, \u0434\u043e \u043c\u0435\u043d\u044f \u043d\u0438\u043a\u043e\u043c\u0443 \u043d\u0435\u0442 \u0434\u0435\u043b\u0430\u00bb.<\/p>\n<p>\u0415\u0441\u043b\u0438 \u0442\u044b \u0441\u043e\u0431\u0438\u0440\u0430\u0435\u0448\u044c \u0438\u043c\u0435\u043d\u0430 \u0438\/\u0438\u043b\u0438 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u044b\/email \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0438\u0437 \u0420\u0424 \u2014 \u0442\u044b <strong>\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u043d\u043d\u044b\u0445<\/strong> \u043f\u043e 152-\u0424\u0417. \u041d\u0435 \u00ab\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u00bb, \u0430 \u0432 \u0431\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u0435 \u0441\u043b\u0443\u0447\u0430\u0435\u0432 \u0438\u043c\u0435\u043d\u043d\u043e \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440. \u0418 \u0442\u044b \u043e\u0431\u044f\u0437\u0430\u043d:<\/p>\n<p>1. \u041f\u043e\u0434\u0430\u0442\u044c \u0432 \u0420\u041a\u041d \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u043e \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438\u0438 \u043e\u0431\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0442\u044c \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 <a href=\"https:\/\/rkn.gov.ru\" rel=\"noopener noreferrer nofollow\">rkn.gov.ru<\/a> (\u0444\u043e\u0440\u043c\u0430 \u043e\u043d\u043b\u0430\u0439\u043d).<\/p>\n<p>2. \u0418\u043c\u0435\u0442\u044c \u041f\u043e\u043b\u0438\u0442\u0438\u043a\u0443 \u043a\u043e\u043d\u0444\u0438\u0434\u0435\u043d\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0438 \u0421\u043e\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435 \u043e \u0441\u043e\u0433\u043b\u0430\u0441\u0438\u0438 \u043d\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443, \u0432\u044b\u043b\u043e\u0436\u0435\u043d\u043d\u044b\u0435 \u043d\u0430 \u0441\u0430\u0439\u0442\u0435.<\/p>\n<p>3. \u0425\u0440\u0430\u043d\u0438\u0442\u044c \u041f\u0414\u043d \u043d\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430\u0445 <strong>\u0432 \u0420\u0424<\/strong> (242-\u0424\u0417). \u0415\u0441\u043b\u0438 \u0441\u0435\u0440\u0432\u0435\u0440 \u0432 \u0413\u0435\u0440\u043c\u0430\u043d\u0438\u0438 \u2014 \u044d\u0442\u043e \u043d\u0430\u0440\u0443\u0448\u0435\u043d\u0438\u0435, \u0448\u0442\u0440\u0430\u0444\u044b \u043e\u0442 100\u041a \u20bd \u0434\u043b\u044f \u0418\u041f.<\/p>\n<p>4. \u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043d\u043d\u043e\u0433\u043e \u0437\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443 \u041f\u0414\u043d (\u0434\u043b\u044f \u0441\u0430\u043c\u043e\u0437\u0430\u043d\u044f\u0442\u043e\u0433\u043e\/\u0418\u041f \u044d\u0442\u043e \u043e\u0431\u044b\u0447\u043d\u043e \u0441\u0430\u043c).<\/p>\n<p>\u042f \u043e\u0442\u043a\u0440\u044b\u043b \u0441\u0430\u043c\u043e\u0437\u0430\u043d\u044f\u0442\u043e\u0441\u0442\u044c, \u043f\u043e\u0434\u0430\u043b \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0432 \u0420\u041a\u041d, \u043e\u0444\u043e\u0440\u043c\u0438\u043b \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044e \u041f\u0414\u043d \u043d\u0430 VPS \u0432 \u0420\u0424, \u043d\u0430\u043f\u0438\u0441\u0430\u043b \u041f\u043e\u043b\u0438\u0442\u0438\u043a\u0443 + \u0421\u043e\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435. \u041f\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u2014 \u0432\u043c\u0435\u0441\u0442\u0435 \u0441 \u0418\u0418 + \u0440\u0435\u0441\u0435\u0440\u0447\u0435\u043c \u0440\u0430\u0431\u043e\u0442\u044b \u0441\u0443\u043c\u043c\u0430\u0440\u043d\u043e \u043d\u0430 1-2 \u0434\u043d\u044f, \u043d\u0435 \u0441\u0447\u0438\u0442\u0430\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u0420\u041a\u041d.<\/p>\n<p>\u0415\u0441\u043b\u0438 \u043a\u0442\u043e-\u0442\u043e \u0438\u0437 \u0447\u0438\u0442\u0430\u0442\u0435\u043b\u0435\u0439 \u0434\u0435\u043b\u0430\u0435\u0442 SaaS-pet-\u043f\u0440\u043e\u0435\u043a\u0442 \u0434\u043b\u044f \u0420\u0424-\u0430\u0443\u0434\u0438\u0442\u043e\u0440\u0438\u0438 \u0438 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043b \u044d\u0442\u043e\u0442 \u0448\u0430\u0433: \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u044e \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u0442\u044c\u0441\u044f. \u0421 2024 \u0433\u043e\u0434\u0430 \u0420\u041a\u041d \u0430\u043a\u0442\u0438\u0432\u043d\u043e \u0440\u0430\u0441\u0441\u044b\u043b\u0430\u0435\u0442 \u0437\u0430\u043f\u0440\u043e\u0441\u044b \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u0430\u043c, \u043d\u0435 \u043f\u043e\u0434\u0430\u0432\u0448\u0438\u043c \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435. \u041f\u043e \u0441\u0443\u0442\u0438 \u044d\u0442\u043e \u0432\u043e\u043f\u0440\u043e\u0441 \u00ab\u043a\u043e\u0433\u0434\u0430 \u043f\u0440\u0438\u0434\u0451\u0442 \u043f\u0438\u0441\u044c\u043c\u043e\u00bb, \u0430 \u043d\u0435 \u00ab\u043f\u0440\u0438\u0434\u0451\u0442 \u043b\u0438\u00bb.<\/p>\n<h3>\u041e\u0442\u043a\u0440\u044b\u0442\u0430\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u0438 feedback loop<\/h3>\n<p>TeachTrack \u2014 \u0447\u0435\u0442\u0432\u0451\u0440\u0442\u044b\u0439 \u0441\u0435\u0437\u043e\u043d \u043c\u043e\u0435\u0433\u043e \u043b\u0438\u0447\u043d\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430\u0442\u0430 \u00abProof-of-Work\u00bb: \u043e\u0434\u0438\u043d \u043f\u0440\u043e\u0435\u043a\u0442 \u2014 \u043e\u0434\u0438\u043d \u0441\u0435\u0437\u043e\u043d. \u0412\u0441\u0435 \u043a\u043e\u043c\u043c\u0438\u0442\u044b \u043f\u0443\u0431\u043b\u0438\u0447\u043d\u044b\u0435 (\u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438 \u043d\u0430 GitHub: backend, frontend), changelog \u0432 <a href=\"http:\/\/CHANGELOG.md\" rel=\"noopener noreferrer nofollow\"><code>CHANGELOG.md<\/code><\/a>, \u0440\u0435\u0433\u0443\u043b\u044f\u0440\u043d\u044b\u0435 \u0430\u043f\u0434\u0435\u0439\u0442\u044b \u0432 Telegram-\u043a\u0430\u043d\u0430\u043b\u0435 <strong>OneZee<\/strong>.<\/p>\n<p>\u0424\u043e\u0440\u043c\u0430\u0442 \u0441\u0442\u0440\u0430\u043d\u043d\u044b\u0439, \u043d\u043e \u043e\u043d \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442: \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u0441\u0442\u044c \u0434\u0438\u0441\u0446\u0438\u043f\u043b\u0438\u043d\u0438\u0440\u0443\u0435\u0442. \u041a\u043e\u0433\u0434\u0430 \u043a\u043e\u043c\u043c\u0438\u0442\u044b \u0432\u0438\u0434\u044f\u0442 \u0436\u0438\u0432\u044b\u0435 \u043b\u044e\u0434\u0438 \u2014 \u043f\u0438\u0448\u0435\u0448\u044c \u0447\u0438\u0449\u0435, \u043d\u0435 \u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0448\u044c TODO \u0432 \u0434\u0443\u0445\u0435 \u00ab\u043f\u043e\u0442\u043e\u043c \u0440\u0430\u0437\u0431\u0435\u0440\u0443\u0441\u044c\u00bb, \u0438 \u0431\u043e\u0438\u0448\u044c\u0441\u044f \u043e\u043f\u0443\u0431\u043b\u0438\u043a\u043e\u0432\u0430\u0442\u044c \u0441\u044b\u0440\u043e\u0435.<\/p>\n<p>Feedback loop: \u043f\u0435\u0440\u0432\u044b\u0435 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u0438 \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435 \u043f\u0440\u0438\u0448\u043b\u0438 \u0447\u0435\u0440\u0435\u0437 \u0445\u043e\u043b\u043e\u0434\u043d\u044b\u0439 \u0430\u0443\u0442\u0440\u0438\u0447 \u0432 Telegram-\u0447\u0430\u0442\u0430\u0445 \u0440\u0435\u043f\u0435\u0442\u0438\u0442\u043e\u0440\u043e\u0432. \u0418\u0437 30 \u043e\u0442\u0432\u0435\u0442\u0438\u043b\u0438 3: \u00ab\u0441\u043f\u0430\u0441\u0438\u0431\u043e, \u043d\u0435 \u043d\u0443\u0436\u043d\u043e\u00bb \u0438 2 \u00ab\u043e\u043a, \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e. \u0410 \u043e\u0442 \u043c\u0435\u043d\u044f \u0447\u0442\u043e \u043d\u0443\u0436\u043d\u043e-\u0442\u043e?\u00bb.<\/p>\n<p>\u042d\u0442\u043e\u0442 \u0432\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u0434\u0441\u0432\u0435\u0442\u0438\u043b \u043c\u043d\u0435 \u0434\u044b\u0440\u0443 \u0432 \u0438\u0441\u0445\u043e\u0434\u043d\u043e\u043c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0438. \u042f \u043f\u0438\u0441\u0430\u043b \u00ab\u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435, \u0434\u0430\u0439\u0442\u0435 \u0444\u0438\u0434\u0431\u0435\u043a\u00bb. \u0412 \u043c\u043e\u0435\u0439 \u0433\u043e\u043b\u043e\u0432\u0435 \u044d\u0442\u043e \u0431\u044b\u043b\u043e \u044f\u0441\u043d\u043e. \u0421 \u0442\u043e\u0439 \u0441\u0442\u043e\u0440\u043e\u043d\u044b \u2014 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u0447\u0435\u043b\u043e\u0432\u0435\u043a \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043b \u00ab\u043d\u0435\u0437\u043d\u0430\u043a\u043e\u043c\u044b\u0439 \u043f\u0430\u0440\u0435\u043d\u044c \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e \u0447\u0442\u043e-\u0442\u043e \u043f\u0440\u0435\u0434\u043b\u0430\u0433\u0430\u0435\u0442\u00bb \u0438 \u0441\u043f\u0440\u043e\u0441\u0438\u043b \u0432 \u043b\u043e\u0431: \u00ab\u0432 \u0447\u0451\u043c \u043f\u043e\u0434\u0432\u043e\u0445?\u00bb<\/p>\n<p>\u041f\u0435\u0440\u0435\u043f\u0438\u0441\u0430\u043b \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435: \u0443\u043a\u0430\u0437\u0430\u043b \u044f\u0432\u043d\u043e, \u0447\u0442\u043e \u0432\u0437\u0430\u043c\u0435\u043d \u043f\u0440\u043e\u0448\u0443 \u043e\u0434\u0438\u043d \u043e\u0442\u0437\u044b\u0432, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043f\u043e\u0442\u043e\u043c \u0440\u0430\u0437\u043c\u0435\u0449\u0443 \u043d\u0430 \u0433\u043b\u0430\u0432\u043d\u043e\u0439 \u0441\u0430\u0439\u0442\u0430. \u041d\u0438\u043a\u0430\u043a\u043e\u0433\u043e \u043f\u043e\u0434\u0432\u043e\u0445\u0430 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435\u0442, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u043e\u0431\u043c\u0435\u043d \u0442\u0435\u043f\u0435\u0440\u044c \u044f\u0432\u043d\u044b\u0439.<\/p>\n<p>\u0417\u0430\u043c\u0435\u0442\u043a\u0430 \u043d\u0430 \u0431\u0443\u0434\u0443\u0449\u0435\u0435: \u0432 \u043b\u044e\u0431\u043e\u043c \u0445\u043e\u043b\u043e\u0434\u043d\u043e\u043c \u0430\u0443\u0442\u0440\u0438\u0447\u0435 \u0432 \u0420\u0424, \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0432\u0437\u0430\u043c\u0435\u043d \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u043d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0443\u0436\u043d\u043e, \u043f\u0440\u0438\u0434\u0443\u043c\u0430\u0439 \u044f\u0432\u043d\u044b\u0439 \u043e\u0431\u043c\u0435\u043d. \u0418\u043d\u0430\u0447\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0448\u044c \u043c\u043e\u043b\u0447\u0430\u043d\u0438\u0435 (\u043a\u0430\u043a \u0443 \u043c\u0435\u043d\u044f \u2014 25 \u0438\u0437 30) \u0438\u043b\u0438 \u0432\u043e\u043f\u0440\u043e\u0441 \u00ab\u0432 \u0447\u0451\u043c \u043f\u043e\u0434\u0432\u043e\u0445\u00bb (\u043a\u0430\u043a \u0443 \u0434\u0432\u0443\u0445 \u0441\u0430\u043c\u044b\u0445 \u0441\u043c\u0435\u043b\u044b\u0445).<\/p>\n<h3>AI \u043a\u0430\u043a \u0441\u043e\u0430\u0432\u0442\u043e\u0440<\/h3>\n<p>\u041a\u0430\u043a \u0438 \u0432 TripTrack-\u0441\u0442\u0430\u0442\u044c\u0435, \u043d\u0435 \u043c\u043e\u0433\u0443 \u043f\u0440\u043e\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u044d\u0442\u0443 \u0442\u0435\u043c\u0443 \u2014 \u0431\u0435\u0437 AI-\u0430\u0433\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0435\u043a\u0442 \u0431\u044b \u043b\u0438\u0431\u043e \u043d\u0435 \u0441\u043b\u0443\u0447\u0438\u043b\u0441\u044f, \u043b\u0438\u0431\u043e \u0441\u043b\u0443\u0447\u0438\u043b\u0441\u044f \u0447\u0435\u0440\u0435\u0437 \u0433\u043e\u0434.<\/p>\n<p><strong>\u0413\u0434\u0435 Claude Code \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u0432\u044b\u0442\u0430\u0449\u0438\u043b.<\/strong> \u0411\u043e\u0439\u043b\u0435\u0440\u043f\u043b\u0435\u0439\u0442 NestJS-\u043c\u043e\u0434\u0443\u043b\u0435\u0439 \u2014 controller + service + DTO + entity + migration. \u0415\u0441\u043b\u0438 \u0442\u044b \u0437\u043d\u0430\u0435\u0448\u044c \u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u0443, \u043d\u043e \u043d\u0435 \u043f\u043e\u043c\u043d\u0438\u0448\u044c \u0442\u043e\u0447\u043d\u044b\u0439 \u0441\u0438\u043d\u0442\u0430\u043a\u0441\u0438\u0441 <code>@Cron<\/code> \u0434\u0435\u043a\u043e\u0440\u0430\u0442\u043e\u0440\u043e\u0432 \u0438\u043b\u0438 TypeORM relations \u2014 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e \u0441\u043a\u0435\u043b\u0435\u0442\u0430 \u0438 \u043f\u043e\u0442\u043e\u043c \u0440\u0430\u0437\u0431\u043e\u0440 \u00ab\u0447\u0442\u043e \u043e\u043d \u0441\u0434\u0435\u043b\u0430\u043b \u0438 \u043f\u043e\u0447\u0435\u043c\u0443\u00bb \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0432 \u0440\u0430\u0437\u044b \u0431\u044b\u0441\u0442\u0440\u0435\u0435, \u0447\u0435\u043c \u0433\u0443\u0433\u043b\u0438\u0442\u044c.<\/p>\n<p>TypeORM \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438 \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439 \u043a\u0430\u0439\u0444. \u041e\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0448\u044c \u0441\u043b\u043e\u0432\u0430\u043c\u0438 \u00ab\u0434\u043e\u0431\u0430\u0432\u044c partial unique index \u043d\u0430 student_telegram_invite \u043f\u043e (student_id) \u043f\u0440\u0438 condition <code>consumed_at IS NULL AND revoked_at IS NULL<\/code>\u00bb, \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0448\u044c \u0440\u0430\u0431\u043e\u0447\u0438\u0439 \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0439 \u0441\u043a\u0440\u0438\u043f\u0442. \u0421\u0430\u043c \u0431\u044b \u044f \u043f\u043e\u043b\u0435\u0437 \u0432 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044e \u043c\u0438\u043d\u0443\u0442 \u043d\u0430 15.<\/p>\n<p>React-\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0441 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u043c state-management \u0447\u0435\u0440\u0435\u0437 react-query \u2014 \u0442\u0443\u0442 AI \u0432\u044b\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u0435\u0442 \u043d\u0430 80%. Optimistic updates, \u0438\u043d\u0432\u0430\u043b\u0438\u0434\u0430\u0446\u0438\u044f cache, \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u043e\u0448\u0438\u0431\u043e\u043a \u2014 \u043f\u0430\u0442\u0442\u0435\u0440\u043d\u043e\u0432 \u0443\u0436\u0435 \u043c\u0438\u043b\u043b\u0438\u043e\u043d \u0432 \u0434\u0430\u0442\u0430\u0441\u0435\u0442\u0435, \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u0447\u0442\u0438 \u0431\u0435\u0437\u043e\u0448\u0438\u0431\u043e\u0447\u043d\u0430\u044f.<\/p>\n<p><strong>\u0413\u0434\u0435 AI \u043e\u043a\u0430\u0437\u0430\u043b\u0441\u044f \u0431\u0435\u0441\u043f\u043e\u043c\u043e\u0449\u043d\u044b\u043c.<\/strong> \u041f\u0440\u043e\u0434\u0443\u043a\u0442\u043e\u0432\u044b\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u044f. \u0427\u0442\u043e \u0432\u0430\u0436\u043d\u0435\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u0432 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0435 \u0443\u0447\u0435\u043d\u0438\u043a\u0430 \u2014 \u043e\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u0438\u043b\u0438 \u0431\u0430\u043b\u0430\u043d\u0441 \u043f\u0430\u043a\u0435\u0442\u0430? \u041a\u0430\u043a\u043e\u0439 UX \u0443 \u0444\u043e\u0440\u043c\u044b \u00ab\u043e\u0442\u043c\u0435\u043d\u0430 \u0437\u0430\u043d\u044f\u0442\u0438\u044f\u00bb \u2014 \u043c\u043e\u0434\u0430\u043b\u043a\u0430 \u0438\u043b\u0438 slideout? \u041a\u0443\u0434\u0430 \u043f\u043e\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044c \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439 \u2014 \u0432 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443 TG \u0438\u043b\u0438 \u0432 \u043e\u0431\u0449\u0438\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438? \u2014 Claude \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0438\u0442\u044c \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u044b, \u043d\u043e \u0440\u0435\u0448\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0435\u043f\u043e\u0434-\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0438 \u044f.<\/p>\n<p>\u0418\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u044b\u0439 outbox \u2014 \u0440\u0435\u0431\u0451\u043d\u043e\u043a \u0431\u043e\u043b\u0438. \u042f \u0442\u0440\u0438 \u0440\u0430\u0437\u0430 \u043f\u0435\u0440\u0435\u043f\u0438\u0441\u044b\u0432\u0430\u043b \u0432\u043e\u0440\u043a\u0435\u0440, \u043f\u043e\u043a\u0430 \u043d\u0435 \u0434\u043e\u0448\u0451\u043b \u0434\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u0441 <code>FOR UPDATE SKIP LOCKED<\/code> \u0438 \u044f\u0432\u043d\u044b\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043c transient\/permanent\/blocked. \u041a\u0430\u0436\u0434\u044b\u0439 AI-\u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0432\u0430\u0440\u0438\u0430\u043d\u0442 \u0431\u044b\u043b \u00ab\u0442\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u043c\u00bb \u2014 \u043d\u043e \u043d\u0435 \u0440\u0435\u0448\u0430\u043b \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0439 \u0432\u043e\u043f\u0440\u043e\u0441 \u00ab\u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c, \u0435\u0441\u043b\u0438 Telegram \u0432\u0435\u0440\u043d\u0443\u043b 429 \u043d\u0430 \u043f\u044f\u0442\u043e\u0439 \u043f\u043e\u043f\u044b\u0442\u043a\u0435 \u0438\u0437 \u0434\u0435\u0441\u044f\u0442\u0438\u00bb. \u0421\u0435\u043b \u0438 \u043d\u0430\u043f\u0438\u0441\u0430\u043b \u0441\u0430\u043c, \u043f\u043e \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u044b\u043c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0432\u0438\u0434\u0435\u043b \u0432 \u043b\u043e\u0433\u0430\u0445.<\/p>\n<p>\u041b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f \u041f\u0414\u043d \u043f\u043e\u0434 \u0420\u041a\u041d \u2014 Claude \u0437\u043d\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u0442\u043e, \u0447\u0442\u043e \u0432 \u0435\u0433\u043e \u043e\u0431\u0443\u0447\u0430\u044e\u0449\u0435\u043c \u0434\u0430\u0442\u0430\u0441\u0435\u0442\u0435 \u043d\u0430 2024 \u0433\u043e\u0434. \u041f\u043e 152-\u0424\u0417 \u0435\u0441\u0442\u044c \u0441\u0432\u0435\u0436\u0438\u0435 \u043f\u043e\u043f\u0440\u0430\u0432\u043a\u0438 2025-\u0433\u043e (376-\u0424\u0417 \u043f\u0440\u043e \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438 \u0438 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u0443\u044e \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c) \u2014 \u043e\u043d \u043f\u0440\u043e \u043d\u0438\u0445 \u043d\u0435 \u0437\u043d\u0430\u0435\u0442.<\/p>\n<p><strong>\u041f\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438.<\/strong> \u041e\u0442 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430 \u0434\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u044b \u2014 \u043e\u043a\u043e\u043b\u043e \u0434\u0432\u0443\u0445 \u0441 \u043f\u043e\u043b\u043e\u0432\u0438\u043d\u043e\u0439 \u043d\u0435\u0434\u0435\u043b\u044c \u0432\u0435\u0447\u0435\u0440\u0430\u043c\u0438 \u043f\u043e\u0441\u043b\u0435 \u0440\u0430\u0431\u043e\u0442\u044b \u0438 \u0432 \u0432\u044b\u0445\u043e\u0434\u043d\u044b\u0435. \u0411\u0435\u0437 AI \u044d\u0442\u043e \u0431\u044b\u043b\u043e \u0431\u044b \u0432\u0441\u0435 \u0447\u0435\u0442\u044b\u0440\u0435 \u043c\u0435\u0441\u044f\u0446\u0430, \u043c\u043e\u0436\u0435\u0442 \u043f\u044f\u0442\u044c. \u0421 \u043d\u0438\u043c \u2014 \u044f \u043d\u0435 \u043f\u0440\u0435\u0432\u0440\u0430\u0442\u0438\u043b\u0441\u044f \u0432 superman, \u043d\u043e \u0441\u043c\u043e\u0433 \u0441\u043e\u0431\u0440\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u0443\u043a\u0442, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e \u0441\u0430\u043c \u0438 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442 \u0436\u0438\u0432\u044b\u0435 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u0438.<\/p>\n<p><strong>\u0421\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0438\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442\u043e\u0432.<\/strong> ~$120\/\u043c\u0435\u0441 \u043d\u0430 Claude \u0438 Cursor. \u041f\u043e\u043b\u043e\u0432\u0438\u043d\u0443 \u044d\u0442\u043e\u0439 \u0441\u0443\u043c\u043c\u044b \u044f \u0438 \u0442\u0430\u043a \u0442\u0440\u0430\u0447\u0443 \u043d\u0430 \u0440\u0430\u0431\u043e\u0447\u0438\u0435 \u0437\u0430\u0434\u0430\u0447\u0438, \u0442\u0430\u043a \u0447\u0442\u043e \u0447\u0438\u0441\u0442\u044b\u0439 incremental cost \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u2014 \u043e\u043a\u043e\u043b\u043e $60\/\u043c\u0435\u0441.<\/p>\n<h3>\u0426\u0438\u0444\u0440\u044b<\/h3>\n<ul>\n<li>\n<p><strong>~20 \u0434\u043d\u0435\u0439<\/strong> \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0439 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438 (\u0432\u0435\u0447\u0435\u0440\u0430 + \u0432\u044b\u0445\u043e\u0434\u043d\u044b\u0435)<\/p>\n<\/li>\n<li>\n<p><strong>~25 000 \u0441\u0442\u0440\u043e\u043a<\/strong> TypeScript (backend + frontend \u0441\u0443\u043c\u043c\u0430\u0440\u043d\u043e, \u043d\u0435 \u0441\u0447\u0438\u0442\u0430\u044f \u0442\u0435\u0441\u0442\u043e\u0432)<\/p>\n<\/li>\n<li>\n<p><strong>~80 \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0439<\/strong> TypeORM \u043e\u0442 \u043f\u0435\u0440\u0432\u043e\u0439 \u0434\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u0439<\/p>\n<\/li>\n<li>\n<p><strong>350\u20bd\/\u043c\u0435\u0441<\/strong> \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 (VPS)<\/p>\n<\/li>\n<li>\n<p><strong>$120\/\u043c\u0435\u0441<\/strong> \u0438\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442\u044b \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438 (Claude Code + Cursor)<\/p>\n<\/li>\n<li>\n<p><strong>3 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f<\/strong> \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435 \u043d\u0430 \u043c\u043e\u043c\u0435\u043d\u0442 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438 (2 \u043e\u0442\u0432\u0435\u0442\u0438\u043b\u0438 \u043d\u0430 \u0445\u043e\u043b\u043e\u0434\u043d\u044b\u0439 \u0430\u0443\u0442\u0440\u0438\u0447, 1 &#8212; \u043c\u043e\u044f \u0434\u0435\u0432\u0443\u0448\u043a\u0430)<\/p>\n<\/li>\n<li>\n<p><strong>0 \u0440\u0443\u0431\u043b\u0435\u0439<\/strong> \u043c\u043e\u043d\u0435\u0442\u0438\u0437\u0430\u0446\u0438\u0438 (\u044d\u0442\u043e \u0441\u043e\u0437\u043d\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u0432\u044b\u0431\u043e\u0440: \u043f\u043e\u043a\u0430 feedback loop \u0432\u0430\u0436\u043d\u0435\u0435)<\/p>\n<\/li>\n<\/ul>\n<h3>\u0427\u0442\u043e \u0434\u0430\u043b\u044c\u0448\u0435<\/h3>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/3a5\/c45\/20b\/3a5c4520b6379e1df349152f4f3be79f.jpg\" alt=\"\u0414\u0430\u0448\u0431\u043e\u0440\u0434 \u0430\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430: \u0432\u044b\u0440\u0443\u0447\u043a\u0430, \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043f\u043e \u0434\u043d\u044f\u043c, \u0443\u0447\u0435\u043d\u0438\u043a\u0438 \u043f\u043e \u0433\u0440\u0443\u043f\u043f\u0430\u043c. \u0421\u044e\u0434\u0430 \u043d\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435 \u044f \u0441\u043c\u043e\u0442\u0440\u044e, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c, \u0433\u0434\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f, \u0430 \u0433\u0434\u0435 \u2014 \u043b\u0435\u0436\u0438\u0442 \u0431\u0435\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438\" title=\"\u0414\u0430\u0448\u0431\u043e\u0440\u0434 \u0430\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430: \u0432\u044b\u0440\u0443\u0447\u043a\u0430, \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043f\u043e \u0434\u043d\u044f\u043c, \u0443\u0447\u0435\u043d\u0438\u043a\u0438 \u043f\u043e \u0433\u0440\u0443\u043f\u043f\u0430\u043c. \u0421\u044e\u0434\u0430 \u043d\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435 \u044f \u0441\u043c\u043e\u0442\u0440\u044e, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c, \u0433\u0434\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f, \u0430 \u0433\u0434\u0435 \u2014 \u043b\u0435\u0436\u0438\u0442 \u0431\u0435\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438\" width=\"2057\" height=\"1224\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/3a5\/c45\/20b\/3a5c4520b6379e1df349152f4f3be79f.jpg 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/3a5\/c45\/20b\/3a5c4520b6379e1df349152f4f3be79f.jpg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>\u0414\u0430\u0448\u0431\u043e\u0440\u0434 \u0430\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430: \u0432\u044b\u0440\u0443\u0447\u043a\u0430, \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043f\u043e \u0434\u043d\u044f\u043c, \u0443\u0447\u0435\u043d\u0438\u043a\u0438 \u043f\u043e \u0433\u0440\u0443\u043f\u043f\u0430\u043c. \u0421\u044e\u0434\u0430 \u043d\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435 \u044f \u0441\u043c\u043e\u0442\u0440\u044e, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c, \u0433\u0434\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0440\u0435\u0430\u043b\u044c\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f, \u0430 \u0433\u0434\u0435 \u2014 \u043b\u0435\u0436\u0438\u0442 \u0431\u0435\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438<\/figcaption><\/div>\n<\/figure>\n<p><strong>\u0411\u044d\u043a\u043b\u043e\u0433:<\/strong><\/p>\n<ul>\n<li>\n<p><strong>\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f<\/strong> \u2014 \u0438\u0437\u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e \u043f\u043e\u0434 \u0440\u0435\u043f\u0435\u0442\u0438\u0442\u043e\u0440\u043e\u0432 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u043e\u0433\u043e, \u0441\u0435\u0439\u0447\u0430\u0441 \u0440\u0430\u0441\u0448\u0438\u0440\u044f\u0435\u043c \u043d\u0430 \u0432\u0441\u0435\u0445 \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u0435\u0439: \u0442\u0440\u0435\u043d\u0435\u0440\u044b, \u0439\u043e\u0433\u0430-\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u044b, \u043c\u0443\u0437\u044b\u043a\u0430\u043b\u044c\u043d\u0430\u044f \u0448\u043a\u043e\u043b\u0430.<\/p>\n<\/li>\n<li>\n<p><strong>Monetization<\/strong> \u2014 \u043f\u043e\u0441\u043b\u0435 \u0420\u041a\u041d \u0438 \u0441\u0442\u0430\u0431\u0438\u043b\u0438\u0437\u0430\u0446\u0438\u0438. \u0421\u043a\u043e\u0440\u0435\u0435 \u0432\u0441\u0435\u0433\u043e \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e \u0434\u043e 5 \u0443\u0447\u0435\u043d\u0438\u043a\u043e\u0432 + \u043f\u043b\u0430\u0442\u043d\u044b\u0439 \u043f\u043b\u0430\u043d \u0434\u043b\u044f \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u043f\u0440\u0435\u043f\u043e\u0434\u043e\u0432.<\/p>\n<\/li>\n<li>\n<p><strong>\u041c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435<\/strong> \u2014 \u043a\u0440\u0438\u0442\u0438\u0447\u043d\u043e \u0434\u043b\u044f pre\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u0435\u0439, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0432\u0435\u0434\u0443\u0442 \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u0441 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430. \u0414\u0443\u043c\u0430\u044e \u043e React Native (\u043e\u0434\u0438\u043d \u043a\u043e\u0434\u0431\u0435\u0439\u0441 \u0441 frontend) \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u043e\u0432\u0442\u043e\u0440\u0435\u043d\u0438\u044f SwiftUI-\u043f\u043e\u0434\u0445\u043e\u0434\u0430 \u043a\u0430\u043a \u0432 TripTrack.<\/p>\n<\/li>\n<li>\n<p><strong>Webhook \u0432\u043c\u0435\u0441\u0442\u043e long-polling<\/strong> \u0434\u043b\u044f \u0431\u043e\u0442\u0430 \u2014 \u043a\u043e\u0433\u0434\u0430 \u0432\u044b\u0440\u0430\u0441\u0442\u0435\u043c \u0434\u043e \u0443\u0440\u043e\u0432\u043d\u044f, \u043a\u043e\u0433\u0434\u0430 5-\u0441\u0435\u043a\u0443\u043d\u0434\u043d\u044b\u0439 \u043b\u0430\u0433 \u043c\u0435\u0436\u0434\u0443 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043c \u0438 \u043e\u0442\u0432\u0435\u0442\u043e\u043c \u043d\u0430\u0447\u043d\u0451\u0442 \u043e\u0449\u0443\u0449\u0430\u0442\u044c\u0441\u044f.<\/p>\n<\/li>\n<\/ul>\n<h3>\u041e\u0442\u043a\u0440\u044b\u0442\u044b\u0439 \u043a\u043e\u0434 \u0438 \u043a\u0430\u043d\u0430\u043b<\/h3>\n<ul>\n<li>\n<p>\u0421\u0430\u0439\u0442: <a href=\"https:\/\/teachtrack.ru\" rel=\"noopener noreferrer nofollow\">teachtrack.ru<\/a><\/p>\n<\/li>\n<\/ul>\n<ul>\n<li>\n<p>Telegram-\u043a\u0430\u043d\u0430\u043b \u0430\u0432\u0442\u043e\u0440\u0430: <a href=\"https:\/\/t.me\/onezee_co\" rel=\"noopener noreferrer nofollow\">OneZee<\/a> \u2014 \u043f\u0440\u043e \u043f\u0440\u043e\u0446\u0435\u0441\u0441, \u0433\u0440\u0430\u0431\u043b\u0438, \u0438 \u043b\u0438\u0447\u043d\u044b\u0435 \u043d\u0430\u0431\u043b\u044e\u0434\u0435\u043d\u0438\u044f. \u0411\u0435\u0437 \u0440\u0435\u0434\u043f\u043e\u043b\u0438\u0442\u0438\u043a\u0438.<\/p>\n<\/li>\n<\/ul>\n<ul>\n<li>\n<p>\u0420\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438: <a href=\"https:\/\/github.com\/onezee23\" rel=\"noopener noreferrer nofollow\">GitHub OneZee<\/a><\/p>\n<\/li>\n<\/ul>\n<ul>\n<li>\n<p>\u041f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0430\u044f \u0441\u0442\u0430\u0442\u044c\u044f \u043d\u0430 \u0425\u0430\u0431\u0440\u0435: <a href=\"https:\/\/habr.com\/ru\/articles\/1024896\/\" rel=\"noopener noreferrer nofollow\">TripTrack: \u0424\u0438\u043b\u044c\u0442\u0440 \u041a\u0430\u043b\u043c\u0430\u043d\u0430, geohash fog of war \u0438 \u0442\u0440\u0438 \u0440\u0435\u0434\u0436\u0435\u043a\u0442\u0430 Apple<\/a> \u2014 \u0434\u043b\u044f \u0442\u0435\u0445, \u043a\u0442\u043e \u043f\u0440\u043e\u043f\u0443\u0441\u0442\u0438\u043b.<\/p>\n<\/li>\n<\/ul>\n<p>\u0415\u0441\u043b\u0438 \u0435\u0441\u0442\u044c \u0432\u043e\u043f\u0440\u043e\u0441\u044b \u043f\u043e \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u2014 \u043f\u0438\u0448\u0438\u0442\u0435 \u0432 \u043a\u043e\u043c\u043c\u0435\u043d\u0442\u0430\u0440\u0438\u044f\u0445 \u0438\u043b\u0438 \u0432 GitHub issues. \u0415\u0441\u043b\u0438 \u0432\u044b \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044c \u0438 \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u043e\u043f\u0440\u043e\u0431\u043e\u0432\u0430\u0442\u044c TeachTrack \u2014 \u043f\u0438\u0448\u0438\u0442\u0435 \u0432 Telegram, \u043e\u0442\u0432\u0435\u0447\u0443 \u043b\u0438\u0447\u043d\u043e \u0438 \u043f\u043e\u043c\u043e\u0433\u0443 \u0441 onboarding.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1032304\/\">https:\/\/habr.com\/ru\/articles\/1032304\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u041c\u0435\u0441\u044f\u0446 \u043d\u0430\u0437\u0430\u0434 \u044f \u0432\u044b\u043b\u043e\u0436\u0438\u043b \u043d\u0430 \u0425\u0430\u0431\u0440 \u0441\u0442\u0430\u0442\u044c\u044e \u043f\u0440\u043e TripTrack \u2014 GPS-\u0442\u0440\u0435\u043a\u0435\u0440 \u0434\u043b\u044f \u043c\u0430\u0448\u0438\u043d\u044b \u043d\u0430 iOS, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0441\u043e\u0431\u0440\u0430\u043b \u0431\u0443\u0434\u0443\u0447\u0438 \u0431\u044d\u043a\u0435\u043d\u0434\u0435\u0440\u043e\u043c \u0431\u0435\u0437 \u043e\u043f\u044b\u0442\u0430 \u0432 Swift. \u0421\u0442\u0430\u0442\u044c\u044f \u043d\u0435\u043e\u0436\u0438\u0434\u0430\u043d\u043d\u043e \u043d\u0430\u0431\u0440\u0430\u043b\u0430 7.4\u041a (\u043d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u044f) \u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u043e\u0432. \u041d\u043e, \u043c\u043d\u0435 \u043f\u043e\u0441\u0447\u0430\u0441\u0442\u043b\u0438\u0432\u0438\u043b\u043e\u0441\u044c \u043f\u043e\u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043f\u043e \u0441\u0432\u043e\u0435\u0439 \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438, \u043d\u0435 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u0434 IOS-\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0430 \u043f\u043e\u0434 NestJS \u0431\u0435\u043a\u0435\u043d\u0434.\u041f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e \u0441 TripTrack \u044f \u043f\u0438\u0441\u0430\u043b \u0432\u0442\u043e\u0440\u043e\u0439 \u043f\u0440\u043e\u0435\u043a\u0442 \u2014 \u043d\u0430 \u044d\u0442\u043e\u0442 \u0440\u0430\u0437 \u0440\u043e\u0432\u043d\u043e \u0432 \u0437\u043e\u043d\u0435 \u043a\u043e\u043c\u0444\u043e\u0440\u0442\u0430 (NestJS + PostgreSQL), \u0438 \u044d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u043b\u043e \u0440\u0430\u0437\u0432\u0435\u0440\u043d\u0443\u0442\u044c\u0441\u044f \u043f\u043e-\u0441\u0435\u0440\u044c\u0451\u0437\u043d\u043e\u043c\u0443: \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u043e\u043d\u043d\u044b\u0439 outbox \u0434\u043b\u044f \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u044b\u0445 \u043e\u0442\u043f\u0440\u0430\u0432\u043e\u043a \u0432 Telegram, single-use invite-\u0442\u043e\u043a\u0435\u043d\u044b \u0441 \u0437\u0430\u0449\u0438\u0442\u043e\u0439 \u043e\u0442 enumeration, timezone-aware scheduler, partial unique indexes \u2014 \u0441\u043b\u043e\u0432\u043e\u043c, \u0432\u0441\u0451 \u0442\u043e, \u0447\u0442\u043e \u0434\u043b\u044f \u0431\u044d\u043a\u0435\u043d\u0434\u0435\u0440\u0430 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e \u0441\u0430\u043c\u043e \u043f\u043e \u0441\u0435\u0431\u0435.\u041f\u043e\u0434 \u043a\u0430\u0442\u043e\u043c \u2014 \u043f\u0440\u043e \u0442\u043e, \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0435\u043d Telegram-\u0431\u043e\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439 \u0432 TeachTrack, \u0447\u0442\u043e \u044f \u043f\u043e\u043d\u044f\u043b \u043f\u0440\u043e pessimistic_write \u0438 FOR UPDATE SKIP LOCKED, \u0437\u0430\u0447\u0435\u043c pet-\u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0441 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u043c\u0438 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c\u0438 \u0438\u0437 \u0420\u0424 \u043d\u0443\u0436\u043d\u043e \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0432 \u0420\u041a\u041d, \u0438 \u043f\u043e\u0447\u0435\u043c\u0443 \u0445\u043e\u043b\u043e\u0434\u043d\u044b\u0439 \u0430\u0443\u0442\u0440\u0438\u0447 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f\u043c \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u043e\u0433\u043e \u043d\u0430\u0443\u0447\u0438\u043b \u043c\u0435\u043d\u044f \u0432\u0430\u0436\u043d\u043e\u0439 \u0432\u0435\u0449\u0438 \u043f\u0440\u043e \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u043c\u0435\u043d\u0442\u0430\u043b\u0438\u0442\u0435\u0442.\u0417\u0430\u0447\u0435\u043c \u044d\u0442\u043e \u0432\u043e\u043e\u0431\u0449\u0435\u041c\u043e\u044f \u0434\u0435\u0432\u0443\u0448\u043a\u0430 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0451\u0442 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0438\u0439. \u0423\u0447\u0435\u043d\u0438\u043a\u043e\u0432 \u0434\u0435\u0441\u044f\u0442\u043e\u043a, \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0440\u0430\u0437 \u0432 \u043d\u0435\u0434\u0435\u043b\u044e, \u0438 \u043a\u0430\u0436\u0434\u043e\u0435 \u043d\u0430\u0447\u0430\u043b\u043e \u043c\u0435\u0441\u044f\u0446\u0430 \u2014 \u043e\u0434\u043d\u0430 \u0438 \u0442\u0430 \u0436\u0435 \u043a\u0430\u0440\u0442\u0438\u043d\u0430: \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f Excel-\u0444\u0430\u0439\u043b, \u0440\u044f\u0434\u043e\u043c \u0431\u043b\u043e\u043a\u043d\u043e\u0442, \u0440\u044f\u0434\u043e\u043c \u043a\u0430\u043b\u0435\u043d\u0434\u0430\u0440\u044c \u0432 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0435. \u00ab\u0422\u0430\u043a, \u0443 \u041c\u0430\u0448\u0438 \u0431\u044b\u043b\u043e \u0447\u0435\u0442\u044b\u0440\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f, \u043d\u043e \u043e\u0434\u043d\u043e \u043e\u043d\u0430 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0438\u043b\u0430\u2026 \u0430 \u0418\u0432\u0430\u043d \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b \u0437\u0430 \u043f\u0430\u043a\u0435\u0442 \u043d\u0430 \u043f\u044f\u0442\u044c, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043b \u0442\u0440\u0438, \u043e\u0441\u0442\u0430\u043b\u043e\u0441\u044c \u0434\u0432\u0430\u2026 \u0430 \u041e\u043b\u0435\u0433\u0443 \u044f \u043d\u0435 \u043d\u0430\u043f\u0438\u0441\u0430\u043b\u0430, \u0447\u0442\u043e \u0443 \u043d\u0430\u0441 \u0437\u0430\u0432\u0442\u0440\u0430 \u0432 18:00\u2026\u00bb\u0420\u0430\u0437 \u0432 \u043c\u0435\u0441\u044f\u0446 \u044d\u0442\u043e\u0442 \u0443\u0447\u0451\u0442 \u043b\u043e\u043c\u0430\u043b\u0441\u044f. \u0422\u043e \u0443\u0447\u0435\u043d\u0438\u043a \u00ab\u043d\u0435 \u043f\u043e\u043c\u043d\u0438\u043b\u00bb, \u0447\u0442\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b \u0442\u043e\u043b\u044c\u043a\u043e \u0437\u0430 \u0434\u0432\u0430 \u0437\u0430\u043d\u044f\u0442\u0438\u044f. \u0422\u043e \u043e\u043d\u0430 \u043f\u0440\u0435\u0434\u043b\u0430\u0433\u0430\u043b\u0430 \u043f\u0435\u0440\u0435\u043d\u043e\u0441 \u2014 \u0438 \u0437\u0430\u0431\u044b\u0432\u0430\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c, \u0447\u0442\u043e \u043e\u043d\u043e \u043f\u0435\u0440\u0435\u043d\u0435\u0441\u0435\u043d\u043e. \u0422\u043e \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0435 \u0443\u0447\u0435\u043d\u0438\u043a\u0443 \u043d\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u043b\u043e\u0441\u044c \u0432\u043e\u0432\u0440\u0435\u043c\u044f, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u0432 \u044d\u0442\u043e \u0432\u0440\u0435\u043c\u044f \u0431\u044b\u043b \u0437\u0430\u043d\u044f\u0442, \u0438 \u043e\u043d \u043e\u043f\u0430\u0437\u0434\u044b\u0432\u0430\u043b \u043d\u0430 15 \u043c\u0438\u043d\u0443\u0442.\u042f \u0431\u044d\u043a\u0435\u043d\u0434-\u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a. \u041f\u043e\u0441\u043b\u0435 TripTrack \u0441\u0442\u0430\u043b\u043e \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e: \u0430 \u0435\u0441\u043b\u0438 \u0432\u0437\u044f\u0442\u044c \u0437\u0430\u0434\u0430\u0447\u0443, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u0443 \u043c\u0435\u043d\u044f \u0435\u0441\u0442\u044c \u043f\u043e\u043b\u043d\u044b\u0439 domain-expertise \u2014 \u0447\u0442\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u0441\u044f? \u041f\u043e\u043b\u0443\u0447\u0438\u043b\u0441\u044f TeachTrack: \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435, \u0443\u0447\u0451\u0442 \u043e\u043f\u043b\u0430\u0442 \u0438 \u043f\u0430\u043a\u0435\u0442\u043e\u0432 \u0437\u0430\u043d\u044f\u0442\u0438\u0439, \u0430\u0432\u0442\u043e-\u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043b\u043a\u0438 \u0443\u0447\u0435\u043d\u0438\u043a\u0430\u043c \u0432 Telegram \u043f\u0435\u0440\u0435\u0434 \u0443\u0440\u043e\u043a\u043e\u043c. \u0421\u0435\u0439\u0447\u0430\u0441 \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0439 \u0431\u0435\u0442\u0435.\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u0434\u043b\u044f \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u0447\u0451\u0442\u0447\u0438\u043a\u0438 \u0443\u0447\u0435\u043d\u0438\u043a\u043e\u0432\/\u0433\u0440\u0443\u043f\u043f, \u0431\u043b\u0438\u0436\u0430\u0439\u0448\u0438\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f, \u0431\u044b\u0441\u0442\u0440\u044b\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f. \u0421\u044e\u0434\u0430 \u043f\u043e\u043f\u0430\u0434\u0430\u0435\u0448\u044c \u043f\u043e\u0441\u043b\u0435 \u043b\u043e\u0433\u0438\u043d\u0430\u0421\u0442\u0435\u043a\u0411\u0435\u0437 \u0441\u044e\u0440\u043f\u0440\u0438\u0437\u043e\u0432:Backend: NestJS + TypeORM + PostgreSQL. \u041e\u0434\u0438\u043d \u0441\u0435\u0440\u0432\u0435\u0440, \u043e\u0434\u0438\u043d \u0438\u043d\u0441\u0442\u0430\u043d\u0441, \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u043e\u043c \u043f\u0440\u0438 \u0441\u0442\u0430\u0440\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 DB_MIGRATE=true.Frontend: React + Vite + React Router 7, Tailwind, react-query \u0434\u043b\u044f cache, sonner \u0434\u043b\u044f \u0442\u043e\u0441\u0442\u043e\u0432.Telegram: node-telegram-bot-api \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 long polling (webhook \u043d\u0435 \u043d\u0443\u0436\u0435\u043d \u2014 \u043d\u0430 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e\u043c VPS \u0443 \u043c\u0435\u043d\u044f \u043d\u0435\u0442 \u0441\u0442\u0430\u0442\u0438\u0447\u043d\u043e\u0433\u043e IP).\u0414\u0435\u043f\u043b\u043e\u0439: GitLab CI \u2192 SSH \u0432 VPS \u2192 docker compose pull &amp;&amp; up -d.\u0421\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0438\u043d\u0444\u0440\u044b: ~350\u20bd\/\u043c\u0435\u0441 VPS + 0\u20bd Telegram + 0\u20bd \u0434\u043e\u043c\u0435\u043d (\u0431\u044b\u043b \u043a\u0443\u043f\u043b\u0435\u043d \u0437\u0430\u0440\u0430\u043d\u0435\u0435).\u041d\u0438\u043a\u0430\u043a\u043e\u0433\u043e Kubernetes, managed-\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0432, \u043e\u0447\u0435\u0440\u0435\u0434\u0435\u0439-\u0431\u0440\u043e\u043a\u0435\u0440\u043e\u0432. \u041e\u0434\u0438\u043d \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 NestJS, \u043e\u0434\u0438\u043d \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 Postgres, \u043e\u0434\u0438\u043d Nginx. \u0415\u0441\u043b\u0438 \u0447\u0442\u043e-\u0442\u043e \u043b\u043e\u043c\u0430\u0435\u0442\u0441\u044f \u2014 SSH \u0432 VPS \u0438 docker compose logs. \u041f\u0440\u0438\u043d\u0446\u0438\u043f \u0442\u0430\u043a\u043e\u0439: \u043f\u043e\u043a\u0430 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043c\u0435\u043d\u044c\u0448\u0435 \u0442\u044b\u0441\u044f\u0447\u0438, \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0441\u043b\u043e\u0438 \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u044b \u2014 \u044d\u0442\u043e \u043e\u0432\u0435\u0440\u0438\u043d\u0436\u0438\u043d\u0438\u0440\u0438\u043d\u0433, \u0437\u0430\u043c\u0435\u0434\u043b\u044f\u044e\u0449\u0438\u0439 \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u0438.\u0414\u0430\u043b\u044c\u0448\u0435 \u2014 \u043f\u0440\u043e \u0442\u043e, \u0447\u0442\u043e \u0432 \u044d\u0442\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e \u0441 \u0438\u043d\u0436\u0435\u043d\u0435\u0440\u043d\u043e\u0439 \u0442\u043e\u0447\u043a\u0438 \u0437\u0440\u0435\u043d\u0438\u044f.Telegram-\u0431\u043e\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439\u042d\u0442\u043e \u0441\u0435\u0440\u0434\u0446\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430. \u041f\u0440\u0435\u043f\u043e\u0434 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442 \u0443\u0447\u0435\u043d\u0438\u043a\u0430 \u043a \u0431\u043e\u0442\u0443 \u0447\u0435\u0440\u0435\u0437 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443, \u043f\u043e\u0441\u043b\u0435 \u044d\u0442\u043e\u0433\u043e \u0437\u0430 \u0447\u0430\u0441 (\u0438\u043b\u0438 \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u043b) \u0434\u043e \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442 \u0432 \u0441\u0432\u043e\u0439 Telegram \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0432\u0438\u0434\u0430 \u00ab\u041d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0435: \u0437\u0430\u043d\u044f\u0442\u0438\u0435 \u0441\u0435\u0433\u043e\u0434\u043d\u044f \u0432 18:00 \u2014 19:00, \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0438\u0439\u00bb.\u0417\u0432\u0443\u0447\u0438\u0442 \u043f\u0440\u043e\u0441\u0442\u043e. \u041d\u0430 \u0434\u0435\u043b\u0435 \u0437\u0434\u0435\u0441\u044c \u0441\u0438\u0434\u0438\u0442 \u0448\u0442\u0443\u043a \u043f\u044f\u0442\u044c \u0440\u0430\u0437\u043d\u044b\u0445 \u043a\u043b\u0430\u0441\u0441\u043e\u0432 \u043f\u0440\u043e\u0431\u043b\u0435\u043c, \u0438 \u044f \u0440\u0430\u0437\u0431\u0435\u0440\u0443 \u043a\u0430\u0436\u0434\u0443\u044e.1. Single-use invite-\u0442\u043e\u043a\u0435\u043d\u044b \u0431\u0435\u0437 enumeration\u0422\u043e\u0447\u043a\u0430 \u0432\u0445\u043e\u0434\u0430 \u0432 invite-flow: \u043f\u0440\u0435\u043f\u043e\u0434 \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443 \u0443\u0447\u0435\u043d\u0438\u043a\u0430, \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 &#171;\u041f\u0440\u0438\u0433\u043b\u0430\u0441\u0438\u0442\u044c \u0432 Telegram&#187;, \u0431\u044d\u043a \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443 \u2014 \u043e\u043d\u0430 \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442\u0441\u044f \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0438 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0438\u0433\u0434\u0435 \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0432\u0438\u0434\u0435 \u043d\u0435 \u043b\u0435\u0436\u0438\u0442\u041a\u0430\u0436\u0434\u044b\u0439 \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443:https:\/\/t.me\/teachtrackbot?start=&lt;token&gt;&lt;token&gt; \u2014 32 \u0431\u0430\u0439\u0442\u0430 URL-safe base64. \u0412 \u0411\u0414 \u0445\u0440\u0430\u043d\u0438\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e SHA-256 \u043e\u0442 \u043d\u0435\u0433\u043e; plaintext \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f \u0444\u0440\u043e\u043d\u0442\u0443 \u043e\u0434\u0438\u043d \u0440\u0430\u0437 \u0432 \u043e\u0442\u0432\u0435\u0442 \u043d\u0430 createInvite \u0438 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0438\u0433\u0434\u0435 \u043d\u0435 \u043e\u0441\u0442\u0430\u0451\u0442\u0441\u044f (\u0434\u0430\u0436\u0435 \u0432 \u043b\u043e\u0433\u0430\u0445).const rawToken = randomBytes(32).toString(&#8216;base64url&#8217;);const tokenHash = createHash(&#8216;sha256&#8217;).update(rawToken).digest(&#8216;hex&#8217;);const expiresAt = new Date(Date.now() + INVITE_TTL_MS); \/\/ 14 \u0434\u043d\u0435\u0439 \u0417\u0430\u0447\u0435\u043c \u0445\u044d\u0448 \u0432 \u0411\u0414, \u0430 \u043d\u0435 plaintext? \u0415\u0441\u043b\u0438 \u0437\u043b\u043e\u0443\u043c\u044b\u0448\u043b\u0435\u043d\u043d\u0438\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442 read-\u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0442\u0430\u0431\u043b\u0438\u0446\u0435 (\u0440\u0435\u0437\u0435\u0440\u0432\u043d\u0430\u044f \u043a\u043e\u043f\u0438\u044f, \u0434\u0430\u043c\u043f, SQL-\u0438\u043d\u044a\u0435\u043a\u0446\u0438\u044f \u0432 \u043a\u0430\u043a\u043e\u043c-\u0442\u043e \u0431\u0443\u0434\u0443\u0449\u0435\u043c \u043a\u043e\u0434\u0435) \u2014 \u043e\u043d \u043d\u0435 \u0441\u043c\u043e\u0436\u0435\u0442 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0440\u0430\u0431\u043e\u0447\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443. \u041f\u0440\u0438\u043d\u0446\u0438\u043f \u0442\u043e\u0442 \u0436\u0435, \u0447\u0442\u043e \u0441 \u043f\u0430\u0440\u043e\u043b\u044f\u043c\u0438: \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 hash(input) === stored_hash, \u0430 \u043d\u0435 \u0447\u0435\u0440\u0435\u0437 input === stored_plain.\u0414\u0430\u043b\u044c\u0448\u0435 \u2014 partial unique index, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043d\u0435 \u0434\u0430\u0451\u0442 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u043c \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u043c \u0438\u043d\u0432\u0430\u0439\u0442\u0430\u043c \u043d\u0430 \u043e\u0434\u043d\u043e\u0433\u043e \u0443\u0447\u0435\u043d\u0438\u043a\u0430 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e:CREATE UNIQUE INDEX student_telegram_invite_activeON student_telegram_invite (student_id)WHERE consumed_at IS NULL AND revoked_at IS NULL;\u042d\u0442\u043e \u0434\u0430\u0451\u0442 \u0441\u0438\u043b\u044c\u043d\u0443\u044e \u0438\u043d\u0432\u0430\u0440\u0438\u0430\u043d\u0442 \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u0430 \u043d\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f. \u0415\u0441\u043b\u0438 \u043f\u0440\u0435\u043f\u043e\u0434 \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 \u00ab\u0432\u044b\u0434\u0430\u0442\u044c \u043d\u043e\u0432\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443\u00bb, \u043a\u043e\u0433\u0434\u0430 \u0441\u0442\u0430\u0440\u0430\u044f \u0435\u0449\u0451 \u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u2014 \u043c\u044b \u0441\u043d\u0430\u0447\u0430\u043b\u0430 revoke, \u043f\u043e\u0442\u043e\u043c insert, \u0432 \u043e\u0434\u043d\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438:await this.em.transaction(async (trx) =&gt; {const inviteRepo = trx.getRepository(StudentTelegramInviteEntity);\/\/ Revoke any currently-active invite for this student so the\/\/ partial unique index doesn&#8217;t reject the insert.await inviteRepo    .createQueryBuilder()    .update()    .set({ revokedAt: () =&gt; &#8216;now()&#8217; })    .where(&#8216;student_id = :studentId&#8217;, { studentId })    .andWhere(&#8216;consumed_at IS NULL&#8217;)    .andWhere(&#8216;revoked_at IS NULL&#8217;)    .execute();await inviteRepo.save(  new StudentTelegramInviteEntity({        studentId,            teacherId,            tokenHash,            expiresAt,    }),  );});\u0421\u0430\u043c\u043e\u0435 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e\u0435 \u2014 \u043d\u0430 \u0441\u0442\u043e\u0440\u043e\u043d\u0435 consume(). \u041a\u043e\u0433\u0434\u0430 \u0443\u0447\u0435\u043d\u0438\u043a \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 \u0432 Telegram \u043a\u043d\u043e\u043f\u043a\u0443 Start, \u0431\u043e\u0442 \u0432\u044b\u0437\u044b\u0432\u0430\u0435\u0442 \u044d\u0442\u043e\u0442 \u043c\u0435\u0442\u043e\u0434. \u0422\u0443\u0442 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u0430 \u0433\u043e\u043d\u043a\u0430: \u0434\u0432\u043e\u0435 \u0440\u043e\u0434\u0438\u0442\u0435\u043b\u0435\u0439 \u0441 \u043e\u0434\u043d\u043e\u0439 \u0441\u0441\u044b\u043b\u043a\u0438 \u043a\u043b\u0438\u043a\u0430\u044e\u0442 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e. \u0411\u0435\u0437 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u0430 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u044e\u0442 consumed_at IS NULL, \u043e\u0431\u0430 \u0441\u043e\u0437\u0434\u0430\u0434\u0443\u0442 binding, \u043e\u0431\u0430 \u0443\u0432\u0438\u0434\u044f\u0442 \u0443\u0441\u043f\u0435\u0445 \u2014 \u043d\u043e binding-row \u0431\u0443\u0434\u0435\u0442 \u0434\u0432\u0430, \u0441 \u0440\u0430\u0437\u043d\u044b\u043c\u0438 chat_id.\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u2014 pessimistic_write (SELECT &#8230; FOR UPDATE):public async consume(  rawToken: string,    chatId: string,    from: { username?: string; firstName?: string; lastName?: string },): Promise&lt;ConsumeResult | null&gt; {  const tokenHash = createHash(&#8216;sha256&#8217;).update(rawToken).digest(&#8216;hex&#8217;);  return this.em.transaction(async (trx) =&gt; {    const inviteRepo = trx.getRepository(StudentTelegramInviteEntity);    \/\/ Pessimistic lock so concurrent \/start attempts don&#8217;t double-        \/\/ consume the same invite. &#171;FOR UPDATE&#187; blocks the second txn        \/\/ until the first commits; second then sees consumed_at and bails.        const invite = await inviteRepo      .createQueryBuilder(&#8216;i&#8217;)      .where(&#8216;i.token_hash = :tokenHash&#8217;, { tokenHash })      .setLock(&#8216;pessimistic_write&#8217;)      .getOne();        if (!invite) return null;        if (invite.consumedAt) return null;        if (invite.revokedAt) return null;        if (invite.expiresAt.getTime() &lt; Date.now()) return null;        \/\/ &#8230; \u0441\u043e\u0437\u0434\u0430\u0451\u043c binding, \u043f\u043e\u043c\u0435\u0447\u0430\u0435\u043c consumed_at = now() &#8230;  });}\u041f\u0435\u0440\u0432\u0430\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442 \u0441\u0442\u0440\u043e\u043a\u0443. \u0412\u0442\u043e\u0440\u0430\u044f \u0436\u0434\u0451\u0442 \u043a\u043e\u043c\u043c\u0438\u0442\u0430 \u043f\u0435\u0440\u0432\u043e\u0439, \u043f\u043e\u0441\u043b\u0435 \u0447\u0435\u0433\u043e \u0447\u0438\u0442\u0430\u0435\u0442 consumed_at != null \u0438 \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u0442 null. \u0413\u043e\u043d\u043a\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u0430 \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u0431\u0435\u0437 in-memory mutex&#8217;\u043e\u0432.\u0412\u043e\u0437\u0432\u0440\u0430\u0442 null \u0434\u043b\u044f \u0432\u0441\u0435\u0445 \u0441\u043b\u0443\u0447\u0430\u0435\u0432 (expired, revoked, unknown, consumed) \u2014 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u044b\u0439 \u0432\u044b\u0431\u043e\u0440. \u0411\u043e\u0442 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u044b\u043c \u00ab\u0421\u0441\u044b\u043b\u043a\u0430 \u0443\u0441\u0442\u0430\u0440\u0435\u043b\u0430 \u0438\u043b\u0438 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0430\u00bb. \u042d\u0442\u043e \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043e\u0440\u0430\u043a\u0443\u043b: \u0430\u0442\u0430\u043a\u0443\u044e\u0449\u0438\u0439, \u043f\u0435\u0440\u0435\u0431\u0438\u0440\u0430\u044e\u0449\u0438\u0439 \u0442\u043e\u043a\u0435\u043d\u044b, \u043d\u0435 \u0434\u043e\u043b\u0436\u0435\u043d \u043f\u043e \u0440\u0430\u0437\u043d\u043e\u043c\u0443 \u043e\u0442\u0432\u0435\u0442\u0443 \u043f\u043e\u043d\u044f\u0442\u044c, \u043a\u0430\u043a\u0430\u044f \u0441\u0441\u044b\u043b\u043a\u0430 \u0431\u044b\u043b\u0430 \u0432\u0430\u043b\u0438\u0434\u043d\u0430 \u043a\u043e\u0433\u0434\u0430-\u0442\u043e, \u0430 \u043a\u0430\u043a\u0430\u044f \u043d\u0438\u043a\u043e\u0433\u0434\u0430 \u043d\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u043e\u0432\u0430\u043b\u0430.2. Anti-enumeration \u043d\u0430 bare \/start\u0421\u0432\u044f\u0437\u0430\u043d\u043d\u0430\u044f \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430: Telegram \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043a\u043e\u043c\u0443 \u0443\u0433\u043e\u0434\u043d\u043e \u043d\u0430\u0447\u0430\u0442\u044c \u0447\u0430\u0442 \u0441 \u0431\u043e\u0442\u043e\u043c \u0431\u0435\u0437 \u0442\u043e\u043a\u0435\u043d\u0430 \u2014 \u043f\u0440\u043e\u0441\u0442\u043e \/start. \u041f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u044d\u0442\u043e\u0433\u043e \u0441\u043b\u0443\u0447\u0430\u044f \u043c\u043e\u0436\u043d\u043e \u0443\u0433\u0430\u0434\u044b\u0432\u0430\u0442\u044c \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0435 chat_id&#8217;\u044b (\u0435\u0441\u043b\u0438 \u0431\u043e\u0442 \u043f\u043e-\u0440\u0430\u0437\u043d\u043e\u043c\u0443 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u00ab\u043d\u0435\u0437\u043d\u0430\u043a\u043e\u043c\u0446\u0443\u00bb \u0438 \u00ab\u0443\u0436\u0435 \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e\u00bb \u2014 \u044d\u0442\u043e side-channel).\u0412 TeachTrack \u0431\u043e\u0442 \u043e\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u044b\u043c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043c \u0438 \u0432 \u0442\u043e\u043c, \u0438 \u0432 \u0434\u0440\u0443\u0433\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435, \u043f\u043b\u044e\u0441 \u0435\u0441\u0442\u044c rate-limit:if (!rawToken) {  \/\/ Bare \/start \u2014 first-time visitor \/ returning user \/ attacker probing    \/\/ chatIds. Reply with one generic message either way: distinguishing    \/\/ &#171;already bound&#187; from &#171;new&#187; was an enumeration oracle. Non-bound chats    \/\/ bump the fail counter so bare \/start can&#8217;t be used to keep a    \/\/ cool-down alive while guessing with another path.  const existing = await this.em        .getRepository(StudentTelegramBindingEntity)        .findOne({ where: { chatId }, select: [&#8216;id&#8217;] });    if (!existing) {      if (!this.noteStartFailure(msg.chat.id)) {          await this.bot.sendMessage(msg.chat.id, &#8216;\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 \u0447\u0430\u0441.&#8217;);            return;        }    }    await this.bot.sendMessage(      msg.chat.id,        &#8216;\u0427\u0442\u043e\u0431\u044b \u043f\u043e\u043b\u0443\u0447\u0430\u0442\u044c \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0443 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e \u0441\u0441\u044b\u043b\u043a\u0443 \u0438 \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u043d\u0435\u0439.\\n\/help \u2014 \u0441\u043f\u0438\u0441\u043e\u043a \u043a\u043e\u043c\u0430\u043d\u0434.&#8217;,        );    return;}noteStartFailure \u2014 \u043f\u0440\u043e\u0441\u0442\u043e\u0439 in-memory bucket \u0441 TTL: \u0447\u0430\u0442, \u043d\u0435 \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0439 \u043d\u0438 \u043a \u043a\u043e\u043c\u0443, \u043c\u043e\u0436\u0435\u0442 \u0441\u0434\u0435\u043b\u0430\u0442\u044c N \u0431\u0435\u0441\u043f\u043e\u043b\u0435\u0437\u043d\u044b\u0445 \/start \u0432 \u0447\u0430\u0441, \u0434\u0430\u043b\u044c\u0448\u0435 \u043e\u0442\u0432\u0435\u0442 \u0437\u0430\u043c\u0435\u043d\u044f\u0435\u0442\u0441\u044f \u043d\u0430 \u00ab\u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a\u00bb. \u041f\u0440\u0438\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0435 \u0447\u0430\u0442\u044b \u043d\u0435 \u0441\u0447\u0438\u0442\u0430\u044e\u0442\u0441\u044f \u2014 \u0438\u043d\u0430\u0447\u0435 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0438\u0439 \u0443\u0447\u0435\u043d\u0438\u043a \u043f\u043e\u0441\u043b\u0435 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438 \u0431\u043e\u0442\u0430 \u0438 \u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0431\u0430\u043d.3. Atomic claim \u0432 scheduler\u0422\u043e\u0442 \u0436\u0435 outbox-\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c, \u0447\u0442\u043e \u043e\u0431\u0441\u043b\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u044f, \u0434\u0435\u043b\u0430\u0435\u0442 \u0438 \u0440\u0443\u0447\u043d\u044b\u0435 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438 \u043f\u0440\u0435\u043f\u043e\u0434\u0430\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u0441\u043f\u0440\u0430\u0432\u0430 preview \u0438\u043c\u0435\u043d\u043d\u043e \u0442\u043e\u0433\u043e \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0443\u0439\u0434\u0451\u0442 \u0432 Telegram\u0421\u0430\u043c\u0430\u044f \u043d\u0435\u0442\u0440\u0438\u0432\u0438\u0430\u043b\u044c\u043d\u0430\u044f \u0447\u0430\u0441\u0442\u044c \u2014 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043d\u0438\u0439. Cron-\u0442\u0438\u043a \u043a\u0430\u0436\u0434\u0443\u044e \u043c\u0438\u043d\u0443\u0442\u0443:@Cron(CronExpression.EVERY_MINUTE)public async sendUpcomingReminders(): Promise&lt;void&gt; {  if (!this.telegram.isEnabled()) return;    if (this.isRunning) {      this.logger.warn({ event: &#8216;reminder.tick_skipped&#8217;, reason: &#8216;previous tick still running&#8217; });      return;    }    this.isRunning = true;  try {    await this.runTick();  } finally {    this.isRunning = false;    }}isRunning \u2014 in-memory \u043c\u044c\u044e\u0442\u0435\u043a\u0441. NestJS Schedule \u043d\u0435 \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442 cron, \u0438 \u0435\u0441\u043b\u0438 \u0442\u0438\u043a \u0434\u043b\u0438\u043d\u043d\u0435\u0435 60 \u0441\u0435\u043a\u0443\u043d\u0434 (\u0447\u0442\u043e \u0431\u044b\u0432\u0430\u0435\u0442: 200 \u0443\u0440\u043e\u043a\u043e\u0432 \u0441 relations \u0447\u0435\u0440\u0435\u0437 TypeORM = \u0434\u0435\u0441\u044f\u0442\u043a\u0438 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432), \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 \u0442\u0438\u043a \u0441\u0442\u0430\u0440\u0442\u0443\u0435\u0442 \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e. \u0411\u0435\u0437 \u0444\u043b\u0430\u0433\u0430 \u0434\u0432\u0435 \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u0438 \u0447\u0438\u0442\u0430\u044e\u0442 hasNotificationSent=false \u0438 \u0441\u0442\u0430\u0432\u044f\u0442 \u0432 \u043e\u0447\u0435\u0440\u0435\u0434\u044c \u043e\u0434\u043d\u0443 \u0438 \u0442\u0443 \u0436\u0435 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u043b\u043a\u0443 \u0434\u0432\u0430\u0436\u0434\u044b. \u0414\u043b\u044f multi-instance \u0434\u0435\u043f\u043b\u043e\u044f \u043d\u0443\u0436\u0435\u043d \u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b\u0451\u043d\u043d\u044b\u0439 lock (Redis), \u043d\u043e \u043f\u043e\u043a\u0430 \u0434\u0435\u043f\u043b\u043e\u0439 single-instance \u2014 in-memory \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e.\u0412\u043d\u0443\u0442\u0440\u0438 \u0442\u0438\u043a\u0430 \u2014 \u043e\u0441\u043d\u043e\u0432\u043d\u0430\u044f \u043b\u043e\u0433\u0438\u043a\u0430:const lessons = await this.em.getRepository(LessonEntity).find({  where: {      status: LessonStatus.Scheduled,        hasNotificationSent: &#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-478842","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/478842","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=478842"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/478842\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=478842"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=478842"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=478842"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}