Режим разработки: служебные разделы, робот НРД и имитации — только по признаку из ключа лицензии

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 18:15:18 +03:00
parent 698956092b
commit 627dbca6af
22 changed files with 157 additions and 38 deletions
+8
View File
@@ -53,8 +53,16 @@ type License struct {
// пока действует договор (ContractUntil). У офлайн-ключей оба поля пустые. // пока действует договор (ContractUntil). У офлайн-ключей оба поля пустые.
InstallID string `json:"install_id,omitempty"` InstallID string `json:"install_id,omitempty"`
ContractUntil time.Time `json:"contract_until,omitempty"` ContractUntil time.Time `json:"contract_until,omitempty"`
// DevUntil — режим разработки до указанного момента: сервер лицензий временно включает его
// установке, чтобы разработчики bj подключились к серверу клиента (тестовые разделы и
// инструменты). Указатель — чтобы у ключей без режима разработки подписанный JSON не менялся.
DevUntil *time.Time `json:"dev_until,omitempty"`
} }
// DevMode — действует ли режим разработки на момент now.
func (l *License) DevMode(now time.Time) bool { return l.DevUntil != nil && now.Before(*l.DevUntil) }
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка // Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно. // или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
type Token struct { type Token struct {
+2
View File
@@ -346,6 +346,7 @@ type page struct {
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
MockReason string // короткое описание почему mock MockReason string // короткое описание почему mock
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
} }
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers. // globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
@@ -704,6 +705,7 @@ func nowPage(title, active string) page {
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")} p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
if globalRC != nil { if globalRC != nil {
p.NPR = nprAvailable(globalRC) p.NPR = nprAvailable(globalRC)
p.DevMode = devMode(globalRC)
s := globalRC.Snapshot() s := globalRC.Snapshot()
switch { switch {
case s.NSD.IGWBaseURL == "": case s.NSD.IGWBaseURL == "":
+3
View File
@@ -37,6 +37,9 @@ func advancedUnlocked(r *http.Request) bool {
if globalRC != nil && moduleAllowed(globalRC, "quik-mnp") { if globalRC != nil && moduleAllowed(globalRC, "quik-mnp") {
return true return true
} }
if !devMode(globalRC) { // пароль на модули — инструмент разработчиков
return false
}
c, err := r.Cookie(advancedCookie) c, err := r.Cookie(advancedCookie)
if err != nil { if err != nil {
return false return false
+26 -2
View File
@@ -180,6 +180,9 @@ type User struct {
} }
func (u *User) Can(perm string) bool { func (u *User) Can(perm string) bool {
if perm == "dev" { // режим разработки: включает сервер лицензий, внутри — права настройки
return u != nil && devMode(globalRC) && u.Can("settings")
}
if u == nil || !permLicensed(perm) { if u == nil || !permLicensed(perm) {
return false return false
} }
@@ -809,16 +812,30 @@ func authExempt(p string) bool {
return true return true
case strings.HasPrefix(p, "/api/"): case strings.HasPrefix(p, "/api/"):
return true return true
case p == "/admin/api/callback-url": // служебный вызов эмулятора ЛК
return true
case strings.HasPrefix(p, "/admin/npr/static/"): case strings.HasPrefix(p, "/admin/npr/static/"):
return true return true
} }
return false return false
} }
// devOnlyPath — служебные разделы разработчиков (тестирование, имитации, прогоны с роботом НРД):
// доступны только в режиме разработки.
func devOnlyPath(p string) bool {
for _, pre := range []string{"/admin/m2m-testing", "/admin/incoming/depo-sim", "/admin/setup/test-nsd", "/admin/setup/test-run",
"/admin/setup/lk-simulate", "/admin/setup/postgres/quick-start", "/admin/help/robot", "/admin/api/clients", "/admin/api/callback-url",
"/admin/setup/advanced-unlock", "/admin/setup/advanced-lock"} {
if p == pre || strings.HasPrefix(p, pre+"/") {
return true
}
}
return false
}
// requiredPerm — какое право нужно для пути и метода. // requiredPerm — какое право нужно для пути и метода.
func requiredPerm(p, method string) string { func requiredPerm(p, method string) string {
if devOnlyPath(p) {
return "dev"
}
switch { switch {
case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard": case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard":
return "settings" return "settings"
@@ -853,6 +870,10 @@ func clientIP(r *http.Request) string {
func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler { func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path p := r.URL.Path
if devOnlyPath(p) && !devMode(rc) { // вне режима разработки служебных разделов нет вовсе
http.NotFound(w, r)
return
}
if authExempt(p) { if authExempt(p) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
@@ -1272,6 +1293,9 @@ func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
t2.Funcs(template.FuncMap{ t2.Funcs(template.FuncMap{
"curUser": func() *User { return u }, "curUser": func() *User { return u },
"can": func(perm string) bool { "can": func(perm string) bool {
if perm == "dev" {
return devMode(globalRC) && (authOpen() || u.Can("settings"))
}
if authOpen() { if authOpen() {
return true return true
} }
+32
View File
@@ -0,0 +1,32 @@
package lkgateway
import (
"net/http"
"net/http/httptest"
"testing"
)
// Служебные разделы разработчиков вне режима разработки отвечают 404 — даже при открытом входе.
func TestDevOnlyPaths(t *testing.T) {
for _, p := range []string{"/admin/m2m-testing", "/admin/m2m-testing/preview", "/admin/setup/test-run", "/admin/api/callback-url", "/admin/help/robot"} {
if !devOnlyPath(p) || requiredPerm(p, http.MethodGet) != "dev" {
t.Errorf("%s должен быть служебным", p)
}
}
for _, p := range []string{"/admin/m2m-testingx", "/admin/incoming", "/admin/setup", "/admin/help"} {
if devOnlyPath(p) {
t.Errorf("%s не служебный", p)
}
}
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
h := authMiddleware(rc, next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/m2m-testing", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("вне режима разработки: %d", rec.Code)
}
}
+21 -1
View File
@@ -24,7 +24,8 @@ type LicenseStatus struct {
ContractUntil time.Time ContractUntil time.Time
DaysLeft int DaysLeft int
AllowsUpdates bool AllowsUpdates bool
Features []string // признаки (модули), разрешённые ключом Features []string // признаки (модули), разрешённые ключом
DevUntil time.Time // режим разработки до (выдаёт сервер лицензий; нулевое — выключен)
Message string Message string
} }
@@ -71,6 +72,22 @@ func modulesAllowed(rc *RuntimeConfig) map[string]bool {
return m return m
} }
// devModeForced — режим разработки в тестах пакета.
var devModeForced bool
// devMode — режим разработки: сервер лицензий временно открыл разработчикам bj тестовые разделы и
// служебные инструменты на этой установке. Без него у клиента их нет (адреса отвечают 404).
func devMode(rc *RuntimeConfig) bool {
if devModeForced {
return true
}
if rc == nil {
return false
}
st := licenseStatus(rc)
return st.Valid && time.Now().Before(st.DevUntil)
}
// moduleAllowed — разрешён ли модуль (лицензионный признак). План enterprise — все модули. // moduleAllowed — разрешён ли модуль (лицензионный признак). План enterprise — все модули.
func moduleAllowed(rc *RuntimeConfig, feature string) bool { func moduleAllowed(rc *RuntimeConfig, feature string) bool {
st := licenseStatus(rc) st := licenseStatus(rc)
@@ -127,6 +144,9 @@ func licenseStatus(rc *RuntimeConfig) LicenseStatus {
st.DaysLeft = lic.DaysLeft(now) st.DaysLeft = lic.DaysLeft(now)
st.AllowsUpdates = lic.AllowsUpdates() st.AllowsUpdates = lic.AllowsUpdates()
st.Features = lic.Features st.Features = lic.Features
if lic.DevUntil != nil {
st.DevUntil = *lic.DevUntil
}
st.Online = lic.InstallID != "" st.Online = lic.InstallID != ""
if lic.Product != "" && lic.Product != "bj-server" { if lic.Product != "" && lic.Product != "bj-server" {
st.Message = "ключ выпущен для другого продукта (" + lic.Product + ")" st.Message = "ключ выпущен для другого продукта (" + lic.Product + ")"
+8 -4
View File
@@ -171,17 +171,21 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
return licenseAccept(rc, r.Token, "продлена") return licenseAccept(rc, r.Token, "продлена")
} }
// licenseNeedsRenew — онлайн-ключ, и сутки без успешного продления. // licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
// выключенный на сервере лицензий, доходит до установки быстро. После неудачи — не чаще раза в час.
func licenseNeedsRenew(rc *RuntimeConfig) bool { func licenseNeedsRenew(rc *RuntimeConfig) bool {
s := rc.Snapshot().License s := rc.Snapshot().License
lic, err := verifyLicenseToken(s.Key) lic, err := verifyLicenseToken(s.Key)
if err != nil || lic.InstallID == "" { if err != nil || lic.InstallID == "" {
return false return false
} }
return time.Since(s.LastSync) >= 23*time.Hour if !s.LastOK && !s.LastAttempt.IsZero() && time.Since(s.LastAttempt) < time.Hour && time.Since(s.LastSync) < 23*time.Hour {
return false
}
return time.Since(s.LastSync) >= 15*time.Minute
} }
// licenseLoop — раз в час предупреждает о скором окончании лицензии и проверяет, пора ли продлить // licenseLoop — раз в 5 минут предупреждает о скором окончании лицензии и проверяет, пора ли продлить
// ключ (продление — раз в сутки, при сбое — повтор через час). // ключ (продление — раз в сутки, при сбое — повтор через час).
func (s *Server) licenseLoop(ctx context.Context) { func (s *Server) licenseLoop(ctx context.Context) {
ensureInstallID(s.rc) ensureInstallID(s.rc)
@@ -192,7 +196,7 @@ func (s *Server) licenseLoop(ctx context.Context) {
return return
case <-time.After(wait): case <-time.After(wait):
} }
wait = time.Hour wait = 5 * time.Minute
licenseExpiryWarn(s.rc, time.Now()) licenseExpiryWarn(s.rc, time.Now())
if !licenseNeedsRenew(s.rc) { if !licenseNeedsRenew(s.rc) {
continue continue
+2 -1
View File
@@ -506,7 +506,8 @@ func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl,
// MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот // MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот
// читает сценарий из DocumentSeries). Прод-заявки этим маркером не // читает сценарий из DocumentSeries). Прод-заявки этим маркером не
// затрагиваются. Позволяет прогонять все сценарии через путь ЛК. // затрагиваются. Позволяет прогонять все сценарии через путь ЛК.
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" { // Только в режиме разработки: у клиентов маршрута к роботу нет.
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" && devMode(rc) {
doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]} doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]}
req.Investor.Document = doc req.Investor.Document = doc
req.InvestorDocumentOverride = &doc req.InvestorDocumentOverride = &doc
+1 -1
View File
@@ -35,7 +35,7 @@ var lkEmuSender *mock.Sender
// lkEmulateNSD — идут ли заявки ЛК через имитатор НРД и почему. // lkEmulateNSD — идут ли заявки ЛК через имитатор НРД и почему.
func lkEmulateNSD(rc *RuntimeConfig) (bool, string) { func lkEmulateNSD(rc *RuntimeConfig) (bool, string) {
s := rc.Snapshot() s := rc.Snapshot()
if s.LK.EmulateNSD { if s.LK.EmulateNSD && devMode(rc) {
return true, "включена в настройках" return true, "включена в настройках"
} }
if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") { if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") {
+5
View File
@@ -55,6 +55,11 @@ func TestLKEmulateNSD(t *testing.T) {
} }
s.LK.EmulateNSD = true s.LK.EmulateNSD = true
_ = rc.UpdateLK(s.LK) _ = rc.UpdateLK(s.LK)
if on, _ := lkEmulateNSD(rc); on {
t.Error("флаг в настройках без режима разработки не действует")
}
devModeForced = true
defer func() { devModeForced = false }()
if on, why := lkEmulateNSD(rc); !on || why != "включена в настройках" { if on, why := lkEmulateNSD(rc); !on || why != "включена в настройках" {
t.Errorf("флаг в настройках: %v %q", on, why) t.Errorf("флаг в настройках: %v %q", on, why)
} }
+2 -1
View File
@@ -204,7 +204,8 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
// ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД // ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД
// (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер // (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер
// документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД. // документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД.
if _, isRobot := robotScenarios[u.DocSeries]; isRobot { // Только в режиме разработки: у реальных паспортов такие серии тоже встречаются.
if _, isRobot := robotScenarios[u.DocSeries]; isRobot && devMode(rc) {
claim.ReceiverCodeOverride = NSDRobotCode claim.ReceiverCodeOverride = NSDRobotCode
claim.InvestorDocumentOverride = &doc claim.InvestorDocumentOverride = &doc
} }
+3 -3
View File
@@ -31,7 +31,7 @@ var m2mRejectCatalog = []m2mCodeInfo{
{"M2M08", "Операция нарушит законодательство РФ", {"M2M08", "Операция нарушит законодательство РФ",
"Проведение перевода приведёт к нарушению закона: например, бумага предназначена только для квалифицированных инвесторов, а инвестор не квалифицирован."}, "Проведение перевода приведёт к нарушению закона: например, бумага предназначена только для квалифицированных инвесторов, а инвестор не квалифицирован."},
{"M2M09", "Отказ принимающей стороны (доп.)", {"M2M09", "Отказ принимающей стороны (доп.)",
"Дополнительный код отказа зачисляющей стороны (используется в тест-сценариях)."}, "Дополнительный код отказа зачисляющей стороны."},
} }
// m2mServiceCatalog — коды сервиса МОСТ (ошибки/информирование). Приходят в ответе // m2mServiceCatalog — коды сервиса МОСТ (ошибки/информирование). Приходят в ответе
@@ -107,9 +107,9 @@ var m2mCodeTexts = map[string]string{
"M2M04": "Инвестор не найден", "M2M04": "Инвестор не найден",
"M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на ценную бумагу", "M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на ценную бумагу",
"M2M06": "Приостановление операций по счёту депо зачисления", "M2M06": "Приостановление операций по счёту депо зачисления",
"M2M07": "Отказ принимающей стороны (тест 07)", "M2M07": "Отказ принимающей стороны",
"M2M08": "Проведение операции приведёт к нарушению законодательства РФ", "M2M08": "Проведение операции приведёт к нарушению законодательства РФ",
"M2M09": "Отказ принимающей стороны (тест 09)", "M2M09": "Отказ принимающей стороны (дополнительный код)",
"M2M10": "Получено невалидное сообщение", "M2M10": "Получено невалидное сообщение",
"M2M14": "Отправитель не зарегистрирован в справочнике участников M2M", "M2M14": "Отправитель не зарегистрирован в справочнике участников M2M",
"M2M15": "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников M2M", // предположительно (пара к M2M14); уточнить по Спецификации ЭД "M2M15": "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников M2M", // предположительно (пара к M2M14); уточнить по Спецификации ЭД
+8 -2
View File
@@ -466,8 +466,14 @@ func quikResolveClient(ctx context.Context, rc *RuntimeConfig, ser, num string)
// ---- высокоуровневые операции цикла ---- // ---- высокоуровневые операции цикла ----
// quikActive — цикл M2M в МНП работает: записи разрешены или включена имитация. // quikActive — цикл M2M в МНП работает: записи разрешены или включена имитация. Имитация — только в
func quikActive(s QuikMNPSettings) bool { return s.WritesEnabled || s.Emulate } // режиме разработки; если он закончился при включённой имитации, цикл стоит (в МНП не пишем).
func quikActive(s QuikMNPSettings) bool {
if s.Emulate {
return devMode(globalRC)
}
return s.WritesEnabled
}
// quikEmulated — шаг цикла в режиме имитации: связка переходит в состояние state, в МНП ничего не // quikEmulated — шаг цикла в режиме имитации: связка переходит в состояние state, в МНП ничего не
// пишется (нет пользователя QUIK для блокировок и лимитов); события в DEPO — как при реальной работе. // пишется (нет пользователя QUIK для блокировок и лимитов); события в DEPO — как при реальной работе.
+3 -1
View File
@@ -86,7 +86,9 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) {
} }
s := h.rc.Snapshot().Quik s := h.rc.Snapshot().Quik
s.WritesEnabled = r.FormValue("writes_enabled") != "" s.WritesEnabled = r.FormValue("writes_enabled") != ""
s.Emulate = r.FormValue("emulate") != "" if devMode(h.rc) { // флажок имитации есть только в режиме разработки
s.Emulate = r.FormValue("emulate") != ""
}
s.AutoOut = r.FormValue("auto_out") != "" s.AutoOut = r.FormValue("auto_out") != ""
s.AutoIn = r.FormValue("auto_in") != "" s.AutoIn = r.FormValue("auto_in") != ""
s.PollTerminal = r.FormValue("poll_terminal") != "" s.PollTerminal = r.FormValue("poll_terminal") != ""
+9
View File
@@ -249,6 +249,15 @@ func (s *Server) Run(ctx context.Context) error {
// Монитор здоровья + systemd watchdog: следит за фоновыми горутинами, // Монитор здоровья + systemd watchdog: следит за фоновыми горутинами,
// поднимает алерт при зависании и «пинает» systemd, пока всё живо. // поднимает алерт при зависании и «пинает» systemd, пока всё живо.
healthRegister("poll-lk", time.Minute) healthRegister("poll-lk", time.Minute)
// Ключ продлеваем до запуска фоновых циклов: режим разработки (имитации и т.п.) должен быть
// известен им с первого прохода.
if licenseNeedsRenew(s.rc) {
lctx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := LicenseRenew(lctx, s.rc); err != nil {
log.Printf("lk-gateway: лицензия при запуске: %v", err)
}
cancel()
}
go s.superviseHealth(ctx) go s.superviseHealth(ctx)
// Данные модуля личного кабинета — схема lk в PostgreSQL bj. // Данные модуля личного кабинета — схема lk в PostgreSQL bj.
+3 -1
View File
@@ -522,7 +522,9 @@ func dealToView(d *m2mcore.Deal) ClaimView {
out.LastCallback = &cb out.LastCallback = &cb
} }
// Самопроверка тестовой заявки роботу: серия документа = код сценария. // Самопроверка тестовой заявки роботу: серия документа = код сценария.
out.TestResult = buildTestResult(out.Investor.Document.Series, out.M2MDecision, out.M2MResponse) if devMode(globalRC) { // сценарии робота — только в режиме разработки: такие серии бывают у реальных паспортов
out.TestResult = buildTestResult(out.Investor.Document.Series, out.M2MDecision, out.M2MResponse)
}
return out return out
} }
+2 -2
View File
@@ -185,7 +185,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "" d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != ""
d.Done.Certs = len(d.Certs) > 0 d.Done.Certs = len(d.Certs) > 0
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
d.Done.TestRun = s.LastTest != nil d.Done.TestRun = s.LastTest != nil || (!d.DevMode && d.Done.NSD) // вне режима разработки тестовой заявки нет
// Определяем текущий шаг. // Определяем текущий шаг.
step := 1 step := 1
@@ -612,7 +612,7 @@ func (h *setupHandlers) saveLKIntegration(w http.ResponseWriter, r *http.Request
if a := r.FormValue("active_env"); a == "test" || a == "prod" { if a := r.FormValue("active_env"); a == "test" || a == "prod" {
s.ActiveEnv = a s.ActiveEnv = a
} }
if r.FormValue("emulate_nsd_form") != "" { // флажок есть только в форме активного окружения if r.FormValue("emulate_nsd_form") != "" && devMode(h.rc) { // флажок — в форме активного окружения, в режиме разработки
s.EmulateNSD = r.FormValue("emulate_nsd") != "" s.EmulateNSD = r.FormValue("emulate_nsd") != ""
} }
if err := h.rc.UpdateLK(s); err != nil { if err := h.rc.UpdateLK(s); err != nil {
@@ -29,12 +29,12 @@
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p> <p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
</div> </div>
</a> </a>
<a href="/admin/help/robot" style="text-decoration:none"> {{if .DevMode}}<a href="/admin/help/robot" style="text-decoration:none">
<div class="card" style="height:100%"> <div class="card" style="height:100%">
<h2 style="color:var(--accent)">Тестирование с роботом MOEX МОСТ →</h2> <h2 style="color:var(--accent)">Тестирование с роботом MOEX МОСТ →</h2>
<p class="muted">Робот НРД на TEST3 (код <code>MC0012500000</code>), 4 тестовых сценария (отказ / принять все / частично / встречный перевод), управление через DocumentSeries и DocumentNumber, тестовые наборы депозитариев и кодов ошибок.</p> <p class="muted">Робот НРД на TEST3 (код <code>MC0012500000</code>), 4 тестовых сценария (отказ / принять все / частично / встречный перевод), управление через DocumentSeries и DocumentNumber, тестовые наборы депозитариев и кодов ошибок.</p>
</div> </div>
</a> </a>{{end}}
<a href="/admin/help/architecture" style="text-decoration:none"> <a href="/admin/help/architecture" style="text-decoration:none">
<div class="card" style="height:100%"> <div class="card" style="height:100%">
<h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2> <h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2>
@@ -36,10 +36,10 @@
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p> <p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}} {{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap"> <div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
<form method="post" action="/admin/setup/test-nsd" style="margin:0"> {{if .DevMode}}<form method="post" action="/admin/setup/test-nsd" style="margin:0">
<input type="hidden" name="scenario" value="2001"> <input type="hidden" name="scenario" value="2001">
<button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button> <button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button>
</form> </form>{{else}}<a href="/admin/status" class="btn btn-ok">→ Проверить подключения</a>{{end}}
<form method="post" action="/admin/setup/keywizard/reset" style="margin:0"> <form method="post" action="/admin/setup/keywizard/reset" style="margin:0">
<button type="submit" class="btn btn-secondary">Установить ещё один ключ</button> <button type="submit" class="btn btn-secondary">Установить ещё один ключ</button>
</form> </form>
@@ -18,7 +18,7 @@
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button> <button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button> <button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
<button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button> <button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
<button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button> {{if .DevMode}} <button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
</nav> </nav>
<div class="settings-body"> <div class="settings-body">
@@ -59,7 +59,7 @@
<div class="card"> <div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2> <h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Журнал сделок m2m-core и принимающая БД. Сейчас: {{if .Settings.Postgres.DSN}}<code>подключено</code>{{else}}<code>in-memory</code> (данные не сохраняются){{end}}.</p> <p class="muted">Журнал сделок m2m-core и принимающая БД. Сейчас: {{if .Settings.Postgres.DSN}}<code>подключено</code>{{else}}<code>in-memory</code> (данные не сохраняются){{end}}.</p>
{{if not .Settings.Postgres.DSN}} {{if and .DevMode (not .Settings.Postgres.DSN)}}
<div style="background:var(--card-2);border:1px solid var(--accent);border-radius:10px;padding:16px;margin-top:12px"> <div style="background:var(--card-2);border:1px solid var(--accent);border-radius:10px;padding:16px;margin-top:12px">
<h3 style="margin:0 0 8px">Подключить автоматически</h3> <h3 style="margin:0 0 8px">Подключить автоматически</h3>
<p class="muted" style="margin:0 0 12px">Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.</p> <p class="muted" style="margin:0 0 12px">Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.</p>
@@ -227,16 +227,16 @@
</div> </div>
<div class="card" id="sec-quik-cycle"> <div class="card" id="sec-quik-cycle">
<h2><span class="dot {{if .Settings.Quik.Emulate}}warn{{else if not .Settings.Quik.WritesEnabled}}warn{{else if or .Settings.Quik.AutoOut .Settings.Quik.AutoIn}}ok{{else}}warn{{end}}"></span>Автоматический цикл M2M в МНП{{if .Settings.Quik.Emulate}} <span class="hero-status warn">имитация</span>{{end}}</h2> <h2><span class="dot {{if .Settings.Quik.Emulate}}warn{{else if not .Settings.Quik.WritesEnabled}}warn{{else if or .Settings.Quik.AutoOut .Settings.Quik.AutoIn}}ok{{else}}warn{{end}}"></span>Автоматический цикл M2M в МНП{{if .Settings.Quik.Emulate}} <span class="hero-status warn">{{if .DevMode}}имитация{{else}}остановлен{{end}}</span>{{end}}</h2>
<p class="muted">Что делает bj в Модуле неторговых поручений по ходу перевода: <strong>исходящий</strong> — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; <strong>входящий</strong> — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>.</p> <p class="muted">Что делает bj в Модуле неторговых поручений по ходу перевода: <strong>исходящий</strong> — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; <strong>входящий</strong> — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>.</p>
<form method="post" action="/admin/setup/quik/cycle" style="margin-top:12px;display:grid;gap:10px;max-width:640px"> <form method="post" action="/admin/setup/quik/cycle" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<input type="hidden" name="section" value="sec-quik-cycle"> <input type="hidden" name="section" value="sec-quik-cycle">
<label style="display:flex;gap:8px;align-items:center;font-weight:600"> <label style="display:flex;gap:8px;align-items:center;font-weight:600">
<input type="checkbox" name="writes_enabled" value="1" {{if .Settings.Quik.WritesEnabled}}checked{{end}}> <input type="checkbox" name="writes_enabled" value="1" {{if .Settings.Quik.WritesEnabled}}checked{{end}}>
Разрешить записи в МНП (главный предохранитель — база боевая)</label> Разрешить записи в МНП (главный предохранитель — база боевая)</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:600"> {{if .DevMode}} <label style="display:flex;gap:8px;align-items:center;font-weight:600">
<input type="checkbox" name="emulate" value="1" {{if .Settings.Quik.Emulate}}checked{{end}}> <input type="checkbox" name="emulate" value="1" {{if .Settings.Quik.Emulate}}checked{{end}}>
Имитация: шаги цикла (блокировка, подпись, снятие, зачисление) проходят и отмечаются «имитация», в МНП ничего не пишется — пока нет пользователя QUIK для блокировок и лимитов. Имеет приоритет над записью.</label> Имитация: шаги цикла (блокировка, подпись, снятие, зачисление) проходят и отмечаются «имитация», в МНП ничего не пишется — пока нет пользователя QUIK для блокировок и лимитов. Имеет приоритет над записью.</label>{{end}}
<label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-left:22px"> <label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-left:22px">
<input type="checkbox" name="auto_out" value="1" {{if .Settings.Quik.AutoOut}}checked{{end}}> <input type="checkbox" name="auto_out" value="1" {{if .Settings.Quik.AutoOut}}checked{{end}}>
Исходящие переводы: регистрировать при отправке, подписывать / снимать по ответу автоматически</label> Исходящие переводы: регистрировать при отправке, подписывать / снимать по ответу автоматически</label>
@@ -616,12 +616,12 @@
<option value="prod" {{if eq .Settings.LK.ActiveEnv "prod"}}selected{{end}}>Бой</option> <option value="prod" {{if eq .Settings.LK.ActiveEnv "prod"}}selected{{end}}>Бой</option>
</select> </select>
<span class="muted" style="font-size:12px">bj работает с ЛК через выбранное окружение.</span> <span class="muted" style="font-size:12px">bj работает с ЛК через выбранное окружение.</span>
<input type="hidden" name="emulate_nsd_form" value="1"> {{if .DevMode}} <input type="hidden" name="emulate_nsd_form" value="1">
<label style="display:flex;align-items:flex-start;gap:8px;font-weight:400;flex-basis:100%;margin-top:6px"><input type="checkbox" name="emulate_nsd" style="width:auto;margin-top:3px" {{if .Settings.LK.EmulateNSD}}checked{{end}} onchange="this.form.submit()"> <span><b>Имитация НРД</b> — заявки ЛК проходят сверку с DEPO, блокировку в QUIK и получают статус в ЛК, но в НРД не отправляются: ответ контрагента имитирует bj.{{if and (eq .Settings.LK.ActiveEnv "prod") (not .Settings.LK.EmulateNSD)}} <span style="color:var(--warn)">Сейчас включена принудительно: боевой ЛК при тестовом профиле ИШ ({{.Settings.NSD.Profile}}).</span>{{end}}</span></label> <label style="display:flex;align-items:flex-start;gap:8px;font-weight:400;flex-basis:100%;margin-top:6px"><input type="checkbox" name="emulate_nsd" style="width:auto;margin-top:3px" {{if .Settings.LK.EmulateNSD}}checked{{end}} onchange="this.form.submit()"> <span><b>Имитация НРД</b> — заявки ЛК проходят сверку с DEPO, блокировку в QUIK и получают статус в ЛК, но в НРД не отправляются: ответ контрагента имитирует bj.{{if and (eq .Settings.LK.ActiveEnv "prod") (not .Settings.LK.EmulateNSD)}} <span style="color:var(--warn)">Сейчас включена принудительно: боевой ЛК при тестовом профиле ИШ ({{.Settings.NSD.Profile}}).</span>{{end}}</span></label>{{end}}
</form> </form>
</div> </div>
<div class="card"> {{if .DevMode}} <div class="card">
<h2>Симулятор заявки M2M</h2> <h2>Симулятор заявки M2M</h2>
<p class="muted">Пока подача через веб-форму у ЕСИА Финанс не готова — bj сам собирает заявку <code>depo_m2m</code> и <strong>отправляет её в API ЛК</strong> (<code>POST /claims/</code>) через активное окружение. Ответ показывается ниже; затем заявку ловим опросом в «Переводы». Поля предзаполнены рабочим тестовым договором.</p> <p class="muted">Пока подача через веб-форму у ЕСИА Финанс не готова — bj сам собирает заявку <code>depo_m2m</code> и <strong>отправляет её в API ЛК</strong> (<code>POST /claims/</code>) через активное окружение. Ответ показывается ниже; затем заявку ловим опросом в «Переводы». Поля предзаполнены рабочим тестовым договором.</p>
<form method="post" action="/admin/setup/lk-simulate" style="margin-top:12px"> <form method="post" action="/admin/setup/lk-simulate" style="margin-top:12px">
@@ -657,7 +657,7 @@
<div style="margin-top:16px"><button type="submit" class="btn">Собрать и отправить в ЛК</button></div> <div style="margin-top:16px"><button type="submit" class="btn">Собрать и отправить в ЛК</button></div>
</form> </form>
<p class="muted" style="font-size:12px;margin-top:10px">Результат (сырой ответ API) появится вверху страницы. Если «Раздел счёта не доступен для быстрых переводов» — Финансы должны включить признак M2M на разделе счёта.</p> <p class="muted" style="font-size:12px;margin-top:10px">Результат (сырой ответ API) появится вверху страницы. Если «Раздел счёта не доступен для быстрых переводов» — Финансы должны включить признак M2M на разделе счёта.</p>
</div> </div>{{end}}
<div class="card"> <div class="card">
<h2>Забрать заявки из ЛК</h2> <h2>Забрать заявки из ЛК</h2>
@@ -702,7 +702,7 @@
<p class="muted" style="font-size:12px;margin-top:8px">Ответ придёт во входящие ИШ (~30-60с). Список появится в логе bj-server и в <code>m2m-handbook.json</code>.</p> <p class="muted" style="font-size:12px;margin-top:8px">Ответ придёт во входящие ИШ (~30-60с). Список появится в логе bj-server и в <code>m2m-handbook.json</code>.</p>
</div> </div>
<div class="card"> {{if .DevMode}} <div class="card">
<h2>Тестовый пакет роботу НРД</h2> <h2>Тестовый пакет роботу НРД</h2>
<p class="muted">Робот <code>MC0012500000</code> эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.</p> <p class="muted">Робот <code>MC0012500000</code> эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.</p>
<form method="post" action="/admin/setup/test-nsd" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center"> <form method="post" action="/admin/setup/test-nsd" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
@@ -734,7 +734,7 @@
</table> </table>
</div> </div>
{{end}} {{end}}
</div> </div>{{end}}
</section> </section>
{{/* ============ ОПОВЕЩЕНИЯ ============ */}} {{/* ============ ОПОВЕЩЕНИЯ ============ */}}
@@ -961,7 +961,7 @@
</div> </div>
</section> </section>
{{/* ============ ПЕРСПЕКТИВНЫЕ МОДУЛИ (под паролем) ============ */}} {{if .DevMode}} {{/* ============ ПЕРСПЕКТИВНЫЕ МОДУЛИ (под паролем) ============ */}}
<section class="settings-section" id="sec-advanced"> <section class="settings-section" id="sec-advanced">
<h1>Перспективные модули</h1> <h1>Перспективные модули</h1>
<p class="muted" style="max-width:64ch">Модули для будущих профилей внедрения <strong>«автомат»</strong> и <strong>«полуавтомат»</strong> (когда используется собственный личный кабинет, а не ЕСИА Финанс). Пока не в проде — скрыты, чтобы не мешать текущей работе. В профиле «через Финансы» они не нужны: проверку и блокировку бумаг закрывает сам ЕСИА Финанс.</p> <p class="muted" style="max-width:64ch">Модули для будущих профилей внедрения <strong>«автомат»</strong> и <strong>«полуавтомат»</strong> (когда используется собственный личный кабинет, а не ЕСИА Финанс). Пока не в проде — скрыты, чтобы не мешать текущей работе. В профиле «через Финансы» они не нужны: проверку и блокировку бумаг закрывает сам ЕСИА Финанс.</p>
@@ -992,7 +992,7 @@
</form> </form>
</div> </div>
{{end}} {{end}}
</section> </section>{{end}}
</div> </div>
</div> </div>
@@ -47,7 +47,7 @@
<strong>Что выбрать?</strong> Если у вас уже есть рабочий PostgreSQL — нажмите «У меня уже есть PostgreSQL» и введите DSN. Если впервые настраиваете — выберите «Поднять автоматически», система сама развернёт контейнер с PostgreSQL и накатит миграции. <strong>Что выбрать?</strong> Если у вас уже есть рабочий PostgreSQL — нажмите «У меня уже есть PostgreSQL» и введите DSN. Если впервые настраиваете — выберите «Поднять автоматически», система сама развернёт контейнер с PostgreSQL и накатит миграции.
</div> </div>
{{if not .Settings.Postgres.DSN}} {{if and .DevMode (not .Settings.Postgres.DSN)}}
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px"> <div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
<h3 style="margin:0 0 8px 0;font-size:15px">Вариант А — для тех, у кого нет своего PostgreSQL</h3> <h3 style="margin:0 0 8px 0;font-size:15px">Вариант А — для тех, у кого нет своего PostgreSQL</h3>
<p class="muted" style="margin:0 0 10px 0">Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД <code>bj</code> и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.</p> <p class="muted" style="margin:0 0 10px 0">Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД <code>bj</code> и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.</p>
+1 -1
View File
@@ -287,7 +287,7 @@ label { font-size:13px; font-weight:500; }
<a href="/admin/incoming">Входящие<span id="nav-incoming-badge" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></a> <a href="/admin/incoming">Входящие<span id="nav-incoming-badge" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></a>
</span> </span>
</span> </span>
{{end}}{{if can "operate"}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}} {{end}}{{if and .DevMode (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
{{if and .NPR (can "npr")}}<a href="/admin/npr" class="{{if eq .Active "npr"}}active{{end}}">НПР / УДС</a>{{end}} {{if and .NPR (can "npr")}}<a href="/admin/npr" class="{{if eq .Active "npr"}}active{{end}}">НПР / УДС</a>{{end}}
{{if or (can "view") (can "quik")}}<span class="dd{{if or (eq .Active "fancy") (eq .Active "quik") (eq .Active "news") (eq .Active "handbook")}} active{{end}}"> {{if or (can "view") (can "quik")}}<span class="dd{{if or (eq .Active "fancy") (eq .Active "quik") (eq .Active "news") (eq .Active "handbook")}} active{{end}}">
<span class="ddbtn">Обмен ▾</span> <span class="ddbtn">Обмен ▾</span>