diff --git a/internal/lkgateway/licenseclient.go b/internal/lkgateway/licenseclient.go index 21c4686..6285caa 100644 --- a/internal/lkgateway/licenseclient.go +++ b/internal/lkgateway/licenseclient.go @@ -181,8 +181,8 @@ func licenseNeedsRenew(rc *RuntimeConfig) bool { return time.Since(s.LastSync) >= 23*time.Hour } -// licenseLoop — раз в час проверяет, пора ли продлить ключ (продление — раз в сутки, -// при сбое — повтор через час). +// licenseLoop — раз в час предупреждает о скором окончании лицензии и проверяет, пора ли продлить +// ключ (продление — раз в сутки, при сбое — повтор через час). func (s *Server) licenseLoop(ctx context.Context) { ensureInstallID(s.rc) wait := 2 * time.Minute @@ -193,6 +193,7 @@ func (s *Server) licenseLoop(ctx context.Context) { case <-time.After(wait): } wait = time.Hour + licenseExpiryWarn(s.rc, time.Now()) if !licenseNeedsRenew(s.rc) { continue } diff --git a/internal/lkgateway/licensewarn.go b/internal/lkgateway/licensewarn.go new file mode 100644 index 0000000..1823b6c --- /dev/null +++ b/internal/lkgateway/licensewarn.go @@ -0,0 +1,198 @@ +package lkgateway + +// Предупреждения лицензии. +// +// 1. Заранее об окончании: за 30, 14, 7 и 1 день до окончания договора (или ключа, если договора +// нет или сервер лицензий перестал продлевать ключ). Каждый порог — одно уведомление; если +// договор продлили (дата сменилась), отсчёт начинается заново. +// 2. При закрытых обновлениях bj — о смене версии внешней системы (МНП QUIK, ИШ, DEPO, …). +// +// Решение 16.09.2026: после окончания лицензии фоновые процессы (цикл QUIK МНП, личный кабинет, +// обмен с НРД и DEPO) не останавливаются. Последствие одно — bj перестаёт обновляться, и новые +// версии внешних систем со временем могут перестать с ним корректно работать; об этом и уведомляем. + +import ( + "encoding/json" + "fmt" + "log" + "math" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// licenseWatchFile — что уже отправлено и последние известные версии внешних систем. +var licenseWatchFile = "/var/lib/bj/.bj/license-watch.json" + +type licenseWatch struct { + EndDate string `json:"end_date,omitempty"` // дата окончания, по которой шли предупреждения + WarnedDays int `json:"warned_days,omitempty"` // последний отправленный порог (дней) + Versions map[string]string `json:"versions,omitempty"` // ключ системы → последняя известная версия +} + +var ( + licenseWatchMu sync.Mutex + licenseWarnSteps = []int{30, 14, 7, 1} +) + +// externalSystems — внешние системы, с которыми работает bj (свои компоненты bj обновляются вместе с ним). +var externalSystems = map[string]bool{ + "quik-mnp": true, "ish": true, "depo": true, "quikexport": true, + "validata": true, "java": true, "postgres": true, "os": true, +} + +func loadLicenseWatch() licenseWatch { + var w licenseWatch + if b, err := os.ReadFile(licenseWatchFile); err == nil { + _ = json.Unmarshal(b, &w) + } + if w.Versions == nil { + w.Versions = map[string]string{} + } + return w +} + +func saveLicenseWatch(w licenseWatch) { + b, _ := json.MarshalIndent(w, "", " ") + tmp := licenseWatchFile + ".tmp" + if err := os.MkdirAll(filepath.Dir(licenseWatchFile), 0o700); err == nil { + if err = os.WriteFile(tmp, b, 0o600); err == nil { + err = os.Rename(tmp, licenseWatchFile) + } + if err != nil { + log.Printf("lk-gateway: лицензия: запись %s: %v", licenseWatchFile, err) + } + } +} + +// ---- 1. заранее об окончании ---- + +// licenseEnd — когда лицензия перестанет действовать и что именно заканчивается. +func licenseEnd(st LicenseStatus, lastSync, now time.Time) (end time.Time, what string) { + if !st.Valid { + return time.Time{}, "" // уже не действует — об этом говорит статус и отказ в продлении + } + if !st.ContractUntil.IsZero() { + end, what = st.ContractUntil, "договор" + } + renewStuck := st.Online && now.Sub(lastSync) > 48*time.Hour + if (!st.Online || renewStuck) && !st.ExpiresAt.IsZero() && (end.IsZero() || st.ExpiresAt.Before(end)) { + end, what = st.ExpiresAt, "ключ" + if renewStuck { + what = "ключ (сервер лицензий не продлевает его)" + } + } + return end, what +} + +// expiryStep — какой порог отправить (0 — ничего): наименьший порог, в который уже попали, если он +// ещё не отправлен для этой даты окончания. +func expiryStep(daysLeft int, w licenseWatch, endDate string) int { + warned := w.WarnedDays + if w.EndDate != endDate { + warned = 0 + } + step := 0 + for _, s := range licenseWarnSteps { + if daysLeft <= s { + step = s + } + } + if step == 0 || (warned != 0 && step >= warned) { + return 0 + } + return step +} + +func ruDays(n int) string { + switch { + case n%10 == 1 && n%100 != 11: + return fmt.Sprintf("%d день", n) + case n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14): + return fmt.Sprintf("%d дня", n) + } + return fmt.Sprintf("%d дней", n) +} + +// licenseExpiryWarn — вызывается раз в час из licenseLoop. +func licenseExpiryWarn(rc *RuntimeConfig, now time.Time) { + st := licenseStatus(rc) + end, what := licenseEnd(st, rc.Snapshot().License.LastSync, now) + if end.IsZero() { + return + } + endDate := end.Local().Format("2006-01-02") + daysLeft := int(math.Ceil(end.Sub(now).Hours() / 24)) + if daysLeft < 0 { + daysLeft = 0 + } + licenseWatchMu.Lock() + defer licenseWatchMu.Unlock() + w := loadLicenseWatch() + step := expiryStep(daysLeft, w, endDate) + dateChanged := w.EndDate != endDate + if dateChanged { + w.EndDate, w.WarnedDays = endDate, 0 // договор продлили или сменился ключ — отсчёт заново + } + if step != 0 { + w.WarnedDays = step + } + if dateChanged || step != 0 { + saveLicenseWatch(w) + } + if step == 0 { + return + } + left := "осталось " + ruDays(daysLeft) + if daysLeft == 0 { + left = "сегодня последний день" + } + raiseSystemAlert(rc, "Лицензия: "+strings.SplitN(what, " ", 2)[0]+" заканчивается через "+ruDays(daysLeft), + fmt.Sprintf("Лицензия bj (%s) действует до %s — %s. После этого переводы M2M, обмен с НРД и DEPO, цикл QUIK МНП и личный кабинет продолжат работать, "+ + "но отключатся модули по лицензии и обновления bj: новые версии МНП, ИШ и других систем со временем могут перестать корректно работать с bj. "+ + "Для продления обратитесь к поставщику bj.", what, end.Local().Format("02.01.2006"), left)) +} + +// ---- 2. смена версии внешней системы при закрытых обновлениях ---- + +// versionChanges — сверка с последними известными версиями (запоминает новые); список изменений +// возвращается только когда обновления bj недоступны. +func versionChanges(w *licenseWatch, items []SystemVersion, updatesOK bool) []string { + var changed []string + for _, it := range items { + if !externalSystems[it.Key] || it.Version == "" { + continue + } + if prev := w.Versions[it.Key]; prev != "" && prev != it.Version && !updatesOK { + changed = append(changed, fmt.Sprintf("%s: %s → %s", it.Title, prev, it.Version)) + } + w.Versions[it.Key] = it.Version + } + return changed +} + +// licenseVersionWatch — вызывается после каждого опроса версий систем. +func licenseVersionWatch(rc *RuntimeConfig, v SystemVersions) { + st := licenseStatus(rc) + updatesOK := !licensingEnabled(rc) || (st.Valid && st.AllowsUpdates) + licenseWatchMu.Lock() + w := loadLicenseWatch() + before, _ := json.Marshal(w.Versions) + changed := versionChanges(&w, v.Items, updatesOK) + if after, _ := json.Marshal(w.Versions); string(after) != string(before) { + saveLicenseWatch(w) + } + licenseWatchMu.Unlock() + if len(changed) == 0 { + return + } + reason := "лицензия: " + st.Message + if st.Valid { + reason = "обновления не входят в лицензию" + } + raiseSystemAlert(rc, "Версия внешней системы изменилась, а обновления bj недоступны", + fmt.Sprintf("Изменилось: %s. Обновления bj сейчас недоступны (%s). bj продолжает работать, но совместимость с новой версией не проверена и не гарантируется — "+ + "при сбоях обмена причина может быть в этом. Совместимое обновление bj придёт после продления лицензии.", strings.Join(changed, "; "), reason)) +} diff --git a/internal/lkgateway/licensewarn_test.go b/internal/lkgateway/licensewarn_test.go new file mode 100644 index 0000000..380f9e7 --- /dev/null +++ b/internal/lkgateway/licensewarn_test.go @@ -0,0 +1,92 @@ +package lkgateway + +import ( + "strings" + "testing" + "time" +) + +func TestLicenseEnd(t *testing.T) { + now := time.Date(2027, 8, 20, 12, 0, 0, 0, time.UTC) + contract := time.Date(2027, 9, 14, 0, 0, 0, 0, time.UTC) + key := now.Add(55 * 24 * time.Hour) + + // Онлайн-ключ продлевается — важна дата договора. + st := LicenseStatus{Valid: true, Online: true, ContractUntil: contract, ExpiresAt: key} + if end, what := licenseEnd(st, now.Add(-time.Hour), now); !end.Equal(contract) || what != "договор" { + t.Fatalf("онлайн с договором: %v %q", end, what) + } + // Онлайн без договора и продление идёт — предупреждать не о чем. + st = LicenseStatus{Valid: true, Online: true, ExpiresAt: key} + if end, _ := licenseEnd(st, now.Add(-time.Hour), now); !end.IsZero() { + t.Fatalf("бессрочная онлайн-лицензия: %v", end) + } + // Продление не проходит 3 суток — считаем до конца ключа. + st = LicenseStatus{Valid: true, Online: true, ExpiresAt: now.Add(10 * 24 * time.Hour), ContractUntil: contract} + if end, what := licenseEnd(st, now.Add(-72*time.Hour), now); !end.Equal(st.ExpiresAt) || !strings.HasPrefix(what, "ключ") { + t.Fatalf("продление не проходит: %v %q", end, what) + } + // Офлайн-ключ — до конца ключа. + st = LicenseStatus{Valid: true, ExpiresAt: key} + if end, what := licenseEnd(st, time.Time{}, now); !end.Equal(key) || what != "ключ" { + t.Fatalf("офлайн: %v %q", end, what) + } + // Уже не действует — без предупреждения заранее. + if end, _ := licenseEnd(LicenseStatus{ContractUntil: contract}, now, now); !end.IsZero() { + t.Fatal("недействующая лицензия") + } +} + +func TestExpiryStep(t *testing.T) { + end := "2027-09-14" + cases := []struct { + days int + w licenseWatch + expect int + }{ + {45, licenseWatch{}, 0}, + {30, licenseWatch{}, 30}, + {30, licenseWatch{EndDate: end, WarnedDays: 30}, 0}, + {20, licenseWatch{EndDate: end, WarnedDays: 30}, 0}, + {14, licenseWatch{EndDate: end, WarnedDays: 30}, 14}, + {5, licenseWatch{}, 7}, // первый запуск за 5 дней — одно уведомление, без пачки 30/14/7 + {1, licenseWatch{EndDate: end, WarnedDays: 7}, 1}, + {0, licenseWatch{EndDate: end, WarnedDays: 1}, 0}, + {10, licenseWatch{EndDate: "2026-09-14", WarnedDays: 1}, 14}, // договор продлили — заново + } + for _, c := range cases { + if got := expiryStep(c.days, c.w, end); got != c.expect { + t.Errorf("осталось %d, %+v: порог %d, ожидался %d", c.days, c.w, got, c.expect) + } + } + if ruDays(1) != "1 день" || ruDays(3) != "3 дня" || ruDays(11) != "11 дней" || ruDays(22) != "22 дня" || ruDays(14) != "14 дней" { + t.Errorf("склонение: %s %s %s %s %s", ruDays(1), ruDays(3), ruDays(11), ruDays(22), ruDays(14)) + } +} + +func TestVersionChanges(t *testing.T) { + w := licenseWatch{Versions: map[string]string{}} + items := []SystemVersion{ + {Key: "quik-mnp", Title: "QUIK — Модуль неторговых поручений", Version: "21.4.1"}, + {Key: "bj-server", Title: "Ядро bj", Version: "0.1.0"}, + {Key: "depo", Title: "Fancy DEPO", Version: ""}, + } + if ch := versionChanges(&w, items, false); len(ch) != 0 { + t.Fatalf("первый опрос только запоминает: %v", ch) + } + if w.Versions["quik-mnp"] != "21.4.1" || w.Versions["bj-server"] != "" || w.Versions["depo"] != "" { + t.Fatalf("запомнены: %v", w.Versions) + } + items[0].Version = "21.5.0" + if ch := versionChanges(&w, items, true); len(ch) != 0 { + t.Fatalf("при доступных обновлениях без уведомления: %v", ch) + } + items[0].Version = "21.6.0" + ch := versionChanges(&w, items, false) + if len(ch) != 1 || ch[0] != "QUIK — Модуль неторговых поручений: 21.5.0 → 21.6.0" { + t.Fatalf("изменение при закрытых обновлениях: %v", ch) + } + if ch := versionChanges(&w, items, false); len(ch) != 0 { + t.Fatalf("повторно о той же версии не уведомляем: %v", ch) + } +} diff --git a/internal/lkgateway/versions.go b/internal/lkgateway/versions.go index c60be68..b111498 100644 --- a/internal/lkgateway/versions.go +++ b/internal/lkgateway/versions.go @@ -107,6 +107,7 @@ func refreshVersions(rc *RuntimeConfig) SystemVersions { versionsCache.Lock() versionsCache.v, versionsCache.refreshing = out, false versionsCache.Unlock() + licenseVersionWatch(rc, out) // смена версии внешней системы при закрытых обновлениях bj return out }