From c5784d9b627701cb409100f0616f1b95ddc5809b Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 8 Oct 2026 00:09:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D0=B0=D0=B2=D1=82=D0=BE-=D1=81?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D1=81=20=D1=8D=D0=BC=D1=83=D0=BB=D1=8F=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B2=20=D0=9D=D0=A0=D0=94/=D0=9C=D0=9D?= =?UTF-8?q?=D0=9F=20=D0=BF=D1=80=D0=B8=20=D0=BE=D0=BA=D0=BE=D0=BD=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B8=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если оператор временно получил dev-режим, включил эмулятор и забыл — при окончании dev-режима флаги LK.EmulateNSD и QuikMNP.Emulate сбрасываются насовсем (а не просто перестают действовать): при следующей выдаче dev-режима эмуляторы молча не оживут. Проверка disableEmulatorsIfDevOff вызывается при старте сервера (после продления лицензии) и на каждом проходе цикла продления — ловит истечение dev и по времени, без продления. При сбросе — запись в ленту событий: «Эмуляторы отключены: режим разработки закончился» с перечнем выключенного; если был включён эмулятор МНП при WritesEnabled=false — предупреждение, что цикл МНП остановлен и для боевой работы нужно включить запись. --- REPORT.md | 8 ++- internal/lkgateway/emulators.go | 50 +++++++++++++++ internal/lkgateway/emulators_test.go | 95 ++++++++++++++++++++++++++++ internal/lkgateway/licenseclient.go | 1 + internal/lkgateway/server.go | 1 + 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/REPORT.md b/REPORT.md index 4271c58..52c7c05 100644 --- a/REPORT.md +++ b/REPORT.md @@ -6,7 +6,7 @@ --- -## Обновление 07.10.2026: dev-страница «Эмуляторы» +## Обновление 07.10.2026 (доп. 08.10.2026): dev-страница «Эмуляторы» В режиме разработки (лицензия с `dev_until`) появилась страница `/admin/emulators` (пункт меню «Эмуляторы», плитки на дашборде) — переключатели и настройки эмуляции: @@ -23,7 +23,11 @@ повтор на следующем проходе. Баннер «Режим эмуляции» теперь честен и при настроенном ИШ: при ручном включении -эмуляторов причина указывается явно. +эмуляторов причина указывается явно. При окончании режима разработки (по времени +`dev_until` или снятии на сервере лицензий) флаги эмуляции автоматически +сбрасываются из настроек (проверка при старте и в цикле продления лицензии, +новость «Эмуляторы отключены») — при следующей выдаче dev-режима эмуляторы +молча не оживут. --- diff --git a/internal/lkgateway/emulators.go b/internal/lkgateway/emulators.go index 6466b8f..4210a91 100644 --- a/internal/lkgateway/emulators.go +++ b/internal/lkgateway/emulators.go @@ -7,9 +7,11 @@ package lkgateway import ( "fmt" + "log" "net/http" "strconv" "strings" + "time" ) // emuRejectOption — вариант кода отказа для эмулятора НРД (value — «07», подпись — с названием). @@ -159,3 +161,51 @@ func emuOutcomeRu(outcome string) string { } return "подтверждение" } + +// disableEmulatorsIfDevOff — безопасность: эмуляторы НРД/МНП действуют только в режиме +// разработки; когда он заканчивается (по времени dev_until или снят на сервере лицензий), +// флаги сбрасываются из настроек — иначе при следующей выдаче dev-режима эмуляция +// молча оживёт. Вызывается при старте и в цикле продления лицензии. +func disableEmulatorsIfDevOff(rc *RuntimeConfig) { + if rc == nil || devMode(rc) { + return + } + s := rc.Snapshot() + if !s.LK.EmulateNSD && !s.Quik.Emulate { + return + } + var off []string + if s.LK.EmulateNSD { + lk := s.LK + lk.EmulateNSD = false + if err := rc.UpdateLK(lk); err != nil { + log.Printf("lk-gateway: авто-сброс эмулятора НРД: %v", err) + } else { + off = append(off, "эмулятор НРД (заявки ЛК)") + } + } + if s.Quik.Emulate { + q := s.Quik + q.Emulate = false + if err := rc.UpdateQuik(q); err != nil { + log.Printf("lk-gateway: авто-сброс эмулятора МНП: %v", err) + } else { + off = append(off, "эмулятор МНП QUIK") + } + } + if len(off) == 0 { + return + } + log.Printf("lk-gateway: режим разработки закончился — эмуляторы отключены: %s", strings.Join(off, ", ")) + body := "Выключены: " + strings.Join(off, ", ") + ". Флаги эмуляции сброшены из настроек: при следующем включении режима разработки эмуляторы не включатся сами — их нужно будет включить заново на странице «Эмуляторы»." + if s.Quik.Emulate && !s.Quik.WritesEnabled { + body += " Цикл M2M в МНП остановлен: записи в МНП не разрешены — для боевой работы выберите «Чтение и запись» в настройках QUIK." + } + _ = rc.AddNews(NewsItem{ + ID: "sys-" + strconv.FormatInt(time.Now().Unix()/600, 10) + "-emulators-off", + At: time.Now(), + Kind: "system", + Title: "Эмуляторы отключены: режим разработки закончился", + Body: body, + }) +} diff --git a/internal/lkgateway/emulators_test.go b/internal/lkgateway/emulators_test.go index 691714f..5e0119d 100644 --- a/internal/lkgateway/emulators_test.go +++ b/internal/lkgateway/emulators_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" ) // Настройки эмуляторов: валидация и обратная совместимость (старый setup.json без полей = дефолты). @@ -206,3 +208,96 @@ func TestMNPEmuStepDelayCtx(t *testing.T) { t.Fatal("задержка не прервалась по контексту") } } + +// rcWithExpiredDev — действующая лицензия с истёкшим dev_until: devMode уже false. +func rcWithExpiredDev(t *testing.T) *RuntimeConfig { + t.Helper() + priv := testLicenseKey(t) + rc := testRC(t) + past := time.Now().UTC().Add(-time.Hour) + tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-dev", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro, + IssuedAt: past.Add(-24 * time.Hour), ExpiresAt: time.Now().UTC().Add(60 * 24 * time.Hour), DevUntil: &past}) + if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil { + t.Fatal(err) + } + if devMode(rc) { + t.Fatal("devMode с истёкшим dev_until должен быть выключен") + } + return rc +} + +// dev-режим закончился: включённые эмуляторы сбрасываются из настроек, про это — новость. +func TestDisableEmulatorsIfDevOff(t *testing.T) { + rc := rcWithExpiredDev(t) + s := rc.Snapshot() + s.LK.EmulateNSD = true + if err := rc.UpdateLK(s.LK); err != nil { + t.Fatal(err) + } + s.Quik.Emulate = true + if err := rc.UpdateQuik(s.Quik); err != nil { + t.Fatal(err) + } + disableEmulatorsIfDevOff(rc) + if s := rc.Snapshot(); s.LK.EmulateNSD || s.Quik.Emulate { + t.Fatalf("флаги не сброшены: EmulateNSD=%v Quik.Emulate=%v", s.LK.EmulateNSD, s.Quik.Emulate) + } + // setup.json на диске тоже без флагов — при следующем dev-режиме эмуляторы не оживут. + rc2, err := NewRuntimeConfig(rc.path) + if err != nil { + t.Fatal(err) + } + if s := rc2.Snapshot(); s.LK.EmulateNSD || s.Quik.Emulate { + t.Fatal("в setup.json флаги остались включёнными") + } + found := false + for _, n := range rc.Snapshot().News.Items { + if strings.Contains(n.Title, "Эмуляторы отключены") { + found = true + if !strings.Contains(n.Body, "эмулятор НРД") || !strings.Contains(n.Body, "эмулятор МНП") { + t.Errorf("в новости не перечислено, что выключено: %s", n.Body) + } + // WritesEnabled=false — новость предупреждает об остановленном цикле МНП. + if !strings.Contains(n.Body, "Цикл M2M в МНП остановлен") { + t.Errorf("нет предупреждения об остановленном цикле МНП: %s", n.Body) + } + } + } + if !found { + t.Error("новость об отключении эмуляторов не добавлена") + } +} + +// dev-режим действует: авто-сброс ничего не трогает. +func TestDisableEmulatorsIfDevOffDevOn(t *testing.T) { + devModeForced = true + defer func() { devModeForced = false }() + rc := testRC(t) + s := rc.Snapshot() + s.LK.EmulateNSD = true + s.Quik.Emulate = true + if err := rc.UpdateLK(s.LK); err != nil { + t.Fatal(err) + } + if err := rc.UpdateQuik(s.Quik); err != nil { + t.Fatal(err) + } + disableEmulatorsIfDevOff(rc) + if s := rc.Snapshot(); !s.LK.EmulateNSD || !s.Quik.Emulate { + t.Fatal("при действующем dev-режиме флаги не должны сбрасываться") + } +} + +// dev выключен, но флаги уже сняты: no-op — настройки не переписываются, новости нет. +func TestDisableEmulatorsIfDevOffNoOp(t *testing.T) { + rc := rcWithExpiredDev(t) + before := rc.Snapshot().UpdatedAt + disableEmulatorsIfDevOff(rc) + after := rc.Snapshot() + if !after.UpdatedAt.Equal(before) { + t.Error("no-op не должен переписывать setup.json") + } + if len(after.News.Items) != 0 { + t.Error("no-op не должен добавлять новость") + } +} diff --git a/internal/lkgateway/licenseclient.go b/internal/lkgateway/licenseclient.go index cc85e91..0c9793f 100644 --- a/internal/lkgateway/licenseclient.go +++ b/internal/lkgateway/licenseclient.go @@ -265,6 +265,7 @@ func (s *Server) licenseLoop(ctx context.Context) { } wait = 5 * time.Minute licenseExpiryWarn(s.rc, time.Now()) + disableEmulatorsIfDevOff(s.rc) // dev-режим может истечь и просто по времени — эмуляторы гасим if !licenseNeedsRenew(s.rc) { continue } diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 408c3b2..2d2af37 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -292,6 +292,7 @@ func (s *Server) Run(ctx context.Context) error { } cancel() } + disableEmulatorsIfDevOff(s.rc) // dev-режим закончился, пока сервер стоял — эмуляторы гасим go s.superviseHealth(ctx) // Данные модуля личного кабинета — схема lk в PostgreSQL bj.