5527a464ca
Модуль вынесен в 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>
266 lines
9.4 KiB
Go
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)
|
|
}
|