Files
Bridge-and-Join-s/internal/lkgateway/npr.go
T
zuevav 5527a464ca НПР / УДС — отдельная служба bj-npr
Модуль вынесен в 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) <noreply@anthropic.com>
2026-09-16 01:00:31 +03:00

266 lines
9.4 KiB
Go

package lkgateway
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли по базе экспорта QUIK.
// Сам модуль работает отдельной службой bj-npr (modules/npr, обновляется независимо). Здесь —
// его настройки и проверка подключения, а раздел /admin/npr bj-server проксирует службе: проверяет
// включение и лицензию (права — authMiddleware), передаёт служебный ключ и встраивает полные
// страницы модуля в свой макет. Если служба не отвечает, показывает понятную страницу.
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// NPRSettings — модуль НПР/УДС.
type NPRSettings struct {
Enabled bool `json:"enabled"`
DSN string `json:"dsn"` // postgres://quik_ro:...@host:5432/quik_export
}
func (r *RuntimeConfig) UpdateNPR(s NPRSettings) error {
r.mu.Lock()
r.data.NPR = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// nprAvailable — модуль включён в настройках и разрешён лицензией.
func nprAvailable(rc *RuntimeConfig) bool {
if rc == nil {
return false
}
s := rc.Snapshot().NPR
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(`<div class="card"><h2>` + template.HTMLEscapeString(title) + `</h2><p>` + template.HTMLEscapeString(text) + `</p>` +
`<p class="muted">` + template.HTMLEscapeString(hint) + `</p></div>`)
}
// 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
nprPoolDSN string
)
func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
dsn := strings.TrimSpace(rc.Snapshot().NPR.DSN)
if dsn == "" {
return nil, fmt.Errorf("модуль НПР: не задано подключение к базе экспорта QUIK")
}
nprPoolMu.Lock()
defer nprPoolMu.Unlock()
if nprPool != nil && nprPoolDSN == dsn {
return nprPool, nil
}
if nprPool != nil {
nprPool.Close()
nprPool = nil
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 2
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
nprPool, nprPoolDSN = p, dsn
return p, nil
}
// nprTest — проверка подключения для настроек.
func nprTest(rc *RuntimeConfig) (string, error) {
p, err := nprDB(rc)
if err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var clients, hist int64
var tick *time.Time
if err := p.QueryRow(ctx, "SELECT count(DISTINCT client_code) FROM npr.v_current").Scan(&clients); err != nil {
return "", fmt.Errorf("npr.v_current: %v", err)
}
_ = p.QueryRow(ctx, "SELECT count(*) FROM npr.history").Scan(&hist)
_ = p.QueryRow(ctx, "SELECT last_tick FROM npr.heartbeat WHERE id=1").Scan(&tick)
age := "нет пульса"
if tick != nil {
age = fmt.Sprintf("пульс экспортёра %s назад", time.Since(*tick).Round(time.Second))
}
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) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := NPRSettings{Enabled: r.FormValue("enabled") != "", DSN: strings.TrimSpace(r.FormValue("dsn"))}
if err := h.rc.UpdateNPR(s); err != nil {
setupFlash(w, r, "Модуль НПР: ошибка сохранения: "+err.Error())
return
}
if s.Enabled {
setupFlash(w, r, "Модуль НПР / УДС включён")
} else {
setupFlash(w, r, "Модуль НПР / УДС выключен")
}
}
func (h *setupHandlers) testNPR(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if dsn := strings.TrimSpace(r.FormValue("dsn")); dsn != "" {
s := h.rc.Snapshot().NPR
if s.DSN != dsn {
s.DSN = dsn
_ = h.rc.UpdateNPR(s)
}
}
msg, err := nprTest(h.rc)
if err != nil {
setupFlash(w, r, "Модуль НПР: подключение НЕ удалось — "+err.Error())
return
}
setupFlash(w, r, "Модуль НПР: подключение OK — "+msg)
}