Первичная настройка, часть 2: шаг 9 «Оповещения и резервные копии», шаг 10 — итоговая проверка готовности (живые проверки со ссылкой «Исправить»); проверка модуля неторговых поручений QUIK (объекты M2M, инструменты, справочник получателей, цикл, ошибки операций); восстановление из копии на /first-run по одноразовому коду; возврат на тот же шаг мастера после «Сохранить» (return_to с ?step); пароль SMTP не выводится в страницу; карточки почты, адресатов и копий — общие для настроек и мастера
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
package lkgateway
|
||||
|
||||
// Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со
|
||||
// ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) —
|
||||
// красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные
|
||||
// (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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)
|
||||
out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output()
|
||||
svc := strings.TrimSpace(string(out))
|
||||
c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата» и служба криптографии", Link: "/admin/wizard?step=6", Required: true}
|
||||
switch {
|
||||
case ver == "":
|
||||
c.State, c.Detail = "err", "Валидата на сервере не найдена"
|
||||
case svc != "active":
|
||||
c.State, c.Detail = "err", "Валидата "+ver+", но служба bj-crypto не запущена ("+pickStr(svc, "нет")+")"
|
||||
default:
|
||||
c.State, c.Detail = "ok", "Валидата "+ver+", служба bj-crypto работает"
|
||||
}
|
||||
return c
|
||||
})
|
||||
run(func() ReadyCheck {
|
||||
c := ReadyCheck{Order: 7, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=7", 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: 8, Title: "Шлюз НРД", Link: "/admin/wizard?step=8", 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 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:
|
||||
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: 11, Title: "Время сервера", Link: "/admin/setup#time"}
|
||||
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
|
||||
})
|
||||
wg.Wait()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order })
|
||||
return out
|
||||
}
|
||||
|
||||
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, "не проверены"))
|
||||
}
|
||||
if msg := depoObjectsMissing(cctx, db, f); msg != "" {
|
||||
c.State = "err"
|
||||
notes = append(notes, msg)
|
||||
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
|
||||
c.State = "err"
|
||||
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
|
||||
}
|
||||
if jmsg, jok := depoJobCheck(cctx, rc); !jok {
|
||||
c.State = "err"
|
||||
notes = append(notes, "запуск обработки: "+jmsg)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user