bf7c7b53bd
Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
486 lines
21 KiB
Go
486 lines
21 KiB
Go
package lkgateway
|
|
|
|
// Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со
|
|
// ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) —
|
|
// красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные
|
|
// (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
|
)
|
|
|
|
// cryptoJarPath — модуль проверки ключа подписи bj-crypto (компонент crypto-service.jar).
|
|
const cryptoJarPath = "/opt/bj/crypto-service.jar"
|
|
|
|
// ruPlural — форма слова для числа: 1 участник, 2 участника, 5 участников.
|
|
func ruPlural(n int, one, few, many string) string {
|
|
n %= 100
|
|
switch {
|
|
case n >= 11 && n <= 14:
|
|
return many
|
|
case n%10 == 1:
|
|
return one
|
|
case n%10 >= 2 && n%10 <= 4:
|
|
return few
|
|
}
|
|
return many
|
|
}
|
|
|
|
// ReadyCheck — одна строка итоговой проверки.
|
|
type ReadyCheck struct {
|
|
Order int `json:"-"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"` // ok | warn | err | skip
|
|
Detail string `json:"detail"`
|
|
Link string `json:"link,omitempty"`
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
|
|
s := rc.Snapshot()
|
|
mnp := moduleAllowed(rc, "quik-mnp")
|
|
var (
|
|
mu sync.Mutex
|
|
out []ReadyCheck
|
|
wg sync.WaitGroup
|
|
)
|
|
add := func(c ReadyCheck) {
|
|
mu.Lock()
|
|
out = append(out, c)
|
|
mu.Unlock()
|
|
}
|
|
run := func(f func() ReadyCheck) {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
add(f())
|
|
}()
|
|
}
|
|
ok := func(b bool) string {
|
|
if b {
|
|
return "ok"
|
|
}
|
|
return "err"
|
|
}
|
|
|
|
run(func() ReadyCheck {
|
|
l := licenseStatus(rc)
|
|
c := ReadyCheck{Order: 1, Title: "Лицензия", Link: "/admin/wizard?step=1", Required: true, State: ok(l.Valid), Detail: l.Message}
|
|
if l.Valid {
|
|
c.Detail = pickStr(l.Tenant, "активирована")
|
|
if !l.ContractUntil.IsZero() {
|
|
c.Detail += ", поддержка до " + l.ContractUntil.Format("02.01.2006")
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
st := checkPostgres(ctx, CheckOptions{PostgresDSN: s.Postgres.DSN, Timeout: 3 * time.Second})
|
|
c := ReadyCheck{Order: 2, Title: "База bj", Link: "/admin/wizard?step=2", Required: true, State: ok(st.OK && s.Postgres.DSN != ""), Detail: pickStr(st.Message, dsnSummary(s.Postgres.DSN))}
|
|
if s.Postgres.DSN == "" {
|
|
c.Detail = "база не подключена — заявки не сохраняются"
|
|
}
|
|
return c
|
|
})
|
|
if mnp {
|
|
run(func() ReadyCheck { return readyDepo(ctx, rc) })
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 5, Title: "QUIK — модуль неторговых поручений", Link: "/admin/wizard?step=5", Required: true}
|
|
cs := quikMNPCheck(ctx, s.Quik)
|
|
c.State = mnpWorst(cs)
|
|
var bad []string
|
|
for _, x := range cs {
|
|
if x.State != "ok" {
|
|
bad = append(bad, x.Title+": "+x.Detail)
|
|
}
|
|
}
|
|
if len(bad) == 0 {
|
|
c.Detail = "подключение, объекты M2M, инструменты, справочник получателей и автоматический цикл — в порядке"
|
|
} else {
|
|
c.Detail = strings.Join(bad, "; ")
|
|
}
|
|
return c
|
|
})
|
|
}
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 4, Title: "Организация и реквизиты НРД", Link: "/admin/wizard?step=4", Required: true, State: ok(doneOrg(s))}
|
|
if doneOrg(s) {
|
|
c.Detail = fmt.Sprintf("%s, ИНН %s, код %s; счёт %s, раздел %s", pickStr(s.NSD.OrgName, "—"), orgINN(s), orgCode(s), s.NSD.AccountID, s.NSD.SectionID)
|
|
} else {
|
|
c.Detail = "не заданы организация или депозитарные реквизиты в НРД"
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
|
defer cancel()
|
|
ver, _, _ := validataVersion(cctx)
|
|
c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата»", Link: "/admin/wizard?step=6", Required: true}
|
|
if ver == "" {
|
|
c.State, c.Detail = "err", "Валидата на сервере не найдена — без неё шлюз не подпишет пакеты для НРД"
|
|
return c
|
|
}
|
|
c.State, c.Detail = "ok", "Валидата "+ver
|
|
// Служба bj-crypto — проверка ключа подписи из bj (работает ли ключ, его сертификат); пакеты для НРД подписывает шлюз, поэтому
|
|
// её остановка обмену не мешает. Модуль приезжает с обновлениями — до них службы нет, это не ошибка.
|
|
if _, err := os.Stat(cryptoJarPath); err == nil {
|
|
if u := unitRunState("bj-crypto"); !(u.State == "active" && u.Up >= 5*time.Second) {
|
|
c.State, c.Link = "warn", "/admin/setup#sec-crypto"
|
|
c.Detail += "; проверка ключа подписи из bj не работает (служба bj-crypto: " + pickStr(u.State, "нет") + ") — обмену не мешает: пакеты для НРД подписывает шлюз"
|
|
if cc, ok := release.ComponentByName("crypto-service.jar"); ok && (u.Restarts >= 3 || u.State == "failed") {
|
|
c.Detail += ". Причина: " + unitFailCause(cc)
|
|
}
|
|
} else {
|
|
c.Detail += ", проверка ключа подписи работает"
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 8, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=8", Required: true}
|
|
own := ownValidCerts(storeCertsForUI(s))
|
|
if len(own) == 0 {
|
|
c.State, c.Detail = "err", "в справочнике ИШ нет действующего сертификата организации — установите ключ мастером установки ключа"
|
|
if len(s.NSDCerts.LastStore) == 0 {
|
|
c.Detail = "справочник ИШ ещё не прочитан (появится после установки ключа и настройки шлюза)"
|
|
}
|
|
return c
|
|
}
|
|
sort.Slice(own, func(i, j int) bool { return own[i].NotAfter.Before(own[j].NotAfter) })
|
|
last := own[len(own)-1]
|
|
d := certDaysLeft(last.NotAfter)
|
|
c.State, c.Detail = "ok", fmt.Sprintf("«%s» до %s (осталось %d дн.)", last.SubjectCN, last.NotAfter.Format("02.01.2006"), d)
|
|
if d <= 30 {
|
|
c.State, c.Detail = "warn", c.Detail+" — закажите новый ключ в УЦ Московской биржи"
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 7, Title: "Шлюз НРД", Link: "/admin/wizard?step=7", Required: true}
|
|
up, msg := igateState(ctx, s.NSD.IGWBaseURL)
|
|
switch {
|
|
case !up:
|
|
c.State, c.Detail = "err", msg
|
|
case !doneNSD(s):
|
|
c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран"
|
|
default:
|
|
c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile
|
|
if prof, err := nsdadapter.LookupProfile(s.NSD.Profile); err == nil && orgCode(s) != "" {
|
|
code := prof.Channel + orgCode(s)
|
|
switch found, enabled, err := igateChannelState(ctx, s.NSD.IGWBaseURL, code); {
|
|
case err != nil:
|
|
case !found:
|
|
c.State, c.Detail = "err", "движок шлюза работает, но канала "+code+" в нём нет — шлюз не загрузил канал: пересоздайте его на шаге «Шлюз НРД» (кнопка «Создать канал»)"
|
|
return c
|
|
case !enabled:
|
|
c.State, c.Detail = "err", "канал "+code+" в шлюзе выключен — он включается, когда установлен ключ подписи этого контура (шаг «Ключ подписи»)"
|
|
return c
|
|
default:
|
|
c.Detail += "; канал " + code + " работает"
|
|
}
|
|
}
|
|
nsdWatch.Lock()
|
|
okAt := nsdWatch.LastPollOK
|
|
nsdWatch.Unlock()
|
|
if !okAt.IsZero() {
|
|
c.Detail += "; последний успешный обмен с НРД " + okAt.Format("02.01 15:04")
|
|
}
|
|
if down, det := nsdLinkStatus(); down {
|
|
c.State, c.Detail = "err", det
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 9, Title: "Оповещения", Link: "/admin/wizard?step=9"}
|
|
smtp := s.Alerts.SMTP
|
|
switch {
|
|
case smtp.Enabled && smtp.Host != "" && smtp.To != "":
|
|
c.State, c.Detail = "ok", "почта на "+smtp.To
|
|
if smtp.LastMsg != "" {
|
|
c.Detail += " (последняя проверка: " + smtp.LastMsg + ")"
|
|
}
|
|
if strings.HasPrefix(smtp.LastMsg, "ошибка") {
|
|
c.State = "warn"
|
|
}
|
|
case s.Alerts.Zabbix.Enabled:
|
|
c.State, c.Detail = "ok", "Zabbix "+s.Alerts.Zabbix.Server
|
|
default:
|
|
depo := ""
|
|
if moduleAllowed(rc, "quik-mnp") {
|
|
depo = ", ошибки DEPO"
|
|
}
|
|
c.State, c.Detail = "warn", "не настроены: о сбоях (НРД не отвечает, недоставленные переводы"+depo+", срок сертификата) узнают только открыв bj"
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 10, Title: "Резервные копии", Link: "/admin/wizard?step=9"}
|
|
b := s.Backup
|
|
st := backupStatus()
|
|
switch {
|
|
case !b.Enabled:
|
|
c.State, c.Detail = "warn", "выключены — при сбое сервера настройки, заявки и данные шлюза не восстановить"
|
|
case st == nil:
|
|
c.State, c.Detail = "warn", fmt.Sprintf("включены (каждые %d ч), копий ещё не было", b.every())
|
|
case !st.OK:
|
|
c.State, c.Detail = "err", "последняя копия не удалась: "+st.Msg
|
|
default:
|
|
c.State, c.Detail = "ok", fmt.Sprintf("каждые %d ч, хранение %d дн.; последняя %s", b.every(), b.keep(), backupWhen(st.At))
|
|
if t, err := time.Parse(time.RFC3339, st.At); err == nil && time.Since(t) > time.Duration(2*b.every()+1)*time.Hour {
|
|
c.State, c.Detail = "warn", c.Detail+" — давно, проверьте таймер bj-backup"
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 12, Title: "HTTPS", Link: "/admin/setup#server"}
|
|
cn, until, err := tlsCertInfo()
|
|
switch {
|
|
case err != nil:
|
|
c.State, c.Detail = "warn", "сертификат HTTPS: "+err.Error()
|
|
case certDaysLeft(until) < 14:
|
|
c.State, c.Detail = "warn", fmt.Sprintf("сертификат «%s» истекает %s — замените", cn, until.Format("02.01.2006"))
|
|
case !httpsUp.Load():
|
|
c.State, c.Detail = "warn", "HTTPS не запущен (порт "+strconv.Itoa(s.HTTPS.port())+" занят или нет сертификата) — вход только по http://, пароли идут по сети открытым текстом; см. журнал bj-server"
|
|
case !s.HTTPS.RedirectOn():
|
|
c.State, c.Detail = "warn", "перенаправление с http:// на https:// выключено в настройках — пароли могут идти по сети открытым текстом"
|
|
default:
|
|
c.State, c.Detail = "ok", fmt.Sprintf("вход только по HTTPS, сертификат «%s» до %s", cn, until.Format("02.01.2006"))
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 11, Title: "Рабочее время", Link: "/admin/wizard?step=12"}
|
|
w := s.WorkHours
|
|
switch {
|
|
case w.SavedAt.IsZero():
|
|
c.State, c.Detail = "warn", "рабочее время организации не задано — запросы на перевод M2M принимаются круглосуточно"
|
|
case !w.Enabled:
|
|
c.State, c.Detail = "ok", w.Text()+"; ограничение выключено — запросы M2M принимаются круглосуточно"
|
|
default:
|
|
c.State, c.Detail = "ok", w.Text()+" ("+time.Local.String()+"): запрос на перевод M2M, пришедший вне этого времени, отклоняется кодом "+workHoursRejectCode+" «"+m2mCodeText(workHoursRejectCode)+"»"
|
|
if !w.Open(time.Now()) {
|
|
c.Detail += "; сейчас нерабочее время"
|
|
}
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#server"}
|
|
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
|
|
switch {
|
|
case err != nil:
|
|
c.State, c.Detail = "warn", "состояние синхронизации не получено"
|
|
case strings.TrimSpace(string(out)) == "yes":
|
|
c.State, c.Detail = "ok", "синхронизировано с сервером времени"
|
|
default:
|
|
c.State, c.Detail = "warn", "время не синхронизируется — часы уплывут, а это важно для подписей и обмена с НРД; укажите сервер времени (NTP)"
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 13, Title: "Справочник участников M2M НРД", Link: "/admin/handbook"}
|
|
raw, _ := readHandbook()
|
|
at := handbookAt(raw)
|
|
var hb struct {
|
|
Participants []HandbookEntry `json:"participants"`
|
|
}
|
|
_ = json.Unmarshal(raw, &hb)
|
|
switch {
|
|
case len(hb.Participants) == 0:
|
|
c.State, c.Detail = "err", "справочника нет — получателя перевода не определить"
|
|
case !at.IsZero() && time.Since(at) > 14*24*time.Hour:
|
|
c.State, c.Detail = "warn", fmt.Sprintf("%d %s, от %s — давно не обновлялся (обновляется по связи с НРД)", len(hb.Participants), ruPlural(len(hb.Participants), "участник", "участника", "участников"), at.Format("02.01.2006"))
|
|
default:
|
|
c.State, c.Detail = "ok", fmt.Sprintf("%d %s, от %s", len(hb.Participants), ruPlural(len(hb.Participants), "участник", "участника", "участников"), at.Format("02.01.2006"))
|
|
}
|
|
return c
|
|
})
|
|
run(func() ReadyCheck { return readyNetwork(ctx, s) })
|
|
for i, m := range wizModuleViews(rc, s) { // модули по лицензии (без лицензии — нет и строки)
|
|
m := m
|
|
add(ReadyCheck{Order: 20 + i, Title: "Модуль: " + m.Title, Link: m.Link, State: map[bool]string{true: "ok", false: "warn"}[m.Ready], Detail: m.Status})
|
|
}
|
|
run(func() ReadyCheck {
|
|
c := ReadyCheck{Order: 15, Title: "Учётные записи", Link: "/admin/setup#users"}
|
|
var admins, others int
|
|
for _, u := range globalUsers.List() {
|
|
if !u.Active {
|
|
continue
|
|
}
|
|
if u.Role == "admin" {
|
|
admins++
|
|
} else {
|
|
others++
|
|
}
|
|
}
|
|
switch {
|
|
case s.Auth.ADEnabled:
|
|
c.State, c.Detail = "ok", fmt.Sprintf("вход через Active Directory; локальных: администраторов %d, других %d", admins, others)
|
|
case others == 0:
|
|
c.State, c.Detail = "warn", fmt.Sprintf("только администратор (%d) — заведите учётные записи операторов: у каждого свой вход, в журнале видно, кто что сделал", admins)
|
|
default:
|
|
c.State, c.Detail = "ok", fmt.Sprintf("администраторов %d, других пользователей %d", admins, others)
|
|
}
|
|
return c
|
|
})
|
|
wg.Wait()
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order })
|
|
return out
|
|
}
|
|
|
|
// readyNetwork — сетевые доступы, без которых обмен не заработает: контур НРД (ИШ ходит туда сам по ГОСТ-TLS —
|
|
// здесь только TCP), справочник сертификатов Мосбиржи (LDAP) и сервер лицензий (ключ, продление, обновления).
|
|
func readyNetwork(ctx context.Context, s Settings) ReadyCheck {
|
|
c := ReadyCheck{Order: 14, Title: "Сетевые доступы", Link: "/admin/setup#server"}
|
|
type target struct{ name, addr string }
|
|
var ts []target
|
|
switch contourOf(s.NSD.Profile) {
|
|
case "prod":
|
|
if strings.HasSuffix(s.NSD.Profile, "rsa") {
|
|
ts = append(ts, target{"НРД (боевой, RSA)", "edor.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50003"})
|
|
} else {
|
|
ts = append(ts, target{"НРД (боевой, ГОСТ)", "edog.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50001"})
|
|
}
|
|
default:
|
|
if strings.HasSuffix(s.NSD.Profile, "rsa") {
|
|
ts = append(ts, target{"НРД (TEST3, RSA)", "rsa-t3.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50007"})
|
|
} else {
|
|
ts = append(ts, target{"НРД (TEST3, ГОСТ)", "gost-t3.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50005"})
|
|
}
|
|
}
|
|
ts = append(ts, target{"сервер лицензий", "lic.zetit.ru:443"})
|
|
var bad, good []string
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
for _, t := range ts {
|
|
wg.Add(1)
|
|
go func(t target) {
|
|
defer wg.Done()
|
|
d := net.Dialer{Timeout: 4 * time.Second}
|
|
conn, err := d.DialContext(ctx, "tcp", t.addr)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if err != nil {
|
|
bad = append(bad, t.name+" ("+t.addr+")")
|
|
return
|
|
}
|
|
conn.Close()
|
|
good = append(good, t.name)
|
|
}(t)
|
|
}
|
|
wg.Wait()
|
|
sort.Strings(bad)
|
|
if len(bad) > 0 {
|
|
c.State, c.Detail = "err", "нет доступа: "+strings.Join(bad, ", ")+" — откройте эти адреса на межсетевом экране организации"
|
|
return c
|
|
}
|
|
c.State, c.Detail = "ok", "доступны: "+strings.Join(good, ", ")
|
|
return c
|
|
}
|
|
|
|
func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck {
|
|
f := rc.Snapshot().Fancy
|
|
c := ReadyCheck{Order: 3, Title: "Учёт DEPO", Link: "/admin/wizard?step=3&sub=done", Required: true}
|
|
if strings.TrimSpace(f.Server) == "" {
|
|
c.State, c.Detail = "err", "база DEPO не подключена"
|
|
return c
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
db, err := fancyOpen(f, f.DepoDB())
|
|
if err == nil {
|
|
err = db.PingContext(cctx)
|
|
}
|
|
if err != nil {
|
|
c.State, c.Detail = "err", "подключение: "+quikErrorDetail(err)
|
|
return c
|
|
}
|
|
defer db.Close()
|
|
var notes []string
|
|
c.State = "ok"
|
|
if !f.DepoTablesCheckOK {
|
|
c.State = "err"
|
|
notes = append(notes, "таблицы обмена FTV_M2M: "+pickStr(f.DepoTablesCheck, "не проверены"))
|
|
}
|
|
procsPending := false
|
|
if msg := depoTablesMissing(cctx, db, f); msg != "" {
|
|
c.State = "err"
|
|
notes = append(notes, msg)
|
|
} else if msg := depoProcsMissing(cctx, db, f); msg != "" {
|
|
// процедуры bj2depo ещё не установлены — не блокирует: формат обмена с учётной системой готовится, их
|
|
// доустанавливают отдельно; пока их нет, события bj в DEPO копятся необработанными
|
|
procsPending = true
|
|
notes = append(notes, "процедуры обработки bj2depo ещё не установлены — не забудьте их доустановить. Пока их нет, события bj в базе DEPO ждут обработки")
|
|
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
|
|
c.State = "err"
|
|
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
|
|
}
|
|
if jmsg, jok := depoJobCheck(cctx, rc); !jok && !procsPending {
|
|
c.State = "err"
|
|
notes = append(notes, "запуск обработки: "+jmsg)
|
|
}
|
|
if procsPending && c.State == "ok" {
|
|
c.State = "warn"
|
|
}
|
|
if n, _, err := depoAccPrefixInfo(cctx, f); err == nil && n == 0 && !f.IsPostgres() {
|
|
c.State = "err"
|
|
notes = append(notes, "открытых торговых счетов «"+depoAccPrefix()+"…» в учёте нет — проверьте префикс счёта")
|
|
}
|
|
if len(notes) == 0 {
|
|
c.Detail = f.Server + ", база " + f.DepoDB() + ": таблицы, обработка и счета клиентов на месте"
|
|
} else {
|
|
c.Detail = strings.Join(notes, "; ")
|
|
}
|
|
return c
|
|
}
|
|
|
|
// lastReadiness — итог последней проверки готовности: по нему отмечается шаг 10 мастера.
|
|
var lastReadiness struct {
|
|
sync.Mutex
|
|
ready bool
|
|
at time.Time
|
|
}
|
|
|
|
func readinessDone() bool {
|
|
lastReadiness.Lock()
|
|
defer lastReadiness.Unlock()
|
|
return lastReadiness.ready && time.Since(lastReadiness.at) < 24*time.Hour
|
|
}
|
|
|
|
func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) {
|
|
mux.HandleFunc("/admin/setup/readiness", func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
checks := readinessChecks(ctx, rc)
|
|
ready := true
|
|
for _, c := range checks {
|
|
if c.Required && c.State != "ok" && c.State != "warn" {
|
|
ready = false
|
|
}
|
|
}
|
|
lastReadiness.Lock()
|
|
lastReadiness.ready, lastReadiness.at = ready, time.Now()
|
|
lastReadiness.Unlock()
|
|
jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")})
|
|
})
|
|
}
|