diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go
index 6095202..7d3f6cf 100644
--- a/cmd/bj-installer/main.go
+++ b/cmd/bj-installer/main.go
@@ -609,8 +609,15 @@ func (in *installer) stepServices() (string, error) {
if err != nil {
return "", err
}
+ enable := []string{"enable", "bj-keymedia.service", "bj-server.service"}
+ // службы установленных модулей (bj-npr и т. п.), для которых есть unit-файл
+ for name := range release.LoadInstalled(in.P(release.InstalledFile)) {
+ if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) {
+ enable = append(enable, c.Unit+".service")
+ }
+ }
if in.root == "" {
- for _, c := range [][]string{{"daemon-reload"}, {"enable", "bj-keymedia.service", "bj-server.service"}} {
+ for _, c := range [][]string{{"daemon-reload"}, enable} {
if out, err := run("systemctl", c...); err != nil {
return "", fmt.Errorf("systemctl %s: %v %s", strings.Join(c, " "), err, out)
}
@@ -625,7 +632,13 @@ func (in *installer) stepFirstRun() (string, error) {
return "", err
}
if in.root == "" {
- for _, unit := range []string{"bj-keymedia", "bj-server"} {
+ units := []string{"bj-keymedia", "bj-server"}
+ for name := range release.LoadInstalled(in.P(release.InstalledFile)) {
+ if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) {
+ units = append(units, c.Unit)
+ }
+ }
+ for _, unit := range units {
if out, err := run("systemctl", "restart", unit); err != nil {
return "", fmt.Errorf("запуск %s: %v %s", unit, err, out)
}
diff --git a/cmd/bj-npr/main.go b/cmd/bj-npr/main.go
new file mode 100644
index 0000000..5673800
--- /dev/null
+++ b/cmd/bj-npr/main.go
@@ -0,0 +1,44 @@
+// Command bj-npr — служба модуля «НПР / УДС» (modules/npr), обновляется независимо от ядра bj.
+//
+// Слушает только локальный адрес: пользователи работают через bj-server, который проверяет вход,
+// права и лицензию и проксирует раздел /admin/npr со служебным ключом.
+//
+// bj-npr [-addr 127.0.0.1:8083] [-setup /var/lib/bj/.bj/setup.json] [-key /var/lib/bj/.bj/module.key]
+// bj-npr -version
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/npr"
+)
+
+// version — версия модуля (go build -ldflags "-X main.version=…").
+var version = "0.1.0"
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:8083", "адрес службы (только локальный)")
+ setup := flag.String("setup", "/var/lib/bj/.bj/setup.json", "настройки bj (раздел npr)")
+ key := flag.String("key", "/var/lib/bj/.bj/module.key", "служебный ключ модулей (создаёт bj-server)")
+ showVersion := flag.Bool("version", false, "показать версию")
+ flag.Parse()
+ if *showVersion {
+ fmt.Println("bj-npr", version)
+ return
+ }
+ srv, err := npr.New(npr.Config{SetupPath: *setup, KeyPath: *key})
+ if err != nil {
+ log.Fatalf("bj-npr: %v", err)
+ }
+ log.Printf("bj-npr %s: слушаю %s", version, *addr)
+ hs := &http.Server{Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
+ if err := hs.ListenAndServe(); err != nil {
+ log.Printf("bj-npr: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/deploy/embed.go b/deploy/embed.go
index 030f608..ba9a9f0 100644
--- a/deploy/embed.go
+++ b/deploy/embed.go
@@ -6,5 +6,5 @@ import "embed"
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
//
-//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service
+//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service
var Files embed.FS
diff --git a/deploy/systemd/bj-npr.service b/deploy/systemd/bj-npr.service
new file mode 100644
index 0000000..6a45fe5
--- /dev/null
+++ b/deploy/systemd/bj-npr.service
@@ -0,0 +1,24 @@
+[Unit]
+Description=Bridge&Join: модуль «НПР / УДС» (риск-монитор по данным QUIK)
+Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=bj
+Group=bj
+ExecStart=/opt/bj/bj-npr -addr 127.0.0.1:8083 -setup /var/lib/bj/.bj/setup.json -key /var/lib/bj/.bj/module.key
+Restart=on-failure
+RestartSec=5
+StandardOutput=append:/var/log/bj/bj-npr.log
+StandardError=append:/var/log/bj/bj-npr.err
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/log/bj
+ReadOnlyPaths=/var/lib/bj
+
+[Install]
+WantedBy=multi-user.target
diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
index 171c5d5..8989ed0 100644
--- a/internal/lkgateway/admin.go
+++ b/internal/lkgateway/admin.go
@@ -22,13 +22,13 @@ var templatesFS embed.FS
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
- home, claims, claim, status, setup *template.Template
- help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template
- incoming, incomingDetail, fancyQueue, quikInstr *template.Template
- wizard, news, keyWizard *template.Template
- handbook *template.Template
- m2mtest, m2mpreview, depoSim *template.Template
- journal *template.Template
+ home, claims, claim, status, setup *template.Template
+ help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template
+ incoming, incomingDetail, fancyQueue, quikInstr *template.Template
+ wizard, news, keyWizard *template.Template
+ handbook *template.Template
+ m2mtest, m2mpreview, depoSim *template.Template
+ journal *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
@@ -51,7 +51,6 @@ var templateFuncs = template.FuncMap{
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
"m2mCode": m2mCodeLabel,
"quikCodeMap": quikClientCodeMapText,
- "nprF": nprF,
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"authOpen": authOpen,
@@ -277,20 +276,13 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
}
- parseNPR := func(content string) (*template.Template, error) {
- return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content)
+ // Страница модуля (служба модуля отдаёт содержимое, макет и меню — bj): npr.go.
+ modulePage, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html")
+ if err == nil {
+ _, err = modulePage.New("content").Parse(`{{.Content}}`)
}
- nprT, err := parseNPR("admin_npr.html")
if err != nil {
- return nil, fmt.Errorf("parse admin_npr: %w", err)
- }
- nprC, err := parseNPR("admin_npr_client.html")
- if err != nil {
- return nil, fmt.Errorf("parse admin_npr_client: %w", err)
- }
- nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html")
- if err != nil {
- return nil, fmt.Errorf("parse admin_npr_rows: %w", err)
+ return nil, fmt.Errorf("parse module page: %w", err)
}
keyWizard, err := parse("admin_keywizard.html")
if err != nil {
@@ -335,7 +327,7 @@ func newAdmin() (*admin, error) {
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
- helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR,
+ helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage,
wizard: wizard, news: news, keyWizard: keyWizard,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
diff --git a/internal/lkgateway/npr.go b/internal/lkgateway/npr.go
index d091c76..8095115 100644
--- a/internal/lkgateway/npr.go
+++ b/internal/lkgateway/npr.go
@@ -1,32 +1,28 @@
package lkgateway
-// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли поверх базы
-// экспорта QUIK (quik_export, схема npr). Перенесён «как есть» с 10.10.10.23
-// (FastAPI ui/app.py) 14.09.2026; читает базу логином только для чтения.
-// Включается настройкой NPR.Enabled и лицензионным признаком "npr".
+// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли по базе экспорта QUIK.
+// Сам модуль работает отдельной службой bj-npr (modules/npr, обновляется независимо). Здесь —
+// его настройки и проверка подключения, а раздел /admin/npr bj-server проксирует службе: проверяет
+// включение и лицензию (права — authMiddleware), передаёт служебный ключ и встраивает полные
+// страницы модуля в свой макет. Если служба не отвечает, показывает понятную страницу.
import (
"context"
- "embed"
- "encoding/csv"
- "encoding/json"
+ "crypto/rand"
+ "encoding/hex"
"fmt"
- "io/fs"
- "log"
- "math"
+ "html/template"
+ "io"
"net/http"
- "strconv"
+ "net/url"
+ "os"
"strings"
"sync"
"time"
- "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
-//go:embed web/static/npr/*
-var nprStaticFS embed.FS
-
// NPRSettings — модуль НПР/УДС.
type NPRSettings struct {
Enabled bool `json:"enabled"`
@@ -50,6 +46,125 @@ func nprAvailable(rc *RuntimeConfig) bool {
return s.Enabled && strings.TrimSpace(s.DSN) != "" && moduleAllowed(rc, "npr")
}
+// ---- служба модуля ----
+
+const moduleKeyFile = "/var/lib/bj/.bj/module.key"
+
+var (
+ moduleKeyMu sync.Mutex
+ nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+)
+
+// moduleKey — служебный ключ для служб модулей (создаётся один раз, читается службами от того же пользователя).
+func moduleKey() (string, error) {
+ moduleKeyMu.Lock()
+ defer moduleKeyMu.Unlock()
+ if b, err := os.ReadFile(moduleKeyFile); err == nil && len(strings.TrimSpace(string(b))) >= 32 {
+ return strings.TrimSpace(string(b)), nil
+ }
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ key := hex.EncodeToString(buf)
+ if err := os.WriteFile(moduleKeyFile, []byte(key+"\n"), 0o600); err != nil {
+ return "", err
+ }
+ return key, nil
+}
+
+func nprURL() string {
+ if u := strings.TrimSpace(os.Getenv("BJ_NPR_URL")); u != "" {
+ return strings.TrimRight(u, "/")
+ }
+ return "http://127.0.0.1:8083"
+}
+
+// modulePageData — страница модуля в макете bj.
+type modulePageData struct {
+ page
+ Content template.HTML
+}
+
+func moduleMessage(title, text, hint string) template.HTML {
+ return template.HTML(`
` + template.HTMLEscapeString(title) + `
` + template.HTMLEscapeString(text) + `
` +
+ `
` + template.HTMLEscapeString(hint) + `
`)
+}
+
+// registerNPR — прокси раздела /admin/npr к службе bj-npr.
+func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
+ unavailable := func(w http.ResponseWriter, r *http.Request, status int, title, text, hint string) {
+ if isAjax(r) || !strings.HasPrefix(r.Header.Get("Accept"), "text/html") && r.URL.Path != "/admin/npr" && !strings.HasPrefix(r.URL.Path, "/admin/npr/c/") {
+ http.Error(w, text, status)
+ return
+ }
+ w.WriteHeader(status)
+ render(w, a.modulePage, modulePageData{page: nowPage("НПР / УДС", "npr"), Content: moduleMessage(title, text, hint)})
+ }
+ proxy := func(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasPrefix(r.URL.Path, "/admin/npr/static/") && !nprAvailable(rc) {
+ unavailable(w, r, http.StatusForbidden, "Модуль «НПР / УДС» недоступен",
+ "Модуль не включён в настройках или не входит в лицензию.", "Включение и подключение к базе: Настройка → НПР / УДС. Состав лицензии: Настройка → Лицензия.")
+ return
+ }
+ key, err := moduleKey()
+ if err != nil {
+ http.Error(w, "служебный ключ модулей: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ req, err := http.NewRequestWithContext(r.Context(), r.Method, nprURL()+r.URL.RequestURI(), r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ for _, h := range []string{"Accept", "Content-Type", "HX-Request", "HX-Target", "HX-Trigger", "HX-Current-URL", "If-Modified-Since", "If-None-Match", "Range"} {
+ if v := r.Header.Get(h); v != "" {
+ req.Header.Set(h, v)
+ }
+ }
+ req.Header.Set("X-BJ-Module-Key", key)
+ if u := currentUser(w); u != nil {
+ req.Header.Set("X-BJ-User", url.QueryEscape(u.Login))
+ }
+ resp, err := nprHTTP.Do(req)
+ if err != nil {
+ unavailable(w, r, http.StatusServiceUnavailable, "Служба модуля не отвечает",
+ "Служба bj-npr не запущена или ещё не установлена на этом сервере.",
+ "Установка и обновление модуля: Настройка → Обновления. Состояние службы: systemctl status bj-npr.")
+ return
+ }
+ defer resp.Body.Close()
+ if resp.Header.Get("X-BJ-Layout") == "page" {
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ title, _ := url.PathUnescape(resp.Header.Get("X-BJ-Title"))
+ render(w, a.modulePage, modulePageData{page: nowPage(pickStr(title, "НПР / УДС"), "npr"), Content: template.HTML(body)})
+ return
+ }
+ for _, h := range []string{"Content-Type", "Content-Disposition", "Content-Length", "Cache-Control", "Last-Modified", "ETag", "Accept-Ranges", "Content-Range", "Location"} {
+ if v := resp.Header.Get(h); v != "" {
+ w.Header().Set(h, v)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+ }
+ mux.HandleFunc("/admin/npr", proxy)
+ mux.HandleFunc("/admin/npr/", proxy)
+}
+
+func pickStr(v, def string) string {
+ if strings.TrimSpace(v) == "" {
+ return def
+ }
+ return v
+}
+
+// ---- настройки: проверка подключения ----
+
var (
nprPoolMu sync.Mutex
nprPool *pgxpool.Pool
@@ -74,7 +189,7 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
if err != nil {
return nil, err
}
- cfg.MaxConns = 5
+ cfg.MaxConns = 2
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg)
@@ -85,429 +200,6 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
return p, nil
}
-var nprMSK = time.FixedZone("MSK", 3*3600)
-
-var nprSort = map[string]string{
- "uds_asc": "uds ASC NULLS LAST, client_code ASC",
- "uds_desc": "uds DESC NULLS LAST, client_code ASC",
- "client_asc": "client_code ASC",
- "client_desc": "client_code DESC",
- "ts_desc": "ts DESC",
-}
-
-var nprTabs = map[string]string{
- // Маржинальный риск: клиенты с реально открытыми позициями.
- "margin": "COALESCE(init_margin,0) > 0 OR COALESCE(min_margin,0) > 0",
- // Дебиторская задолженность: позиций нет, но портфель в минусе.
- "debt": "COALESCE(init_margin,0) = 0 AND COALESCE(min_margin,0) = 0 AND COALESCE(portfolio_value,0) < 0",
-}
-
-type nprRow struct {
- ClientCode, MarginType, UDSFlag string
- UDS, RCV1, RCV2, MarginReq *float64
- PortfolioValue *float64
- InitMargin, MinMargin, CorrMargin *float64
- Status, RiskLevel *int
- TS time.Time
- Zone string
-}
-
-func (r nprRow) Class() string {
- switch {
- case r.UDSFlag == "CALL" || (r.UDS != nil && *r.UDS < 1):
- return "row-call"
- case r.UDS != nil && *r.UDS < 1.5:
- return "row-warn"
- case r.UDSFlag == "INF":
- return "row-inf"
- }
- return ""
-}
-
-func (r nprRow) Badge() (cls, text string) {
- switch {
- case r.UDSFlag == "CALL":
- return "badge-call", "МАРЖИН-КОЛЛ"
- case r.UDSFlag == "INF":
- return "badge-inf", "∞ избыточно"
- case r.UDS != nil && *r.UDS < 1:
- return "badge-call", "маржин-колл"
- case r.UDS != nil && *r.UDS < 1.5:
- return "badge-warn", "внимание"
- case r.UDS != nil:
- return "badge-ok", "норма"
- }
- return "", "—"
-}
-func (r nprRow) BadgeClass() string { c, _ := r.Badge(); return c }
-func (r nprRow) BadgeText() string { _, t := r.Badge(); return t }
-
-func nprF(v *float64) string {
- if v == nil || math.IsNaN(*v) {
- return "—"
- }
- return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 2, 64), ".", ",")
-}
-func nprI(v *int) string {
- if v == nil {
- return ""
- }
- return strconv.Itoa(*v)
-}
-
-func nprParseF(s string) *float64 {
- s = strings.TrimSpace(strings.ReplaceAll(s, ",", "."))
- if s == "" {
- return nil
- }
- f, err := strconv.ParseFloat(s, 64)
- if err != nil {
- return nil
- }
- return &f
-}
-
-func nprWhere(q string, mt []string, umn, umx *float64, onlyCalls bool, tab string) (string, []any) {
- var cl []string
- var params []any
- cl = append(cl, "("+nprTabs[tab]+")")
- if q != "" {
- params = append(params, "%"+q+"%")
- cl = append(cl, fmt.Sprintf("client_code ILIKE $%d", len(params)))
- }
- if len(mt) > 0 {
- params = append(params, mt)
- cl = append(cl, fmt.Sprintf("margin_type = ANY($%d)", len(params)))
- }
- if umn != nil {
- params = append(params, *umn)
- cl = append(cl, fmt.Sprintf("uds >= $%d", len(params)))
- }
- if umx != nil {
- params = append(params, *umx)
- cl = append(cl, fmt.Sprintf("uds <= $%d", len(params)))
- }
- if onlyCalls {
- cl = append(cl, "uds IS NOT NULL AND uds < 1")
- }
- return strings.Join(cl, " AND "), params
-}
-
-func scanNPR(rows pgx.Rows) ([]nprRow, int, error) {
- var out []nprRow
- total := 0
- for rows.Next() {
- var r nprRow
- var mt, fl *string
- var zone *string
- if err := rows.Scan(&r.ClientCode, &mt, &r.UDS, &fl, &r.RCV1, &r.RCV2, &r.MarginReq, &r.PortfolioValue, &r.InitMargin, &r.MinMargin, &r.CorrMargin, &r.Status, &r.RiskLevel, &r.TS, &zone, &total); err != nil {
- return nil, 0, err
- }
- if mt != nil {
- r.MarginType = *mt
- }
- if fl != nil {
- r.UDSFlag = *fl
- }
- if zone != nil {
- r.Zone = *zone
- }
- out = append(out, r)
- }
- return out, total, rows.Err()
-}
-
-const nprCols = `client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, corrected_margin::float8, status, risk_level, ts, zone`
-
-func nprParsePeriod(hours int, from, to string) (time.Time, time.Time, bool) {
- parse := func(s string) (time.Time, bool) {
- for _, f := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
- if t, err := time.ParseInLocation(f, s, nprMSK); err == nil {
- return t, true
- }
- }
- return time.Time{}, false
- }
- now := time.Now()
- tf, okf := parse(from)
- tt, okt := parse(to)
- if okf || okt {
- if !okf {
- tf = now.Add(-2 * 365 * 24 * time.Hour)
- }
- if !okt {
- tt = now
- }
- return tf, tt, true
- }
- if hours <= 0 {
- hours = 24
- }
- return now.Add(-time.Duration(hours) * time.Hour), now, false
-}
-
-// registerNPR — маршруты модуля.
-func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
- sub, _ := fs.Sub(nprStaticFS, "web/static/npr")
- mux.Handle("/admin/npr/static/", http.StripPrefix("/admin/npr/static/", http.FileServer(http.FS(sub))))
-
- guard := func(w http.ResponseWriter, r *http.Request) (*pgxpool.Pool, bool) {
- if !nprAvailable(rc) {
- http.Error(w, "модуль «НПР / УДС» не включён или не входит в лицензию", http.StatusForbidden)
- return nil, false
- }
- p, err := nprDB(rc)
- if err != nil {
- http.Error(w, "модуль НПР: "+err.Error(), http.StatusServiceUnavailable)
- return nil, false
- }
- return p, true
- }
- qp := func(r *http.Request) (q string, mt []string, umn, umx *float64, onlyCalls bool, sort, tab string, page int) {
- v := r.URL.Query()
- q = strings.TrimSpace(v.Get("q"))
- for _, m := range v["mt"] {
- if m = strings.TrimSpace(m); m != "" {
- mt = append(mt, m)
- }
- }
- umn, umx = nprParseF(v.Get("umn")), nprParseF(v.Get("umx"))
- onlyCalls = v.Get("only_calls") == "true" || v.Get("only_calls") == "on"
- sort = v.Get("sort")
- if _, ok := nprSort[sort]; !ok {
- sort = "uds_asc"
- }
- tab = v.Get("tab")
- if _, ok := nprTabs[tab]; !ok {
- tab = "margin"
- }
- page, _ = strconv.Atoi(v.Get("page"))
- if page < 1 {
- page = 1
- }
- return
- }
-
- mux.HandleFunc("/admin/npr", func(w http.ResponseWriter, r *http.Request) {
- if _, ok := guard(w, r); !ok {
- return
- }
- q, mt, _, _, onlyCalls, sort, tab, pg := qp(r)
- data := struct {
- page
- Q, Sort, Tab string
- MT []string
- UMN, UMX string
- OnlyCalls bool
- Page int
- }{nowPage("НПР / УДС", "npr"), q, sort, tab, mt, r.URL.Query().Get("umn"), r.URL.Query().Get("umx"), onlyCalls, pg}
- render(w, a.npr, data)
- })
-
- mux.HandleFunc("/admin/npr/rows", func(w http.ResponseWriter, r *http.Request) {
- p, ok := guard(w, r)
- if !ok {
- return
- }
- q, mt, umn, umx, onlyCalls, sort, tab, page := qp(r)
- limit := 30
- where, params := nprWhere(q, mt, umn, umx, onlyCalls, tab)
- params = append(params, limit, (page-1)*limit)
- sql := fmt.Sprintf(`WITH per_client AS (
- SELECT DISTINCT ON (client_code) %s FROM npr.v_current
- ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
- SELECT %s, count(*) OVER () AS total FROM per_client WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
- nprCols, nprCols, where, nprSort[sort], len(params)-1, len(params))
- ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
- defer cancel()
- rows, err := p.Query(ctx, sql, params...)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- list, total, err := scanNPR(rows)
- rows.Close()
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- pages := (total + limit - 1) / limit
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Header().Set("Cache-Control", "no-store")
- _ = a.nprRows.ExecuteTemplate(w, "npr_rows", map[string]any{"Rows": list, "Total": total, "Page": page, "Pages": pages, "HasNext": total > page*limit})
- })
-
- mux.HandleFunc("/admin/npr/api/summary", func(w http.ResponseWriter, r *http.Request) {
- p, ok := guard(w, r)
- if !ok {
- return
- }
- tab := r.URL.Query().Get("tab")
- if _, ok := nprTabs[tab]; !ok {
- tab = "margin"
- }
- ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
- defer cancel()
- var total, below1, below0, calls, inf, warn, okc, tabM, tabD int64
- err := p.QueryRow(ctx, `WITH per_client AS (SELECT DISTINCT ON (client_code) client_code, uds, uds_flag, zone, init_margin, min_margin, portfolio_value FROM npr.v_current WHERE `+nprTabs[tab]+` ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
- SELECT count(*), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 1), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 0), count(*) FILTER (WHERE zone='CALL'), count(*) FILTER (WHERE zone='INF'), count(*) FILTER (WHERE zone='WARN'), count(*) FILTER (WHERE zone='OK') FROM per_client`).
- Scan(&total, &below1, &below0, &calls, &inf, &warn, &okc)
- if err == nil {
- err = p.QueryRow(ctx, `SELECT count(DISTINCT client_code) FILTER (WHERE `+nprTabs["margin"]+`), count(DISTINCT client_code) FILTER (WHERE `+nprTabs["debt"]+`) FROM npr.v_current`).Scan(&tabM, &tabD)
- }
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"total": total, "below_one": below1, "below_zero": below0, "calls": calls, "inf": inf, "warn": warn, "ok": okc, "tab_margin": tabM, "tab_debt": tabD, "tab": tab})
- })
-
- mux.HandleFunc("/admin/npr/api/freshness", func(w http.ResponseWriter, r *http.Request) {
- p, ok := guard(w, r)
- if !ok {
- return
- }
- ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
- defer cancel()
- var lastTick *time.Time
- var ticks int64
- _ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks)
- var lastTS *time.Time
- var total, hour, day int64
- _ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day)
- out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)}
- if lastTick != nil {
- out["last_tick"] = lastTick.Format(time.RFC3339)
- }
- if lastTS != nil {
- out["last_event_ts"] = lastTS.Format(time.RFC3339)
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(out)
- })
-
- mux.HandleFunc("/admin/npr/c/", func(w http.ResponseWriter, r *http.Request) {
- p, ok := guard(w, r)
- if !ok {
- return
- }
- rest := strings.TrimPrefix(r.URL.Path, "/admin/npr/c/")
- code := rest
- sub := ""
- if i := strings.Index(rest, "/"); i >= 0 {
- code, sub = rest[:i], rest[i+1:]
- }
- if code == "" {
- http.NotFound(w, r)
- return
- }
- hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
- tf, tt, custom := nprParsePeriod(hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"))
- if hours <= 0 {
- hours = 24
- }
- ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
- defer cancel()
- switch sub {
- case "series":
- rows, err := p.Query(ctx, "SELECT ts, rcv1::float8, rcv2::float8, uds::float8 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts", code, tf, tt)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- defer rows.Close()
- labels, r1, r2, u := []string{}, []*float64{}, []*float64{}, []*float64{}
- for rows.Next() {
- var ts time.Time
- var a, b, c *float64
- if rows.Scan(&ts, &a, &b, &c) == nil {
- labels = append(labels, ts.Format(time.RFC3339))
- r1, r2, u = append(r1, a), append(r2, b), append(u, c)
- }
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"labels": labels, "rcv1": r1, "rcv2": r2, "uds": u})
- return
- case "export.csv":
- rows, err := p.Query(ctx, `SELECT h.ts, h.firm_id, h.client_code, COALESCE(h.margin_type,''), h.rcv1::float8, h.rcv2::float8,
- CASE WHEN h.funds_level >= 9.99 THEN NULL ELSE h.funds_level::float8 END, CASE WHEN h.funds_level >= 9.99 THEN 'INF' WHEN h.funds_level <= -9.99 THEN 'CALL' ELSE '' END,
- h.portfolio_value::float8, h.init_margin::float8, h.min_margin::float8, h.corrected_margin::float8, GREATEST(-h.rcv1, 0)::float8, h.status, h.risk_level
- FROM npr.history h WHERE h.client_code=$1 AND h.ts BETWEEN $2 AND $3 ORDER BY h.ts`, code, tf, tt)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- defer rows.Close()
- w.Header().Set("Content-Type", "text/csv; charset=utf-8")
- w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="npr_%s_%s-%s.csv"`, code, tf.Format("20060102"), tt.Format("20060102")))
- w.Write([]byte{0xEF, 0xBB, 0xBF})
- cw := csv.NewWriter(w)
- cw.Comma = ';'
- cw.Write([]string{"Время (UTC)", "Время (МСК)", "Фирма", "Код клиента", "Тип клиента", "НПР1", "НПР2", "УДС", "Флаг УДС", "Стоимость портфеля", "Начальная маржа", "Минимальная маржа", "Скорректированная маржа", "Маржинальное требование", "Статус", "Уровень риска"})
- f := func(v *float64) string {
- if v == nil {
- return ""
- }
- return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 4, 64), ".", ",")
- }
- for rows.Next() {
- var ts time.Time
- var firm, cc, mt, flag string
- var a, b, c, pv, im, mm, cm, mr *float64
- var st, rl *int
- if rows.Scan(&ts, &firm, &cc, &mt, &a, &b, &c, &flag, &pv, &im, &mm, &cm, &mr, &st, &rl) != nil {
- continue
- }
- cw.Write([]string{ts.UTC().Format("2006-01-02 15:04:05"), ts.In(nprMSK).Format("2006-01-02 15:04:05"), firm, cc, mt, f(a), f(b), f(c), flag, f(pv), f(im), f(mm), f(cm), f(mr), nprI(st), nprI(rl)})
- }
- cw.Flush()
- return
- case "":
- default:
- http.NotFound(w, r)
- return
- }
- rows, err := p.Query(ctx, `SELECT `+nprCols+`, 0 FROM npr.v_current WHERE client_code=$1 ORDER BY (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC LIMIT 1`, code)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- cur, _, err := scanNPR(rows)
- rows.Close()
- if err != nil || len(cur) == 0 {
- http.Error(w, "Клиент "+code+" не найден в текущем состоянии QUIK", http.StatusNotFound)
- return
- }
- hrows, err := p.Query(ctx, `SELECT client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, NULL::float8, NULL::int, NULL::int, ts, NULL::text, 0 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts DESC LIMIT 200`, code, tf, tt)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- hist, _, _ := scanNPR(hrows)
- hrows.Close()
- data := struct {
- page
- C nprRow
- History []nprRow
- Hours int
- DtFrom, DtTo string
- TF, TT time.Time
- Custom bool
- Periods []struct {
- H int
- T string
- }
- }{nowPage("Клиент "+code+" — НПР/УДС", "npr"), cur[0], hist, hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"), tf, tt, custom,
- []struct {
- H int
- T string
- }{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
- render(w, a.nprClient, data)
- })
- log.Printf("npr: модуль зарегистрирован (включён: %v)", nprAvailable(rc))
-}
-
// nprTest — проверка подключения для настроек.
func nprTest(rc *RuntimeConfig) (string, error) {
p, err := nprDB(rc)
@@ -527,7 +219,12 @@ func nprTest(rc *RuntimeConfig) (string, error) {
if tick != nil {
age = fmt.Sprintf("пульс экспортёра %s назад", time.Since(*tick).Round(time.Second))
}
- return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s", clients, hist, age), nil
+ service := "служба bj-npr не отвечает"
+ if resp, err := (&http.Client{Timeout: 3 * time.Second}).Get(nprURL() + "/healthz"); err == nil {
+ resp.Body.Close()
+ service = "служба bj-npr работает"
+ }
+ return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s; %s", clients, hist, age, service), nil
}
func (h *setupHandlers) saveNPR(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/lkgateway/npr_proxy_test.go b/internal/lkgateway/npr_proxy_test.go
new file mode 100644
index 0000000..0d88d98
--- /dev/null
+++ b/internal/lkgateway/npr_proxy_test.go
@@ -0,0 +1,32 @@
+package lkgateway
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// Страница службы модуля встраивается в макет bj: меню и заголовок bj, содержимое — от модуля.
+func TestModulePageWrap(t *testing.T) {
+ a, err := newAdmin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ frag := `фрагмент
`
+ render(rec, a.modulePage, modulePageData{page: nowPage("Клиент V3BR061 · НПР / УДС", "npr"), Content: template.HTML(frag)})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{frag, "Клиент V3BR061 · НПР / УДС", "