Проверка на чистой ВМ: служба впервые установленного модуля включается в автозапуск, даже если unit-файл уже положил образ (иначе bj-crypto после перезагрузки не стартовал); bj-crypto в готовности — предупреждение, а не блокер (пакеты для НРД подписывает шлюз), до установки модуля не проверяется; в мастере без ссылки «Открыть мастер» на самом мастере, без двойного заголовка «Наша организация», без QUIK и DEPO в текстах и маршрутах уведомлений, если их нет в лицензии; «162 участника», понятная сводка готовности; адрес SMTP через JoinHostPort

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 19:04:24 +03:00
parent 1b58b07e02
commit ba1aae705d
11 changed files with 137 additions and 26 deletions
+2 -2
View File
@@ -833,13 +833,13 @@ func nowPage(title, active string) page {
switch { switch {
case s.NSD.IGWBaseURL == "": case s.NSD.IGWBaseURL == "":
p.IsMockMode = true p.IsMockMode = true
p.MockReason = "ИШ НРД не настроен — обмен с НРД не выполняется" p.MockReason = "Не подключён интеграционный шлюз НРД."
if p.DevMode { if p.DevMode {
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)" p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
} }
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub": case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
p.IsMockMode = true p.IsMockMode = true
p.MockReason = "Не настроено средство подписи (СКЗИ) — обмен с НРД невозможен" p.MockReason = "Не настроено средство подписи (СКЗИ)."
if p.DevMode { if p.DevMode {
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен" p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
} }
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"mime" "mime"
"net" "net"
"net/smtp" "net/smtp"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -64,7 +65,7 @@ func sendSMTP(s SMTPSettings, subject, body string) error {
if port == 0 { if port == 0 {
port = 587 port = 587
} }
addr := fmt.Sprintf("%s:%d", s.Host, port) addr := net.JoinHostPort(s.Host, strconv.Itoa(port))
msg := buildMIME(s.From, rcpts, subject, body) msg := buildMIME(s.From, rcpts, subject, body)
dialer := &net.Dialer{Timeout: 15 * time.Second} dialer := &net.Dialer{Timeout: 15 * time.Second}
+31
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -236,3 +237,33 @@ func TestLettersFill(t *testing.T) {
t.Error("неизвестное письмо") t.Error("неизвестное письмо")
} }
} }
func TestRuPlural(t *testing.T) {
for n, want := range map[int]string{1: "участник", 2: "участника", 4: "участника", 5: "участников", 11: "участников", 12: "участников", 21: "участник", 162: "участника", 111: "участников", 0: "участников"} {
if got := ruPlural(n, "участник", "участника", "участников"); got != want {
t.Errorf("%d: %q, ожидалось %q", n, got, want)
}
}
}
// Без лицензии QUIK МНП маршрута «ошибки QUIK» в форме нет, а сохранение формы не стирает его прежнее значение.
func TestNotifyKindsByLicense(t *testing.T) {
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
for _, k := range visibleNotifyKinds(rc) {
if k.Key == "quik" {
t.Fatal("без лицензии QUIK МНП маршрута QUIK быть не должно")
}
}
if len(visibleNotifyKinds(rc)) != len(notifyKinds)-1 {
t.Fatalf("типы: %+v", visibleNotifyKinds(rc))
}
h := &setupHandlers{rc: rc}
form := url.Values{"enabled": {"on"}, "emails": {"depo@example.ru"}, "route_incoming_depo": {"1"}}
req := httptest.NewRequest(http.MethodPost, "/admin/setup/incoming-notify", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.saveIncomingNotify(httptest.NewRecorder(), req)
r := rc.Snapshot().Incoming.Routes
if r["incoming"] != "depo" || r["quik"] != defaultNotifyRoutes["quik"] {
t.Fatalf("маршруты после сохранения: %+v", r)
}
}
+40 -12
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"sort" "sort"
"strings" "strings"
@@ -18,6 +19,23 @@ import (
"time" "time"
) )
// 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 — одна строка итоговой проверки. // ReadyCheck — одна строка итоговой проверки.
type ReadyCheck struct { type ReadyCheck struct {
Order int `json:"-"` Order int `json:"-"`
@@ -107,16 +125,22 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
cctx, cancel := context.WithTimeout(ctx, 4*time.Second) cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel() defer cancel()
ver, _, _ := validataVersion(cctx) 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 {
out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output() out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output()
svc := strings.TrimSpace(string(out)) if svc := strings.TrimSpace(string(out)); svc != "active" {
c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата» и служба криптографии", Link: "/admin/wizard?step=6", Required: true} c.State, c.Link = "warn", "/admin/setup#sec-crypto"
switch { c.Detail += "; служба пробной подписи bj-crypto не запущена (" + pickStr(svc, "нет") + ") — обмену не мешает: пакеты для НРД подписывает шлюз"
case ver == "": } else {
c.State, c.Detail = "err", "Валидата на сервере не найдена" c.Detail += ", служба пробной подписи bj-crypto работает"
case svc != "active": }
c.State, c.Detail = "err", "Валидата "+ver+", но служба bj-crypto не запущена ("+pickStr(svc, "нет")+")"
default:
c.State, c.Detail = "ok", "Валидата "+ver+", служба bj-crypto работает"
} }
return c return c
}) })
@@ -176,7 +200,11 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
case s.Alerts.Zabbix.Enabled: case s.Alerts.Zabbix.Enabled:
c.State, c.Detail = "ok", "Zabbix "+s.Alerts.Zabbix.Server c.State, c.Detail = "ok", "Zabbix "+s.Alerts.Zabbix.Server
default: default:
c.State, c.Detail = "warn", "не настроены: о сбоях (НРД не отвечает, недоставленные переводы, ошибки DEPO, срок сертификата) узнают только открыв bj" depo := ""
if moduleAllowed(rc, "quik-mnp") {
depo = ", ошибки DEPO"
}
c.State, c.Detail = "warn", "не настроены: о сбоях (НРД не отвечает, недоставленные переводы"+depo+", срок сертификата) узнают только открыв bj"
} }
return c return c
}) })
@@ -241,9 +269,9 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
case len(hb.Participants) == 0: case len(hb.Participants) == 0:
c.State, c.Detail = "err", "справочника нет — получателя перевода не определить" c.State, c.Detail = "err", "справочника нет — получателя перевода не определить"
case !at.IsZero() && time.Since(at) > 14*24*time.Hour: case !at.IsZero() && time.Since(at) > 14*24*time.Hour:
c.State, c.Detail = "warn", fmt.Sprintf("%d участников, от %s — давно не обновлялся (обновляется по связи с НРД)", len(hb.Participants), at.Format("02.01.2006")) c.State, c.Detail = "warn", fmt.Sprintf("%d %s, от %s — давно не обновлялся (обновляется по связи с НРД)", len(hb.Participants), ruPlural(len(hb.Participants), "участник", "участника", "участников"), at.Format("02.01.2006"))
default: default:
c.State, c.Detail = "ok", fmt.Sprintf("%d участников, от %s", len(hb.Participants), at.Format("02.01.2006")) c.State, c.Detail = "ok", fmt.Sprintf("%d %s, от %s", len(hb.Participants), ruPlural(len(hb.Participants), "участник", "участника", "участников"), at.Format("02.01.2006"))
} }
return c return c
}) })
+12
View File
@@ -602,6 +602,18 @@ var notifyKinds = []struct{ Key, Title string }{
{"system", "Прочие системные оповещения"}, {"system", "Прочие системные оповещения"},
} }
// visibleNotifyKinds — типы уведомлений этой установки: ошибки QUIK МНП — только при модуле в лицензии.
func visibleNotifyKinds(rc *RuntimeConfig) []struct{ Key, Title string } {
var out []struct{ Key, Title string }
for _, k := range notifyKinds {
if k.Key == "quik" && rc != nil && !moduleAllowed(rc, "quik-mnp") {
continue
}
out = append(out, k)
}
return out
}
// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои. // defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои.
var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"} var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"}
+11 -2
View File
@@ -311,6 +311,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
} }
if (step >= 3 && step <= 5) || step == 9 || step == 11 { // DEPO, организация, QUIK МНП, оповещения и копии, модули — общие блоки из «Настройки» if (step >= 3 && step <= 5) || step == 9 || step == 11 { // DEPO, организация, QUIK МНП, оповещения и копии, модули — общие блоки из «Настройки»
fd := h.setupData(w, r, "") fd := h.setupData(w, r, "")
fd.InWizard = true
d.Fancy = &fd d.Fancy = &fd
} }
if step == 3 { if step == 3 {
@@ -458,6 +459,7 @@ func (h *setupHandlers) testSignCrypto(w http.ResponseWriter, r *http.Request) {
type SetupData struct { type SetupData struct {
page page
FromWizard bool // раздел открыт из мастера (настройка модуля) — показать «Вернуться в мастер» FromWizard bool // раздел открыт из мастера (настройка модуля) — показать «Вернуться в мастер»
InWizard bool // общий блок показан внутри шага мастера — заголовок и пояснение даёт сам шаг
Settings Settings Settings Settings
Suggest *DepoRequisites // предложение реквизитов из DEPO Suggest *DepoRequisites // предложение реквизитов из DEPO
Readiness []Readiness Readiness []Readiness
@@ -554,7 +556,7 @@ func (h *setupHandlers) setupData(w http.ResponseWriter, req *http.Request, flas
OrgName: orgName(s), OrgName: orgName(s),
OrgCode: orgCode(s), OrgCode: orgCode(s),
NotifyGroups: notifyGroups, NotifyGroups: notifyGroups,
NotifyKinds: notifyKinds, NotifyKinds: visibleNotifyKinds(h.rc),
DepoJob: depoJobView(s.Fancy), DepoJob: depoJobView(s.Fancy),
DepoTables: depoTablesView(s.Fancy), DepoTables: depoTablesView(s.Fancy),
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"), QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
@@ -1103,7 +1105,14 @@ func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Reques
EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")), EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")),
Routes: map[string]string{}, Routes: map[string]string{},
} }
for _, k := range notifyKinds { prev := h.rc.Snapshot().Incoming.Routes
if prev == nil {
prev = defaultNotifyRoutes
}
for _, k := range notifyKinds { // скрытые в форме типы (модуль не в лицензии) — прежний маршрут
s.Routes[k.Key] = prev[k.Key]
}
for _, k := range visibleNotifyKinds(h.rc) {
var gs []string var gs []string
for _, g := range notifyGroups { for _, g := range notifyGroups {
if r.FormValue("route_"+k.Key+"_"+g.Key) != "" { if r.FormValue("route_"+k.Key+"_"+g.Key) != "" {
@@ -316,8 +316,8 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{if eq .Step 4}} {{if eq .Step 4}}
<div class="card"> <div class="card">
<h2><span class="dot {{if .Done.Org}}ok{{else}}err{{end}}"></span>Наша организация</h2> <h2><span class="dot {{if .Done.Org}}ok{{else}}err{{end}}"></span>Наша организация</h2>
<div class="help-block"><strong>Что это.</strong> Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: код участника, ИНН и наименование попадают в пакеты НРД и в поручения QUIK.<br> <div class="help-block"><strong>Что это.</strong> Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: код участника, ИНН и наименование попадают в пакеты НРД{{if .DepoLicensed}} и в поручения QUIK{{end}}.<br>
<strong>Откуда взять.</strong> Если подключена база DEPO (раздел «Учёт DEPO»), bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную.</div> <strong>Откуда взять.</strong> {{if .DepoLicensed}}Если подключена база DEPO (раздел «Учёт DEPO»), bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите{{else}}Выберите{{end}} себя из справочника участников НРД или введите вручную.</div>
{{if .Suggest}}{{if .Suggest.OrgName}}{{if not .Done.Org}}<p style="margin:8px 0;padding:8px 12px;border-radius:8px;background:var(--accent-weak)">✓ <strong>Определено из DEPO</strong>: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}}{{if .Suggest.AccountID}}, счёт НРД {{.Suggest.AccountID}}{{end}}. Поля ниже заполнены — проверьте и нажмите «Сохранить».</p>{{end}}{{end}}{{end}} {{if .Suggest}}{{if .Suggest.OrgName}}{{if not .Done.Org}}<p style="margin:8px 0;padding:8px 12px;border-radius:8px;background:var(--accent-weak)">✓ <strong>Определено из DEPO</strong>: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}}{{if .Suggest.AccountID}}, счёт НРД {{.Suggest.AccountID}}{{end}}. Поля ниже заполнены — проверьте и нажмите «Сохранить».</p>{{end}}{{end}}{{end}}
{{with .Fancy}}{{template "orgSection" .}}{{end}} {{with .Fancy}}{{template "orgSection" .}}{{end}}
<div class="card" id="nsd-req" style="margin-top:12px"> <div class="card" id="nsd-req" style="margin-top:12px">
@@ -517,7 +517,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{if eq .Step 9}} {{if eq .Step 9}}
<div class="card"> <div class="card">
<h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Оповещения и резервные копии</h2> <h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Оповещения и резервные копии</h2>
<div class="help-block"><strong>Зачем.</strong> bj сам следит за обменом: НРД не отвечает, перевод не доставлен, ошибка обработки в DEPO, истекает сертификат ключа. Чтобы об этом узнали сразу, а не открыв bj, — укажите почту. Резервные копии позволят восстановить сервер целиком (настройки, заявки, шлюз НРД) после сбоя или переустановки.<br> <div class="help-block"><strong>Зачем.</strong> bj сам следит за обменом: НРД не отвечает, перевод не доставлен{{if .DepoLicensed}}, ошибка обработки в DEPO{{end}}, истекает сертификат ключа. Чтобы об этом узнали сразу, а не открыв bj, — укажите почту. Резервные копии позволят восстановить сервер целиком (настройки, заявки, шлюз НРД) после сбоя или переустановки.<br>
<strong>Можно позже.</strong> Шаг необязательный — обмен работает и без него, но мы настоятельно советуем настроить оба пункта.</div> <strong>Можно позже.</strong> Шаг необязательный — обмен работает и без него, но мы настоятельно советуем настроить оба пункта.</div>
<style>.wiz9 label:not(:has(input[type=checkbox])){display:block;margin:0}.wiz9 label input:not([type=checkbox]),.wiz9 label select,.wiz9 .grid input,.wiz9 .grid select{width:100%;box-sizing:border-box;margin-top:4px}</style> <style>.wiz9 label:not(:has(input[type=checkbox])){display:block;margin:0}.wiz9 label input:not([type=checkbox]),.wiz9 label select,.wiz9 .grid input,.wiz9 .grid select{width:100%;box-sizing:border-box;margin-top:4px}</style>
<div class="wiz9"> <div class="wiz9">
@@ -568,11 +568,12 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
var tb=document.querySelector('#rd-tab tbody'), sum=document.getElementById('rd-sum'), dot=document.getElementById('rd-dot'); var tb=document.querySelector('#rd-tab tbody'), sum=document.getElementById('rd-sum'), dot=document.getElementById('rd-dot');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})} function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
var ic={ok:'<span style="color:var(--ok)">✓</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>',skip:'<span class="muted">—</span>'}; var ic={ok:'<span style="color:var(--ok)">✓</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>',skip:'<span class="muted">—</span>'};
function pl(n,one,few,many){var m=n%100,d=n%10;return (m>=11&&m<=14)?many:d===1?one:(d>=2&&d<=4)?few:many}
function load(){ sum.textContent='Проверяю…'; tb.innerHTML=''; function load(){ sum.textContent='Проверяю…'; tb.innerHTML='';
fetch('/admin/setup/readiness',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ fetch('/admin/setup/readiness',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
tb.innerHTML=d.checks.map(function(c){return '<tr><td style="width:24px;vertical-align:top">'+ic[c.state]+'</td><td style="vertical-align:top;white-space:nowrap;padding-right:10px"><b>'+esc(c.title)+'</b></td><td style="vertical-align:top">'+esc(c.detail)+(c.state!=='ok'&&c.link?' <a href="'+esc(c.link)+'">Исправить →</a>':'')+'</td></tr>'}).join(''); tb.innerHTML=d.checks.map(function(c){return '<tr><td style="width:24px;vertical-align:top">'+ic[c.state]+'</td><td style="vertical-align:top;white-space:nowrap;padding-right:10px"><b>'+esc(c.title)+'</b></td><td style="vertical-align:top">'+esc(c.detail)+(c.state!=='ok'&&c.link?' <a href="'+esc(c.link)+'">Исправить →</a>':'')+'</td></tr>'}).join('');
var red=d.checks.filter(function(c){return c.state==='err'}).length, yel=d.checks.filter(function(c){return c.state==='warn'}).length; var red=d.checks.filter(function(c){return c.state==='err'}).length, yel=d.checks.filter(function(c){return c.state==='warn'}).length;
sum.innerHTML=d.ready?(yel?'<span style="color:var(--ok)">✓ Обмен с НРД готов к работе</span> <span class="muted" style="font-weight:400">— желательно исправить: '+yel+'</span>':'<span style="color:var(--ok)">✓ Всё готово к работе</span>'):'<span style="color:var(--err)">✗ Не готово: обязательных проблем — '+red+'</span>'; sum.innerHTML=d.ready?(yel?'<span style="color:var(--ok)">✓ Обмен с НРД готов к работе</span> <span class="muted" style="font-weight:400">— желательно исправить ещё '+yel+' '+pl(yel,'пункт','пункта','пунктов')+' (отмечены ⚠)</span>':'<span style="color:var(--ok)">✓ Всё готово к работе</span>'):'<span style="color:var(--err)">✗ Пока не готово: осталось '+red+' '+pl(red,'обязательный пункт','обязательных пункта','обязательных пунктов')+' (отмечены ✗)</span>';
if(dot) dot.className='dot '+(d.ready?(yel?'warn':'ok'):'err'); if(dot) dot.className='dot '+(d.ready?(yel?'warn':'ok'):'err');
document.getElementById('rd-at').textContent='проверено '+d.at; document.getElementById('rd-at').textContent='проверено '+d.at;
}).catch(function(){ sum.textContent='Проверка не выполнилась — обновите страницу.'; }); } }).catch(function(){ sum.textContent='Проверка не выполнилась — обновите страницу.'; }); }
+1 -1
View File
@@ -393,7 +393,7 @@ details:has(> pre.copyable) { min-width:0; max-width:100%; }
<div class="banner-mock"> <div class="banner-mock">
<span class="dot warn" style="width:9px;height:9px;margin:0"></span> <span class="dot warn" style="width:9px;height:9px;margin:0"></span>
<div><strong style="color:var(--warn)">{{if devOn}}Режим эмуляции{{else}}Обмен с НРД не настроен{{end}}</strong> — {{if devOn}}реального обмена с НРД нет{{else}}поручения в НРД не отправляются{{end}}. <span class="muted">{{.MockReason}}</span></div> <div><strong style="color:var(--warn)">{{if devOn}}Режим эмуляции{{else}}Обмен с НРД не настроен{{end}}</strong> — {{if devOn}}реального обмена с НРД нет{{else}}поручения в НРД не отправляются{{end}}. <span class="muted">{{.MockReason}}</span></div>
<a href="/admin/wizard" style="margin-left:auto">Открыть мастер настройки →</a> {{if ne .Active "wizard"}}<a href="/admin/wizard" style="margin-left:auto">Открыть мастер настройки →</a>{{end}}
</div> </div>
{{end}} {{end}}
@@ -1,8 +1,8 @@
{{/* «Наша организация» — одна и та же в «Настройка → НРД» и в мастере настройки (данные — SetupData). */}} {{/* «Наша организация» — одна и та же в «Настройка → НРД» и в мастере настройки (данные — SetupData). */}}
{{define "orgSection"}} {{define "orgSection"}}
<div class="card" id="sec-org"> <div class="card" id="sec-org">
<h2><span class="dot {{if and .OrgINN .OrgCode}}ok{{else}}err{{end}}"></span>Наша организация</h2> {{if not .InWizard}}<h2><span class="dot {{if and .OrgINN .OrgCode}}ok{{else}}err{{end}}"></span>Наша организация</h2>
<p class="muted">Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах, код отправителя и код фирмы в QUIK. Выберите себя из справочника участников НРД или введите данные вручную.</p> <p class="muted">Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах{{if mod "quik-mnp"}}, код отправителя и код фирмы в QUIK{{end}}. Выберите себя из справочника участников НРД или введите данные вручную.</p>{{end}}
{{if and .OrgINN .OrgCode}}<p style="margin:8px 0 0"><b>{{if .OrgName}}{{.OrgName}}{{else}}—{{end}}</b> · ИНН <code>{{.OrgINN}}</code> · код <code>{{.OrgCode}}</code>{{if not .Settings.NSD.OrgINN}} <span class="muted">(ИНН взят из справочника по коду)</span>{{end}}</p> {{if and .OrgINN .OrgCode}}<p style="margin:8px 0 0"><b>{{if .OrgName}}{{.OrgName}}{{else}}—{{end}}</b> · ИНН <code>{{.OrgINN}}</code> · код <code>{{.OrgCode}}</code>{{if not .Settings.NSD.OrgINN}} <span class="muted">(ИНН взят из справочника по коду)</span>{{end}}</p>
{{else}}<p style="margin:8px 0 0;color:var(--err)">Организация не задана — переводы не будут сформированы.</p>{{end}} {{else}}<p style="margin:8px 0 0;color:var(--err)">Организация не задана — переводы не будут сформированы.</p>{{end}}
<form method="post" action="/admin/setup/org" style="margin-top:12px;display:grid;gap:10px;max-width:680px"> <form method="post" action="/admin/setup/org" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
+7 -1
View File
@@ -40,6 +40,7 @@ type InstallResult struct {
Release string `json:"release,omitempty"` Release string `json:"release,omitempty"`
Installed []map[string]string `json:"installed,omitempty"` Installed []map[string]string `json:"installed,omitempty"`
Restarted []string `json:"restarted,omitempty"` Restarted []string `json:"restarted,omitempty"`
Enabled []string `json:"enabled,omitempty"` // службы, включённые в автозапуск (впервые установленные модули)
} }
// InstallStaged — установить компоненты из каталога подготовки: manifest.json (подписанный), // InstallStaged — установить компоненты из каталога подготовки: manifest.json (подписанный),
@@ -129,6 +130,10 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
ts := time.Now().Format("20060102-150405") ts := time.Now().Format("20060102-150405")
var units, newUnits []string var units, newUnits []string
for _, j := range jobs { for _, j := range jobs {
// Модуля на сервере ещё не было — его службу включаем, даже если unit-файл уже стоит: установка
// с образа кладёт unit-файлы всех служб сразу (выключенными), а файл модуля приезжает обновлением.
_, statErr := os.Stat(j.c.PathAt(opt.Root))
fresh := statErr != nil
if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil { if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil {
return fail("установка %s: %v", j.c.Name, err) return fail("установка %s: %v", j.c.Name, err)
} }
@@ -140,7 +145,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
if err != nil { if err != nil {
return fail("служба %s: %v", j.c.Unit, err) return fail("служба %s: %v", j.c.Unit, err)
} }
if added { if added || fresh {
newUnits = append(newUnits, j.c.Unit) newUnits = append(newUnits, j.c.Unit)
} }
} }
@@ -153,6 +158,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
return fail("включение служб %s: %v: %s", strings.Join(newUnits, ", "), err, strings.TrimSpace(string(out))) return fail("включение служб %s: %v: %s", strings.Join(newUnits, ", "), err, strings.TrimSpace(string(out)))
} }
} }
res.Enabled = newUnits
for _, j := range jobs { for _, j := range jobs {
if j.c.Name == "bj-installer" && opt.Root == "" { if j.c.Name == "bj-installer" && opt.Root == "" {
// новые службы и скрипты из обновлённого установщика; перезапуск служб — отдельно ниже // новые службы и скрипты из обновлённого установщика; перезапуск служб — отдельно ниже
+23
View File
@@ -66,6 +66,29 @@ func TestInstallSplitModuleWithUnit(t *testing.T) {
if inst := LoadInstalled(filepath.Join(root, InstalledFile)); inst["bj-npr"].Version != "0.1.0" { if inst := LoadInstalled(filepath.Join(root, InstalledFile)); inst["bj-npr"].Version != "0.1.0" {
t.Fatalf("components.json: %+v", inst) t.Fatalf("components.json: %+v", inst)
} }
if len(res.Enabled) != 1 || res.Enabled[0] != "bj-npr" {
t.Fatalf("служба впервые установленного модуля должна включаться: %+v", res.Enabled)
}
// Повторная установка того же модуля: служба уже стоит и модуль был — автозапуск не трогаем
// (администратор мог выключить службу сам).
res = InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units})
if !res.OK || len(res.Enabled) != 0 {
t.Fatalf("повторная установка не должна включать службу: %+v", res)
}
// Установка с образа: unit-файл уже положен (выключенным), файла модуля нет — служба включается.
root2 := t.TempDir()
if err := os.MkdirAll(filepath.Join(root2, "/etc/systemd/system"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root2, "/etc/systemd/system/bj-npr.service"), []byte("[Service]\n"), 0o644); err != nil {
t.Fatal(err)
}
res = InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"],"new":["bj-npr"]}`), InstallOptions{Root: root2, PubKey: pub, Units: units})
if !res.OK || len(res.Enabled) != 1 || res.Enabled[0] != "bj-npr" {
t.Fatalf("unit-файл из образа: служба должна включиться при первой установке модуля: %+v", res)
}
// Самостоятельный модуль (не FromCore) по new не ставится. // Самостоятельный модуль (не FromCore) по new не ставится.
c, _ := ComponentByName("bj-lk") c, _ := ComponentByName("bj-lk")