Лицензия: предупреждения заранее об окончании и о смене версий внешних систем
За 30, 14, 7 и 1 день до окончания договора (офлайн-ключ или застрявшее продление — до конца ключа) bj шлёт уведомление: что продолжит работать, а что отключится. Каждый порог — один раз, при продлении договора отсчёт заново; проверка раз в час в licenseLoop. После каждого опроса версий bj запоминает версии внешних систем (МНП QUIK, ИШ, DEPO, экспортёр ARQA, «Валидата», Java, PostgreSQL, ОС). Если обновления bj недоступны и версия изменилась — уведомление, что совместимость с новой версией не гарантируется. Решение: после окончания лицензии цикл QUIK МНП и личный кабинет продолжают работать. Состояние — /var/lib/bj/.bj/license-watch.json. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,8 +181,8 @@ func licenseNeedsRenew(rc *RuntimeConfig) bool {
|
|||||||
return time.Since(s.LastSync) >= 23*time.Hour
|
return time.Since(s.LastSync) >= 23*time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
// licenseLoop — раз в час проверяет, пора ли продлить ключ (продление — раз в сутки,
|
// licenseLoop — раз в час предупреждает о скором окончании лицензии и проверяет, пора ли продлить
|
||||||
// при сбое — повтор через час).
|
// ключ (продление — раз в сутки, при сбое — повтор через час).
|
||||||
func (s *Server) licenseLoop(ctx context.Context) {
|
func (s *Server) licenseLoop(ctx context.Context) {
|
||||||
ensureInstallID(s.rc)
|
ensureInstallID(s.rc)
|
||||||
wait := 2 * time.Minute
|
wait := 2 * time.Minute
|
||||||
@@ -193,6 +193,7 @@ func (s *Server) licenseLoop(ctx context.Context) {
|
|||||||
case <-time.After(wait):
|
case <-time.After(wait):
|
||||||
}
|
}
|
||||||
wait = time.Hour
|
wait = time.Hour
|
||||||
|
licenseExpiryWarn(s.rc, time.Now())
|
||||||
if !licenseNeedsRenew(s.rc) {
|
if !licenseNeedsRenew(s.rc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ func refreshVersions(rc *RuntimeConfig) SystemVersions {
|
|||||||
versionsCache.Lock()
|
versionsCache.Lock()
|
||||||
versionsCache.v, versionsCache.refreshing = out, false
|
versionsCache.v, versionsCache.refreshing = out, false
|
||||||
versionsCache.Unlock()
|
versionsCache.Unlock()
|
||||||
|
licenseVersionWatch(rc, out) // смена версии внешней системы при закрытых обновлениях bj
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user