From 5527a464ca669a5d12608bc31dd528475e5a011a Mon Sep 17 00:00:00 2001 From: zuevav Date: Wed, 16 Sep 2026 01:00:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=9F=D0=A0=20/=20=D0=A3=D0=94=D0=A1=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BB=D1=83=D0=B6=D0=B1=D0=B0=20bj-npr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модуль вынесен в modules/npr + cmd/bj-npr (127.0.0.1:8083), обновляется независимо от ядра. Служба читает подключение из setup.json и принимает запросы только со служебным ключом модулей (/var/lib/bj/.bj/module.key, создаёт bj-server). Полные страницы отдаются фрагментом (X-BJ-Layout: page). bj-server хранит настройки и проверку подключения, проксирует /admin/npr после входа, прав и лицензии и встраивает страницы модуля в общий макет (admin.modulePage); если служба не отвечает — понятная страница. Unit bj-npr.service встроен в установщик, установщик включает службы установленных модулей; build-release.sh собирает bj-npr. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/bj-installer/main.go | 17 +- cmd/bj-npr/main.go | 44 ++ deploy/embed.go | 2 +- deploy/systemd/bj-npr.service | 24 + internal/lkgateway/admin.go | 34 +- internal/lkgateway/npr.go | 577 ++++------------- internal/lkgateway/npr_proxy_test.go | 32 + modules/npr/npr.go | 601 ++++++++++++++++++ .../npr/web/client.html | 2 +- .../npr_css.html => modules/npr/web/css.html | 0 .../npr/web/list.html | 2 +- .../npr/web/rows.html | 0 .../npr/web/static}/chart.umd.min.js | 0 .../npr/web/static}/htmx.min.js | 0 scripts/build-release.sh | 4 +- 15 files changed, 872 insertions(+), 467 deletions(-) create mode 100644 cmd/bj-npr/main.go create mode 100644 deploy/systemd/bj-npr.service create mode 100644 internal/lkgateway/npr_proxy_test.go create mode 100644 modules/npr/npr.go rename internal/lkgateway/web/templates/admin_npr_client.html => modules/npr/web/client.html (99%) rename internal/lkgateway/web/templates/npr_css.html => modules/npr/web/css.html (100%) rename internal/lkgateway/web/templates/admin_npr.html => modules/npr/web/list.html (99%) rename internal/lkgateway/web/templates/admin_npr_rows.html => modules/npr/web/rows.html (100%) rename {internal/lkgateway/web/static/npr => modules/npr/web/static}/chart.umd.min.js (100%) rename {internal/lkgateway/web/static/npr => modules/npr/web/static}/htmx.min.js (100%) 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 · НПР / УДС", ""} { + if !strings.Contains(body, want) { + t.Errorf("в странице нет %q", want) + } + } + if strings.Count(body, " 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", +} + +// Row — клиент в текущем состоянии или запись истории. +type Row 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 Row) 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 Row) 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 Row) BadgeClass() string { c, _ := r.Badge(); return c } +func (r Row) BadgeText() string { _, t := r.Badge(); return t } + +func fmtF(v *float64) string { + if v == nil || math.IsNaN(*v) { + return "—" + } + return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 2, 64), ".", ",") +} + +func fmtI(v *int) string { + if v == nil { + return "" + } + return strconv.Itoa(*v) +} + +func parseF(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 where(q string, mt []string, umn, umx *float64, onlyCalls bool, tab string) (string, []any) { + var cl []string + var params []any + cl = append(cl, "("+tabs[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 scanRows(rows pgx.Rows) ([]Row, int, error) { + var out []Row + total := 0 + for rows.Next() { + var r Row + var mt, fl, 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 cols = `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 parsePeriod(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, msk); 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 +} + +// ---- маршруты ---- + +// Handler — все маршруты модуля; без служебного ключа отвечает 403. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + sub, _ := fs.Sub(staticFS, "web/static") + mux.Handle("/admin/npr/static/", http.StripPrefix("/admin/npr/static/", http.FileServer(http.FS(sub)))) + + guard := func(w http.ResponseWriter) (*pgxpool.Pool, bool) { + p, err := s.db() + 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 = parseF(v.Get("umn")), parseF(v.Get("umx")) + onlyCalls = v.Get("only_calls") == "true" || v.Get("only_calls") == "on" + sort = v.Get("sort") + if _, ok := sortOrders[sort]; !ok { + sort = "uds_asc" + } + tab = v.Get("tab") + if _, ok := tabs[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); !ok { + return + } + q, mt, _, _, onlyCalls, sort, tab, pg := qp(r) + data := struct { + Q, Sort, Tab string + MT []string + UMN, UMX string + OnlyCalls bool + Page int + }{q, sort, tab, mt, r.URL.Query().Get("umn"), r.URL.Query().Get("umx"), onlyCalls, pg} + s.page(w, s.list, "npr_list", "НПР / УДС", data) + }) + + mux.HandleFunc("/admin/npr/rows", func(w http.ResponseWriter, r *http.Request) { + p, ok := guard(w) + if !ok { + return + } + q, mt, umn, umx, onlyCalls, sort, tab, page := qp(r) + limit := 30 + cond, params := where(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`, + cols, cols, cond, sortOrders[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 := scanRows(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") + _ = s.rows.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) + if !ok { + return + } + tab := r.URL.Query().Get("tab") + if _, ok := tabs[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 `+tabs[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 `+tabs["margin"]+`), count(DISTINCT client_code) FILTER (WHERE `+tabs["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) + 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) + if !ok { + return + } + rest := strings.TrimPrefix(r.URL.Path, "/admin/npr/c/") + code, sub := rest, "" + 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 := parsePeriod(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(msk).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), fmtI(st), fmtI(rl)}) + } + cw.Flush() + return + case "": + default: + http.NotFound(w, r) + return + } + rows, err := p.Query(ctx, `SELECT `+cols+`, 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 := scanRows(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, _, _ := scanRows(hrows) + hrows.Close() + type period struct { + H int + T string + } + data := struct { + C Row + History []Row + Hours int + DtFrom, DtTo string + TF, TT time.Time + Custom bool + Periods []period + }{cur[0], hist, hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"), tf, tt, custom, + []period{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}} + s.page(w, s.client, "npr_client", "Клиент "+code+" · НПР / УДС", data) + }) + return s.requireKey(mux) +} diff --git a/internal/lkgateway/web/templates/admin_npr_client.html b/modules/npr/web/client.html similarity index 99% rename from internal/lkgateway/web/templates/admin_npr_client.html rename to modules/npr/web/client.html index 1e0497d..f61ea6c 100644 --- a/internal/lkgateway/web/templates/admin_npr_client.html +++ b/modules/npr/web/client.html @@ -1,4 +1,4 @@ -{{define "content"}} +{{define "npr_client"}} {{template "npr_css"}}

