Экспорт QUIK — отдельная служба bj-quikexport; модули, выделенные из ядра, ставятся с обновлением

bj-quikexport (modules/quikexport + cmd/bj-quikexport, 127.0.0.1:8084): управление ARQA
quikexport перенесено из bj-server. Настройки у службы в /var/lib/bj/.bj/quik-export/settings.json
(при первом запуске переносятся из setup.json), раздел отдаётся фрагментом с индикатором меню,
действия отвечают X-BJ-Flash. bj-server встраивает раздел в «Настройку», проксирует формы
/admin/setup/quikexport/… и передаёт лицензию (X-BJ-Licensed). Общая проверка служебного
ключа — modules/modkit (им пользуется и bj-npr), общее для прокси — lkgateway/modules.go.

Выделенные из ядра модули (bj-npr, bj-quikexport: Component.FromCore) при обновлении ставятся и
там, где их ещё нет, если входят в лицензию: release.MarkSplitModules, request.json "new",
root-установщик ставит и включает unit-файл из комплекта. Если в обновлении меняется сам
установщик, модули ставятся после перезапуска bj-server (ensureSplitModules).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 01:15:27 +03:00
parent 5527a464ca
commit 97e99b72c7
26 changed files with 1095 additions and 303 deletions
+32
View File
@@ -0,0 +1,32 @@
// Package modkit — общее для служб модулей bj (bj-npr, bj-quikexport, …).
//
// Службы модулей слушают только локальный адрес и принимают запросы только от bj-server:
// он проверяет вход, права и лицензию и передаёт служебный ключ в X-BJ-Module-Key
// (файл /var/lib/bj/.bj/module.key создаёт bj-server, службы читают его от того же пользователя).
package modkit
import (
"crypto/subtle"
"net/http"
"os"
"strings"
)
// RequireKey — пропускает запросы только со служебным ключом (кроме /healthz).
// Ключ читается при каждом запросе, поэтому его замена не требует перезапуска службы.
func RequireKey(keyPath string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
key, err := os.ReadFile(keyPath)
want := strings.TrimSpace(string(key))
got := r.Header.Get("X-BJ-Module-Key")
if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+3 -20
View File
@@ -11,7 +11,6 @@ package npr
import (
"bytes"
"context"
"crypto/subtle"
"embed"
"encoding/csv"
"encoding/json"
@@ -29,6 +28,8 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit"
)
//go:embed web/*.html
@@ -148,24 +149,6 @@ func (s *Server) db() (*pgxpool.Pool, error) {
return p, nil
}
// requireKey — принимать запросы только от bj-server (кроме /healthz).
func (s *Server) requireKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
key, err := os.ReadFile(s.cfg.KeyPath)
want := strings.TrimSpace(string(key))
got := r.Header.Get("X-BJ-Module-Key")
if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// page — полная страница фрагментом для макета bj-server.
func (s *Server) page(w http.ResponseWriter, t *template.Template, name, title string, data any) {
var buf bytes.Buffer
@@ -597,5 +580,5 @@ func (s *Server) Handler() http.Handler {
[]period{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
s.page(w, s.client, "npr_client", "Клиент "+code+" · НПР / УДС", data)
})
return s.requireKey(mux)
return modkit.RequireKey(s.cfg.KeyPath, mux)
}
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
package quikexport
// Служба bj-quikexport: свои настройки, раздел настроек фрагментом для bj-server и действия раздела.
//
// Протокол с bj-server (всё со служебным ключом, см. modkit):
// - GET /section[?reveal_ro=1] — HTML раздела «Экспорт QUIK» для страницы «Настройка»,
// в заголовке X-BJ-Indicator — цвет индикатора меню (ok | warn | err | пусто);
// - POST /admin/setup/quikexport[/keys|apply|import|service|dbinit] — действие; итог — 204
// с сообщением в X-BJ-Flash, bj-server показывает его как обычное сообщение страницы настроек;
// - GET /admin/setup/quikexport/sql и /preview — файлы как есть.
//
// Лицензию проверяет bj-server и передаёт в X-BJ-Licensed: 1.
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit"
)
//go:embed web/section.html
var sectionFS embed.FS
// Config — где служба берёт настройки и ключ.
type Config struct {
StorePath string // собственные настройки модуля
SetupPath string // setup.json bj: отсюда настройки переносятся при первом запуске (раздел quik_export)
KeyPath string // служебный ключ модулей
}
// Server — служба модуля.
type Server struct {
cfg Config
store *Store
section *template.Template
}
// New — служба с разобранным шаблоном; при первом запуске переносит настройки из setup.json.
func New(cfg Config) (*Server, error) {
t, err := template.New("").Funcs(template.FuncMap{
"msk": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
},
}).ParseFS(sectionFS, "web/section.html")
if err != nil {
return nil, err
}
st := &Store{path: cfg.StorePath}
moved, err := st.migrate(cfg.SetupPath)
if err != nil {
return nil, fmt.Errorf("перенос настроек из %s: %w", cfg.SetupPath, err)
}
if moved {
log.Printf("bj-quikexport: настройки перенесены из %s в %s", cfg.SetupPath, cfg.StorePath)
}
return &Server{cfg: cfg, store: st, section: t}, nil
}
// Handler — все маршруты службы; без служебного ключа отвечает 403.
func (srv *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/section", func(w http.ResponseWriter, r *http.Request) {
v := quikExportView(srv.store, licensed(r), r.URL.Query().Get("reveal_ro") == "1")
var buf bytes.Buffer
if err := srv.section.ExecuteTemplate(&buf, "section", v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-BJ-Indicator", indicator(v))
_, _ = w.Write(buf.Bytes())
})
mux.HandleFunc("/admin/setup/quikexport", srv.saveQuikExport)
mux.HandleFunc("/admin/setup/quikexport/keys", srv.uploadQuikExportKeys)
mux.HandleFunc("/admin/setup/quikexport/apply", srv.applyQuikExport)
mux.HandleFunc("/admin/setup/quikexport/import", srv.importQuikExport)
mux.HandleFunc("/admin/setup/quikexport/service", srv.serviceQuikExport)
mux.HandleFunc("/admin/setup/quikexport/dbinit", srv.dbInitQuikExport)
mux.HandleFunc("/admin/setup/quikexport/sql", srv.sqlQuikExport)
mux.HandleFunc("/admin/setup/quikexport/preview", srv.previewQuikExport)
return modkit.RequireKey(srv.cfg.KeyPath, mux)
}
func licensed(r *http.Request) bool { return r.Header.Get("X-BJ-Licensed") == "1" }
// flash — итог действия для страницы настроек bj.
func flash(w http.ResponseWriter, msg string) {
w.Header().Set("X-BJ-Flash", url.PathEscape(msg))
w.WriteHeader(http.StatusNoContent)
}
// indicator — цвет индикатора раздела в меню настроек.
func indicator(v QuikExportView) string {
switch {
case !v.Licensed:
return "err"
case v.S.Enabled && v.Service.Active == "active":
return "ok"
case v.S.Enabled:
return "warn"
}
return ""
}
// ---- настройки ----
// Store — настройки модуля в собственном файле (bj-server перезаписывает setup.json целиком,
// поэтому служба туда не пишет).
type Store struct {
path string
mu sync.Mutex
}
// Get — настройки со значениями по умолчанию.
func (st *Store) Get() QuikExportSettings {
st.mu.Lock()
defer st.mu.Unlock()
var s QuikExportSettings
if b, err := os.ReadFile(st.path); err == nil {
_ = json.Unmarshal(b, &s)
}
return s.withDefaults()
}
// Save — атомарная запись (0600: в настройках пароли).
func (st *Store) Save(s QuikExportSettings) error {
st.mu.Lock()
defer st.mu.Unlock()
raw, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(st.path), 0o700); err != nil {
return err
}
tmp := st.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, st.path)
}
// migrate — если своих настроек ещё нет, берёт раздел quik_export из setup.json bj.
func (st *Store) migrate(setupPath string) (bool, error) {
if _, err := os.Stat(st.path); err == nil || setupPath == "" {
return false, nil
}
b, err := os.ReadFile(setupPath)
if err != nil {
return false, nil
}
var sj struct {
QuikExport json.RawMessage `json:"quik_export"`
}
if json.Unmarshal(b, &sj) != nil || len(sj.QuikExport) == 0 || string(sj.QuikExport) == "null" {
return false, nil
}
var s QuikExportSettings
if err := json.Unmarshal(sj.QuikExport, &s); err != nil {
return false, err
}
return true, st.Save(s)
}
// ---- root-воркер ----
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
// rootOp — запрос root-воркеру bj-keymedia-worker через файловую очередь и ожидание ответа.
func rootOp(ctx context.Context, action string, fields map[string]string) (map[string]any, error) {
id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
reqPath := filepath.Join(keymediaReqDir, id+".req")
resPath := filepath.Join(keymediaReqDir, id+".res")
defer os.Remove(resPath)
req := map[string]string{"action": action}
for k, v := range fields {
req[k] = v
}
reqBody, _ := json.Marshal(req)
// Пишем атомарно (tmp → rename), чтобы воркер не подхватил полупустой файл.
tmp := reqPath + ".tmp"
if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
return nil, fmt.Errorf("запись запроса: %w", err)
}
if err := os.Rename(tmp, reqPath); err != nil {
return nil, fmt.Errorf("публикация запроса: %w", err)
}
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("таймаут ожидания root-воркера")
case <-ticker.C:
b, err := os.ReadFile(resPath)
if err != nil {
continue // ещё не готово
}
res := map[string]any{}
// systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{».
if i := bytes.IndexByte(b, '{'); i > 0 {
b = b[i:]
}
if jerr := json.Unmarshal(b, &res); jerr != nil {
return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
}
if res["ok"] != true {
msg, _ := res["error"].(string)
if msg == "" {
msg = "ошибка"
}
return res, fmt.Errorf("воркер: %s", msg)
}
return res, nil
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package quikexport
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
const testKey = "k-0123456789abcdef0123456789abcdef"
func TestServiceSectionAndSave(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "module.key")
setup := filepath.Join(dir, "setup.json")
store := filepath.Join(dir, "quik-export", "settings.json")
if err := os.WriteFile(keyPath, []byte(testKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(setup, []byte(`{"quik_export":{"enabled":true,"quik_host":"10.0.0.1","quik_user":"exp","preset":"npr"}}`), 0o600); err != nil {
t.Fatal(err)
}
srv, err := New(Config{StorePath: store, SetupPath: setup, KeyPath: keyPath})
if err != nil {
t.Fatal(err)
}
if got := srv.store.Get(); got.QuikHost != "10.0.0.1" || !got.Enabled || got.QuikPort != 15100 {
t.Fatalf("перенос настроек из setup.json: %+v", got)
}
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/section", nil))
if rec.Code != http.StatusForbidden {
t.Fatalf("без ключа: код %d", rec.Code)
}
req := httptest.NewRequest(http.MethodGet, "/section", nil)
req.Header.Set("X-BJ-Module-Key", testKey)
req.Header.Set("X-BJ-Licensed", "1")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("раздел: код %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Подключение к серверу QUIK", `value="10.0.0.1"`, "/admin/setup/quikexport/apply"} {
if !strings.Contains(body, want) {
t.Errorf("в разделе нет %q", want)
}
}
if ind := rec.Header().Get("X-BJ-Indicator"); ind == "err" {
t.Errorf("индикатор при действующей лицензии: %q", ind)
}
form := url.Values{"part": {"quik"}, "quik_host": {"10.0.0.2"}, "quik_user": {"exp"}, "section": {"sec-quikexport"}}
req = httptest.NewRequest(http.MethodPost, "/admin/setup/quikexport", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-BJ-Module-Key", testKey)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent || rec.Header().Get("X-BJ-Flash") == "" {
t.Fatalf("сохранение: код %d, flash %q", rec.Code, rec.Header().Get("X-BJ-Flash"))
}
if got := srv.store.Get(); got.QuikHost != "10.0.0.2" {
t.Fatalf("после сохранения quik_host = %q", got.QuikHost)
}
if fi, err := os.Stat(store); err != nil || fi.Mode().Perm() != 0o600 {
t.Fatalf("файл настроек: %v %v", fi, err)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
[connect]
trade-server-ip-address=127.0.0.1
auth-name=xxx
check-tmo=150
port=15100
user_name=xxx
user_password=xxx
AutoHeartBeat=1
[proxy]
use-proxy=0
proxy-server-ip-address=
proxy-port=
@@ -0,0 +1,250 @@
-- Миграция: схема npr с историей НПР1/НПР2/УДС через триггер на "Portfolio".
--
-- Применять ПОСЛЕ того, как таблицы QUIK-экспортёра развёрнуты штатным скриптом
-- quikexport_11/scripts/postgre/exporter_pg.sql
-- в ту же БД. В эталонной "Portfolio" уже есть все нужные колонки
-- (PortfolioValue, InitMargin, MinMargin, CorrectedMargin, FundsLevel, Status, RiskLevel),
-- поэтому отдельного ALTER TABLE не требуется.
--
-- Политика записи: триггер пишет в npr.history только когда
-- (а) клиент появился впервые, или
-- (б) сменилась «зона» УДС (CALL → WARN → OK → INF), или
-- (в) прошло более 30 мин с последней записи по этому клиенту (heartbeat).
-- Это резко сокращает объём истории — фиксируются моменты смены состояния,
-- а не каждый тик экспортёра. Дополняется политикой хранения (retention).
BEGIN;
-- (б) Схема для истории НПР/УДС.
CREATE SCHEMA IF NOT EXISTS npr;
-- (в) Таблица истории. Append-only: свой BIGSERIAL PK, без UNIQUE по клиенту.
CREATE TABLE IF NOT EXISTS npr.history (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(4) NOT NULL,
margin_type varchar(4) NULL,
portfolio_value decimal(28, 8) NULL,
init_margin float NULL,
min_margin float NULL,
corrected_margin float NULL,
rcv1 float NULL,
rcv2 float NULL,
funds_level decimal(19, 2) NULL,
status int NULL,
risk_level int NULL
);
-- (г) Индекс для выборок «история по клиенту за период».
CREATE INDEX IF NOT EXISTS ix_npr_hist_client_ts
ON npr.history (client_code, ts DESC);
-- «Пульс» от экспортёра: триггер обновляет last_tick на каждом тике (не чаще раза в 10 сек).
-- Нужен отдельно от истории, чтобы UI видел «данные от QUIK приходят» даже когда зоны не меняются.
CREATE TABLE IF NOT EXISTS npr.heartbeat (
id int PRIMARY KEY DEFAULT 1,
last_tick timestamptz NOT NULL DEFAULT NOW(),
tick_count bigint NOT NULL DEFAULT 0
);
INSERT INTO npr.heartbeat (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Кэш «последнее записанное состояние» — нужен триггеру, чтобы понять,
-- изменилась ли зона относительно ранее сохранённой строки. Одна запись на клиентский PK.
CREATE TABLE IF NOT EXISTS npr.last_seen (
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(12) NOT NULL,
tag varchar(4) NOT NULL,
last_ts timestamptz NOT NULL,
last_zone text NOT NULL,
last_uds numeric(19,2),
PRIMARY KEY (firm_id, client_code, limit_kind, curr_code, tag)
);
-- Зона УДС: CALL / WARN / OK / INF / UNKNOWN. Используется и триггером, и отчётами.
CREATE OR REPLACE FUNCTION npr._zone(fl numeric) RETURNS text AS $$
SELECT CASE
WHEN fl IS NULL THEN 'UNKNOWN'
WHEN fl >= 9.99 THEN 'INF'
WHEN fl <= -9.99 THEN 'CALL'
WHEN fl < 0 THEN 'CALL'
WHEN fl < 1 THEN 'CALL'
WHEN fl < 1.5 THEN 'WARN'
ELSE 'OK'
END;
$$ LANGUAGE sql IMMUTABLE;
-- (д) Триггер-функция: новая запись в history только при смене зоны или раз в 30 мин.
-- Фильтр МД / МД+ / С применяется сразу, чтобы не тратить ресурсы на остальных.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_to_history()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
DECLARE
new_zone text;
last_ts timestamptz;
last_zone text;
should_write boolean := false;
BEGIN
-- Пульс: обновляем не чаще раза в 10 сек, чтобы не делать тысяч UPDATE на один тик экспортёра.
UPDATE npr.heartbeat
SET last_tick = NOW(), tick_count = tick_count + 1
WHERE id = 1 AND last_tick < NOW() - INTERVAL '10 seconds';
IF NEW."MarginType" NOT IN ('МД','МД+','С') THEN
RETURN NEW;
END IF;
new_zone := npr._zone(NEW."FundsLevel");
SELECT ls.last_ts, ls.last_zone
INTO last_ts, last_zone
FROM npr.last_seen ls
WHERE ls.firm_id = NEW."FirmId"
AND ls.client_code = NEW."ClientCode"
AND ls.limit_kind = NEW."LimitKind"
AND ls.curr_code = NEW."CurrCode"
AND ls.tag = NEW."Tag";
IF last_ts IS NULL THEN
should_write := true; -- впервые видим клиента
ELSIF last_zone IS DISTINCT FROM new_zone THEN
should_write := true; -- переход зоны
ELSIF NOW() - last_ts > INTERVAL '30 minutes' THEN
should_write := true; -- heartbeat
END IF;
IF should_write THEN
INSERT INTO npr.history (
firm_id, client_code, limit_kind, curr_code, margin_type,
portfolio_value, init_margin, min_margin, corrected_margin,
rcv1, rcv2, funds_level, status, risk_level
) VALUES (
NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."MarginType",
NEW."PortfolioValue", NEW."InitMargin", NEW."MinMargin", NEW."CorrectedMargin",
NEW."RCV1", NEW."RCV2", NEW."FundsLevel", NEW."Status", NEW."RiskLevel"
);
INSERT INTO npr.last_seen (firm_id, client_code, limit_kind, curr_code, tag,
last_ts, last_zone, last_uds)
VALUES (NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."Tag",
NOW(), new_zone, NEW."FundsLevel")
ON CONFLICT (firm_id, client_code, limit_kind, curr_code, tag)
DO UPDATE SET last_ts = EXCLUDED.last_ts,
last_zone = EXCLUDED.last_zone,
last_uds = EXCLUDED.last_uds;
END IF;
RETURN NEW;
END;
$BODY$;
-- (е) Триггер на "Portfolio" — AFTER INSERT OR UPDATE.
DROP TRIGGER IF EXISTS portfolio_to_npr_history ON "Portfolio";
CREATE TRIGGER portfolio_to_npr_history
AFTER INSERT OR UPDATE ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_to_history();
-- (е2) Повторный INSERT в "Portfolio" → UPDATE без ошибки.
-- Экспортёр каждый цикл сначала делает подготовленный INSERT по всем строкам
-- и по дубликату ключа переходит к UPDATE. В PostgreSQL 11 каждая ошибка
-- подготовленного INSERT оставляет ~20 КБ в памяти сессии: ~10 тыс. строк/мин
-- дают ≈200 МБ/мин, backend дорастает до OOM и роняет весь кластер (14.09.2026).
-- AFTER UPDATE триггер истории выше срабатывает как обычно.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_insert_as_update()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
BEGIN
UPDATE "Portfolio"
SET "MarginType" = NEW."MarginType", "Status" = NEW."Status",
"PortfolioValue" = NEW."PortfolioValue", "InitMargin" = NEW."InitMargin",
"MinMargin" = NEW."MinMargin", "CorrectedMargin" = NEW."CorrectedMargin",
"RCV1" = NEW."RCV1", "RCV2" = NEW."RCV2",
"RiskLevel" = NEW."RiskLevel", "FundsLevel" = NEW."FundsLevel"
WHERE "FirmId" = NEW."FirmId" AND "ClientCode" = NEW."ClientCode"
AND "LimitKind" = NEW."LimitKind" AND "CurrCode" = NEW."CurrCode" AND "Tag" = NEW."Tag";
IF FOUND THEN
RETURN NULL;
END IF;
RETURN NEW;
END;
$BODY$;
DROP TRIGGER IF EXISTS portfolio_insert_as_update ON "Portfolio";
CREATE TRIGGER portfolio_insert_as_update
BEFORE INSERT ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_insert_as_update();
-- (ж) Прикладная вьюха: УДС с обработкой QUIK-сентинелов и маржинальное требование.
-- QUIK кладёт УДС в диапазон [-9.99, 9.99]: 9.99 = «бесконечность» (капнут сверху),
-- -9.99 = маржин-колл / принудзакрытие (капнут снизу).
CREATE OR REPLACE VIEW npr.v_npr AS
SELECT
ts,
firm_id,
client_code,
margin_type,
portfolio_value,
init_margin,
min_margin,
rcv1,
rcv2,
CASE WHEN funds_level >= 9.99 THEN NULL ELSE funds_level END AS uds,
CASE WHEN funds_level >= 9.99 THEN 'INF'
WHEN funds_level <= -9.99 THEN 'CALL'
ELSE NULL END AS uds_flag,
GREATEST(-rcv1, 0) AS margin_requirement
FROM npr.history;
COMMIT;
-- ===========================================================================
-- Retention-политика: cron-файл /etc/cron.d/npr-retention чистит записи > 2 лет
-- раз в месяц:
-- 0 3 1 * * postgres psql -d quik_export -c \
-- "DELETE FROM npr.history WHERE ts < NOW() - INTERVAL '2 years';"
-- Если нужно партиционирование по месяцам — отдельная миграция.
-- ===========================================================================
-- npr.v_current (текущее состояние по клиентам; снято с рабочей базы 14.09.2026)
CREATE OR REPLACE VIEW npr.v_current AS
SELECT p."FirmId" AS firm_id,
p."ClientCode" AS client_code,
p."LimitKind" AS limit_kind,
p."CurrCode" AS curr_code,
p."Tag" AS tag,
p."MarginType" AS margin_type,
p."RCV1" AS rcv1,
p."RCV2" AS rcv2,
p."FundsLevel" AS funds_level,
p."PortfolioValue" AS portfolio_value,
p."InitMargin" AS init_margin,
p."MinMargin" AS min_margin,
p."CorrectedMargin" AS corrected_margin,
p."Status" AS status,
p."RiskLevel" AS risk_level,
CASE
WHEN p."FundsLevel" >= 9.99 THEN NULL::numeric
ELSE p."FundsLevel"
END AS uds,
CASE
WHEN p."FundsLevel" >= 9.99 THEN 'INF'::text
WHEN p."FundsLevel" <= '-9.99'::numeric THEN 'CALL'::text
ELSE NULL::text
END AS uds_flag,
GREATEST(- p."RCV1", 0::double precision) AS margin_requirement,
npr._zone(p."FundsLevel") AS zone,
hb.last_tick AS ts
FROM "Portfolio" p
CROSS JOIN ( SELECT heartbeat.last_tick
FROM npr.heartbeat
WHERE heartbeat.id = 1) hb
WHERE p."MarginType"::text = ANY (ARRAY['МД'::text, 'МД+'::text, 'С'::text, 'C'::text]);
GRANT SELECT ON npr.v_current TO quik_ro;
+131
View File
@@ -0,0 +1,131 @@
{{define "section"}}
{{$X := .}}{{$S := $X.S}}
<h1>QUIK — Экспорт биржевой информации</h1>
<p class="muted">Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак <code>quik-export</code>.</p>
{{range $X.Warnings}}<div class="card" style="padding:8px 14px;margin-bottom:8px;background:color-mix(in srgb,var(--warn) 10%,var(--card));display:flex;gap:8px;align-items:center"><span class="dot warn"></span>{{.}}</div>{{end}}
<div class="card">
<h2><span class="dot {{if not $X.Licensed}}err{{else if and $S.Enabled (eq $X.Service.Active "active")}}ok{{else if $S.Enabled}}warn{{else}}err{{end}}"></span>Состояние модуля</h2>
<form method="post" action="/admin/setup/quikexport" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="module">
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="enabled" value="1" {{if $S.Enabled}}checked{{end}} {{if not $X.Licensed}}disabled{{end}}> Модуль подключён</label>
<button type="submit" class="btn" {{if not $X.Licensed}}disabled{{end}}>Сохранить</button>
{{if not $X.Licensed}}<span class="muted">не входит в лицензию — см. раздел «Лицензия»</span>{{end}}
</form>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px">
<div class="nmini" style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Служба quikexport</div>
<strong>{{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}}</strong>
<div class="muted" style="font-size:11.5px">{{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}</div></div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Данные из QUIK</div>
{{if $X.DB.OK}}<strong>{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}}</strong>
<div class="muted" style="font-size:11.5px">{{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}</div>
{{else}}<strong style="color:var(--err)">база недоступна</strong><div class="muted" style="font-size:11.5px">{{$X.DB.Error}}</div>{{end}}</div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Объём базы {{$S.DBName}}</div>
<strong>{{printf "%.0f" $X.DB.DBSizeMB}} МБ</strong>
<div class="muted" style="font-size:11.5px">история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)</div></div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}</div>
{{with $X.DiskDB}}{{if .OK}}<strong style="{{if lt .FreeGB 10.0}}color:var(--err){{end}}">свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}</strong><div class="muted" style="font-size:11.5px">занято {{.UsedPct}}% · {{.Path}}</div>{{end}}{{end}}
{{if not $X.DiskDB.OK}}{{with $X.DiskExp}}<strong>свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}</strong><div class="muted" style="font-size:11.5px">занято {{.UsedPct}}%</div>{{end}}{{end}}</div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Логи и кэш экспортёра</div>
<strong>{{printf "%.0f" $X.LogsMB}} МБ логов</strong>
<div class="muted" style="font-size:11.5px">{{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий</div></div>
</div>
{{if $X.DB.Tables}}<details style="margin-top:10px"><summary class="muted" style="cursor:pointer">Крупнейшие таблицы базы</summary>
<table style="font-size:12px;margin-top:6px"><thead><tr><th>Таблица</th><th>Размер</th><th>Строк (оценка)</th></tr></thead><tbody>{{range $X.DB.Tables}}<tr><td><code>{{.Name}}</code></td><td>{{.Size}}</td><td>{{.Rows}}</td></tr>{{end}}</tbody></table></details>{{end}}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="restart"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>Перезапустить службу</button></form>
<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="{{if eq $X.Service.Active "active"}}stop{{else}}start{{end}}"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>{{if eq $X.Service.Active "active"}}Остановить{{else}}Запустить{{end}}</button></form>
{{if and $X.Service.Installed (not $X.Service.Enabled)}}<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="enable"><button class="btn">Включить автозапуск</button></form>{{end}}
</div>
{{if $X.LogTail}}<details style="margin-top:10px"><summary class="muted" style="cursor:pointer">Последние строки export.log{{if $X.ErrTail}} и export.err{{end}}</summary>
<pre style="font-size:11px;white-space:pre-wrap;max-height:260px;overflow:auto;background:var(--card-2);padding:8px;border-radius:6px">{{range $X.LogTail}}{{.}}
{{end}}{{if $X.ErrTail}}--- export.err ---
{{range $X.ErrTail}}{{.}}
{{end}}{{end}}</pre></details>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if and $S.QuikHost $S.QuikUser $S.KeysNames}}ok{{else}}warn{{end}}"></span>1. Подключение к серверу QUIK</h2>
<p class="muted">Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей <code>pubring.txk</code> / <code>secring.txk</code> к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;grid-template-columns:2fr 1fr;gap:10px;max-width:720px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="quik">
<label>Адрес сервера QUIK <input type="text" name="quik_host" value="{{$S.QuikHost}}" placeholder="91.209.122.88" style="width:100%" required></label>
<label>Порт <input type="number" name="quik_port" value="{{$S.QuikPort}}" style="width:100%"></label>
<label>Пользователь QUIK <input type="text" name="quik_user" value="{{$S.QuikUser}}" style="width:100%" required></label>
<label>Пароль <input type="password" name="quik_password" value="" placeholder="{{if $S.QuikPassword}}(сохранён, оставьте пустым){{else}}пароль{{end}}" style="width:100%"></label>
<label>Имя авторизации (auth-name) <input type="text" name="auth_name" value="{{$S.AuthName}}" placeholder="FontV" style="width:100%"></label>
<div style="align-self:end"><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
<form method="post" action="/admin/setup/quikexport/keys" enctype="multipart/form-data" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;max-width:720px;margin-top:14px;align-items:end">
<input type="hidden" name="section" value="sec-quikexport">
<label>Открытый ключ pubring.txk <input type="file" name="pubring" accept=".txk" style="width:100%"></label>
<label>Закрытый ключ secring.txk <input type="file" name="secring" accept=".txk" style="width:100%"></label>
<button type="submit" class="btn">Загрузить ключи</button>
</form>
<p class="muted" style="font-size:12px;margin-top:6px">Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}<code>{{.}}</code> {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}.
В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}</p>
</div>
<div class="card">
<h2><span class="dot {{if and $S.DBPassword $S.ROPassword}}ok{{else}}warn{{end}}"></span>2. База данных экспорта</h2>
<p class="muted">Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;max-width:720px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="db">
<div style="display:flex;gap:18px;flex-wrap:wrap">
<label style="display:flex;gap:6px;align-items:center;font-weight:600;margin:0"><input type="radio" name="db_mode" value="local" {{if ne $S.DBMode "external"}}checked{{end}} onchange="document.getElementById('qx-ext').style.display='none'"> Наш PostgreSQL (этот сервер)</label>
<label style="display:flex;gap:6px;align-items:center;font-weight:600;margin:0"><input type="radio" name="db_mode" value="external" {{if eq $S.DBMode "external"}}checked{{end}} onchange="document.getElementById('qx-ext').style.display='grid'"> Внешний сервер PostgreSQL</label>
</div>
<div id="qx-ext" style="display:{{if eq $S.DBMode "external"}}grid{{else}}none{{end}};grid-template-columns:2fr 1fr;gap:10px">
<label>Хост <input type="text" name="db_host" value="{{$S.DBHost}}" style="width:100%"></label>
<label>Порт <input type="number" name="db_port" value="{{$S.DBPort}}" style="width:100%"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px">
<label>База <input type="text" name="db_name" value="{{$S.DBName}}" style="width:100%"></label>
<label>Пользователь экспорта (запись) <input type="text" name="db_user" value="{{$S.DBUser}}" style="width:100%"></label>
<label>Пароль <input type="password" name="db_password" value="" placeholder="{{if $S.DBPassword}}(сохранён){{else}}задать{{end}}" style="width:100%"></label>
<label>Логин только для чтения <input type="text" name="ro_user" value="{{$S.ROUser}}" style="width:100%"></label>
<label>Пароль <input type="password" name="ro_password" value="" placeholder="{{if $S.ROPassword}}(сохранён){{else}}задать{{end}}" style="width:100%"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button>
<button type="submit" formaction="/admin/setup/quikexport/dbinit" class="btn" {{if eq $S.DBMode "external"}}disabled{{end}}>Создать базу и роли на этом сервере</button>
<a class="btn" href="/admin/setup/quikexport/sql">SQL для внешнего сервера</a></div>
</form>
<p class="muted" style="font-size:12px;margin-top:8px">Доступ для внешних систем и других модулей: <code>{{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}}</code>
{{if $X.ShowRO}}<a href="/admin/setup#quikexport">скрыть пароль</a>{{else}}<a href="/admin/setup?reveal_ro=1#quikexport">показать пароль</a>{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост <code>{{$X.ServerIP}}</code>, доступ по IP открывает администратор (pg_hba){{end}}.</p>
</div>
<div class="card">
<h2><span class="dot ok"></span>3. Объём данных</h2>
<p class="muted">Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме <code>npr</code>.</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="volume">
<div style="display:grid;gap:6px">{{range $X.Presets}}
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0"><input type="radio" name="preset" value="{{.Key}}" {{if eq $S.Preset .Key}}checked{{end}} onchange="document.getElementById('qx-tables').style.display=this.value==='custom'?'block':'none'"><span><b>{{.Title}}</b><br><span class="muted" style="font-size:12px">{{.Desc}}</span></span></label>{{end}}
</div>
<div id="qx-tables" style="display:{{if eq $S.Preset "custom"}}block{{else}}none{{end}};border:1px solid var(--border);border-radius:8px;padding:10px">
{{range $g := $X.Groups}}<div style="font-weight:600;margin:6px 0 4px">{{$g}}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:4px 12px">{{range $X.Tables}}{{if eq .Group $g}}
<label style="display:flex;gap:6px;align-items:flex-start;font-weight:400;margin:0;font-size:12.5px"><input type="checkbox" name="tables" value="{{.Name}}" {{if index $X.Enabled .Name}}checked{{end}}><span><code>{{.Name}}</code> — {{.Title}} <span class="muted">· объём: {{.Load}}{{if .Note}} · {{.Note}}{{end}}</span></span></label>{{end}}{{end}}</div>{{end}}
</div>
<details><summary class="muted" style="cursor:pointer">Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}<code>{{.}}</code> {{end}}</summary></details>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;max-width:820px">
<label>Период портфелей, с <input type="number" name="portfolio_period" min="10" value="{{$S.PortfolioPeriod}}" style="width:100%"></label>
<label>Период «Текущих торгов», с <input type="number" name="param_period" min="0" value="{{$S.ParamPeriod}}" style="width:100%"></label>
<label>Виды лимитов (LimitKinds) <input type="text" name="limit_kinds" value="{{$S.LimitKinds}}" style="width:100%"></label>
<label>Уровень лога (DebugLevel) <input type="number" name="debug_level" min="0" max="255" value="{{$S.DebugLevel}}" style="width:100%"></label>
</div>
<div><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
</div>
<div class="card">
<h2><span class="dot {{if not $S.AppliedAt.IsZero}}ok{{else}}warn{{end}}"></span>4. Применить к экспортёру</h2>
<p class="muted">bj собирает <code>export.ini</code>, <code>info.ini</code>, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы (<code>{{$S.Dir}}</code>) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<a class="btn" href="/admin/setup/quikexport/preview" target="_blank">Предпросмотр файлов</a>
<form method="post" action="/admin/setup/quikexport/apply" onsubmit="return confirm('Установить настройки в экспортёр и перезапустить службу? Выгрузка прервётся на несколько секунд.')"><input type="hidden" name="section" value="sec-quikexport"><button class="btn btn-ok" {{if not $X.Licensed}}disabled{{end}}>Применить настройки и перезапустить экспортёр</button></form>
<form method="post" action="/admin/setup/quikexport/import"><input type="hidden" name="section" value="sec-quikexport"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>Прочитать настройки из экспортёра</button></form>
</div>
<p class="muted" style="font-size:12px;margin-top:10px">Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в <code>{{$S.Dir}}</code>; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.</p>
</div>
{{end}}