← К списку клиентов{{if not .Custom}}⟳ Страница обновится автоматически через 60 сек{{end}}

{{if not .Custom}}{{end}} diff --git a/internal/lkgateway/web/templates/npr_css.html b/modules/npr/web/css.html similarity index 100% rename from internal/lkgateway/web/templates/npr_css.html rename to modules/npr/web/css.html diff --git a/internal/lkgateway/web/templates/admin_npr.html b/modules/npr/web/list.html similarity index 99% rename from internal/lkgateway/web/templates/admin_npr.html rename to modules/npr/web/list.html index 8a0888f..6f7f4ea 100644 --- a/internal/lkgateway/web/templates/admin_npr.html +++ b/modules/npr/web/list.html @@ -1,4 +1,4 @@ -{{define "content"}} +{{define "npr_list"}} {{template "npr_css"}}

📊 Риск-монитор: НПР и УДС клиентов

НПР1 / НПР2 / УДС клиентов маржинальной торговли по данным экспорта QUIK. Модуль перенесён как есть, доработки — по мере необходимости.

diff --git a/internal/lkgateway/web/templates/admin_npr_rows.html b/modules/npr/web/rows.html similarity index 100% rename from internal/lkgateway/web/templates/admin_npr_rows.html rename to modules/npr/web/rows.html diff --git a/internal/lkgateway/web/static/npr/chart.umd.min.js b/modules/npr/web/static/chart.umd.min.js similarity index 100% rename from internal/lkgateway/web/static/npr/chart.umd.min.js rename to modules/npr/web/static/chart.umd.min.js diff --git a/internal/lkgateway/web/static/npr/htmx.min.js b/modules/npr/web/static/htmx.min.js similarity index 100% rename from internal/lkgateway/web/static/npr/htmx.min.js rename to modules/npr/web/static/htmx.min.js diff --git a/scripts/build-release.sh b/scripts/build-release.sh index aab937d..6ac1e42 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -6,7 +6,7 @@ # канал stable (по умолчанию) | beta | … # опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом) # -# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, +# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, # CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR # (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv). set -eu @@ -24,6 +24,8 @@ echo "Сборка релиза $VERSION (канал $CHANNEL) в $DIST" go build -trimpath -ldflags "-s -w -X $MOD/internal/lkgateway.BuildVersion=$(ver BJ_SERVER_VERSION)" -o "$DIST/bj-server" ./cmd/bj-server CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.installerVersion=$(ver INSTALLER_VERSION)" -o "$DIST/bj-installer" ./cmd/bj-installer CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$DIST/bj-component-install" ./cmd/bj-component-install +CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver NPR_VERSION)" -o "$DIST/bj-npr" ./cmd/bj-npr +ver NPR_VERSION > "$DIST/bj-npr.version" ver BJ_SERVER_VERSION > "$DIST/bj-server.version" ver INSTALLER_VERSION > "$DIST/bj-installer.version" ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"