893e1222ab
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1407 lines
55 KiB
Go
1407 lines
55 KiB
Go
package quikexport
|
||
|
||
// Модуль «Экспорт биржевой информации» — управление ARQA quikexport из bj:
|
||
// ключи QUIK, логин/пароль, где держим базу (наш PostgreSQL или внешний),
|
||
// какой объём данных выгружаем (набор таблиц), состояние службы, объём базы,
|
||
// свободное место и доступ для внешних систем. Файлы экспортёра принадлежат
|
||
// arqasrv, служба — systemd, поэтому запись и перезапуск идут через root-воркер
|
||
// (bj-keymedia-worker → /usr/local/sbin/bj-quikexport-admin), а чтение состояния
|
||
// (systemctl show, размеры логов, статистика базы) служба делает сама.
|
||
//
|
||
// Работает отдельной службой bj-quikexport (cmd/bj-quikexport, server.go), обновляется
|
||
// независимо от ядра; раздел настроек показывает bj-server на странице «Настройка».
|
||
//
|
||
// Лицензионный признак модуля: quik-export.
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"embed"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
"golang.org/x/text/encoding/charmap"
|
||
)
|
||
|
||
//go:embed web/quikexport/*
|
||
var quikExportFS embed.FS
|
||
|
||
const (
|
||
quikExportDefaultDir = "/opt/arqasrv/quik/utils/quikexport"
|
||
quikExportStaging = "/var/lib/bj/quik-export-apply"
|
||
quikExportKeysDir = "/var/lib/bj/.bj/quik-export/keys"
|
||
quikExportFeature = "quik-export"
|
||
)
|
||
|
||
// QuikExportSettings — настройки модуля (setup.json → quik_export).
|
||
type QuikExportSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Dir string `json:"dir"` // каталог службы экспортёра
|
||
|
||
// Подключение к серверу QUIK (info.ini [connect] + export.ini [QUIK Server]).
|
||
QuikHost string `json:"quik_host"`
|
||
QuikPort int `json:"quik_port"`
|
||
QuikUser string `json:"quik_user"`
|
||
QuikPassword string `json:"quik_password"`
|
||
AuthName string `json:"auth_name"` // auth-name из info.ini (выдаёт брокер/ARQA)
|
||
|
||
// База данных экспорта.
|
||
DBMode string `json:"db_mode"` // local — наш PostgreSQL; external — внешний сервер
|
||
DBHost string `json:"db_host"`
|
||
DBPort int `json:"db_port"`
|
||
DBName string `json:"db_name"`
|
||
DBUser string `json:"db_user"`
|
||
DBPassword string `json:"db_password"`
|
||
ROUser string `json:"ro_user"` // логин только для чтения (для внешних систем и модулей bj)
|
||
ROPassword string `json:"ro_password"`
|
||
|
||
// Объём данных.
|
||
Preset string `json:"preset"` // packages — набор пакетов; custom — таблицы вручную (npr|depo|trading|full — старые наборы)
|
||
Packages []string `json:"packages"` // выбранные пакеты данных
|
||
Tables []string `json:"tables"`
|
||
PortfolioPeriod int `json:"portfolio_period"`
|
||
ParamPeriod int `json:"param_period"`
|
||
StatisticPeriod int `json:"statistic_period"`
|
||
DebugLevel int `json:"debug_level"`
|
||
LimitKinds string `json:"limit_kinds"`
|
||
|
||
KeysAt time.Time `json:"keys_at,omitempty"`
|
||
KeysNames []string `json:"keys_names,omitempty"`
|
||
AppliedAt time.Time `json:"applied_at,omitempty"`
|
||
Imported bool `json:"imported"`
|
||
}
|
||
|
||
func (s QuikExportSettings) withDefaults() QuikExportSettings {
|
||
if s.Dir == "" {
|
||
s.Dir = quikExportDefaultDir
|
||
}
|
||
if s.QuikPort == 0 {
|
||
s.QuikPort = 15100
|
||
}
|
||
if s.DBMode == "" {
|
||
s.DBMode = "local"
|
||
}
|
||
if s.DBHost == "" {
|
||
s.DBHost = "localhost"
|
||
}
|
||
if s.DBPort == 0 {
|
||
s.DBPort = 5432
|
||
}
|
||
if s.DBName == "" {
|
||
s.DBName = "quik_export"
|
||
}
|
||
if s.DBUser == "" {
|
||
s.DBUser = "quik_export"
|
||
}
|
||
if s.ROUser == "" {
|
||
s.ROUser = "quik_ro"
|
||
}
|
||
if s.Preset == "" {
|
||
s.Preset = "npr"
|
||
}
|
||
if s.Preset != "custom" { // старые наборы (npr/depo/trading/full) переводим в пакеты
|
||
if len(s.Packages) == 0 {
|
||
if len(s.Tables) > 0 {
|
||
s.Packages = quikExportPackagesOf(s.Tables)
|
||
} else {
|
||
s.Packages = quikExportPackagesOf(quikExportPresetTables(s.Preset))
|
||
}
|
||
}
|
||
s.Preset = "packages"
|
||
s.Tables = quikExportPackageTables(s.Packages)
|
||
}
|
||
if len(s.Tables) == 0 {
|
||
s.Tables = []string{"Portfolio"}
|
||
}
|
||
if s.PortfolioPeriod == 0 {
|
||
s.PortfolioPeriod = 60
|
||
}
|
||
if s.ParamPeriod == 0 {
|
||
s.ParamPeriod = 30
|
||
}
|
||
if s.StatisticPeriod == 0 {
|
||
s.StatisticPeriod = 60
|
||
}
|
||
if s.DebugLevel == 0 {
|
||
s.DebugLevel = 1
|
||
}
|
||
if s.LimitKinds == "" {
|
||
s.LimitKinds = "0, 1, 365"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// dbHost — хост базы для подключений из bj.
|
||
func (s QuikExportSettings) dbHost() string {
|
||
if s.DBMode != "external" {
|
||
return "localhost"
|
||
}
|
||
return s.DBHost
|
||
}
|
||
|
||
// RODSN — строка подключения логина только для чтения (для модуля НПР и внешних систем).
|
||
func (s QuikExportSettings) RODSN(withPassword bool) string {
|
||
pw := "•••••"
|
||
if withPassword {
|
||
pw = s.ROPassword
|
||
}
|
||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s", s.ROUser, pw, s.dbHost(), s.DBPort, s.DBName)
|
||
}
|
||
|
||
// ---------- каталог таблиц экспорта ----------
|
||
|
||
// quikExportTable — одна таблица экспортёра ([Output tables] в export.ini).
|
||
type quikExportTable struct {
|
||
Name string
|
||
Title string
|
||
Group string
|
||
Load string // объём: низкий | средний | высокий | очень высокий
|
||
Note string
|
||
}
|
||
|
||
var quikExportTables = []quikExportTable{
|
||
{"Portfolio", "Клиентский портфель (НПР1/НПР2/УДС, стоимость портфеля, маржа)", "Портфели и лимиты", "средний", "обязателен для модуля НПР/УДС; ≈20 МБ/сутки истории при 5 тыс. клиентов и периоде 60 с"},
|
||
{"DepoLimits", "Лимиты по бумагам (позиции ДЕПО клиентов)", "Портфели и лимиты", "средний", "нужен для доп. проверок M2M: позиция клиента в QUIK перед списанием"},
|
||
{"MoneyLimits", "Денежные лимиты клиентов", "Портфели и лимиты", "средний", ""},
|
||
{"AccountPositions", "Позиции по счетам", "Портфели и лимиты", "низкий", ""},
|
||
{"FuturesHolding", "Позиции на срочном рынке", "Портфели и лимиты", "средний", ""},
|
||
{"FuturesLimits", "Лимиты срочного рынка", "Портфели и лимиты", "низкий", ""},
|
||
{"DepoLimitsJournal", "Журнал изменений лимитов по бумагам", "Портфели и лимиты", "высокий", "растёт постоянно"},
|
||
{"MoneyLimitsJournal", "Журнал изменений денежных лимитов", "Портфели и лимиты", "высокий", "растёт постоянно"},
|
||
{"FuturesHoldingJournal", "Журнал позиций срочного рынка", "Портфели и лимиты", "высокий", ""},
|
||
{"Classes", "Классы инструментов", "Справочники", "низкий", ""},
|
||
{"Securities", "Инструменты (бумаги)", "Справочники", "низкий", "нужен, чтобы сопоставлять ISIN и коды QUIK"},
|
||
{"SecuritiesDictionary", "Справочник инструментов (расширенный)", "Справочники", "низкий", ""},
|
||
{"Firms", "Фирмы", "Справочники", "низкий", ""},
|
||
{"Trdaccs", "Торговые счета", "Справочники", "низкий", ""},
|
||
{"FirmDepoAccount", "Счета ДЕПО фирмы", "Справочники", "низкий", ""},
|
||
{"SecDepoAccount", "Счета ДЕПО по бумагам", "Справочники", "низкий", ""},
|
||
{"Calendars", "Календари торгов", "Справочники", "низкий", ""},
|
||
{"SettleCodes", "Коды расчётов", "Справочники", "низкий", ""},
|
||
{"CurrencyInfo", "Валюты", "Справочники", "низкий", ""},
|
||
{"Prohibitions", "Запреты (ограничения торговли)", "Справочники", "низкий", ""},
|
||
{"Trades", "Сделки клиентов", "Сделки и заявки", "высокий", "все сделки фирмы за день"},
|
||
{"Orders", "Заявки клиентов", "Сделки и заявки", "высокий", ""},
|
||
{"StopOrders", "Стоп-заявки", "Сделки и заявки", "средний", ""},
|
||
{"NegDeals", "Заявки на внебиржевые сделки (РПС)", "Сделки и заявки", "низкий", ""},
|
||
{"NegReports", "Отчёты по внебиржевым сделкам", "Сделки и заявки", "низкий", ""},
|
||
{"UsTrades", "Сделки для исполнения", "Сделки и заявки", "средний", ""},
|
||
{"AlgoOrders", "Алгоритмические заявки", "Сделки и заявки", "средний", ""},
|
||
{"AlgoLinkedOrders", "Связанные алго-заявки", "Сделки и заявки", "средний", ""},
|
||
{"AlgoLinkedTrades", "Сделки алго-заявок", "Сделки и заявки", "средний", ""},
|
||
{"TransResult", "Результаты транзакций", "Сделки и заявки", "средний", ""},
|
||
{"NCCTransfers", "Переводы НКЦ", "Сделки и заявки", "низкий", ""},
|
||
{"AllTrades", "Все обезличенные сделки рынка", "Рыночные данные", "очень высокий", "сотни МБ – ГБ в сутки; не нужен для депозитарных задач"},
|
||
{"Quotes", "Стаканы котировок", "Рыночные данные", "очень высокий", "ГБ в сутки; не нужен"},
|
||
{"Params", "Текущие торги (параметры инструментов)", "Рыночные данные", "высокий", "цены для оценки портфеля; период ParamPeriod"},
|
||
{"CCPHold", "Обеспечение НКЦ", "Рыночные данные", "низкий", ""},
|
||
{"CCPIndicative", "Индикативные курсы НКЦ", "Рыночные данные", "низкий", ""},
|
||
{"CCPPriceRange", "Ценовые коридоры НКЦ", "Рыночные данные", "низкий", ""},
|
||
{"MarketMakerStats", "Статистика маркет-мейкера", "Рыночные данные", "средний", ""},
|
||
{"MarketMakerForts", "Статистика ММ на срочном рынке", "Рыночные данные", "средний", ""},
|
||
{"MarketMakerExtStats", "Расширенная статистика ММ", "Рыночные данные", "средний", ""},
|
||
{"RmHold", "Риск-параметры: обеспечение", "Рыночные данные", "низкий", ""},
|
||
{"RmIndicative", "Риск-параметры: индикативные курсы", "Рыночные данные", "низкий", ""},
|
||
{"RmPriceRange", "Риск-параметры: ценовые коридоры", "Рыночные данные", "низкий", ""},
|
||
{"RmPriceRangeFirm", "Риск-параметры: коридоры по фирме", "Рыночные данные", "низкий", ""},
|
||
{"RmProductSpreadCurr", "Риск-параметры: спреды", "Рыночные данные", "низкий", ""},
|
||
{"SecAccIntEx", "Внутренние переводы бумаг", "Прочее", "низкий", ""},
|
||
}
|
||
|
||
// quikExportPackage — «пакет данных»: набор таблиц экспортёра под конкретную задачу. Оператор
|
||
// отмечает пакеты галочками, bj сам собирает из них секцию [Output tables] в export.ini.
|
||
type quikExportPackage struct {
|
||
Key string
|
||
Title string
|
||
What string // какие данные приходят
|
||
Gives []string // что это даёт: кому и для чего
|
||
Load string // объём
|
||
Warn string // предупреждение (пусто — нет)
|
||
Tables []string
|
||
}
|
||
|
||
var quikExportPackages = []quikExportPackage{
|
||
{"risk", "Маржа и риск клиента", "Стоимость портфеля, начальная и минимальная маржа, скорректированная маржа, НПР1/НПР2, уровень достаточности средств (УДС), статус расчёта.",
|
||
[]string{
|
||
"Модуль «НПР / УДС»: списки клиентов, зоны риска, история УДС — без пакета модуль пуст",
|
||
"Личный кабинет: показатель достаточности средств и предупреждение о марджин-колле",
|
||
"Оповещения: выход клиента в зону риска",
|
||
}, "≈20 МБ/сутки при 5 тыс. клиентов и периоде 60 с", "", []string{"Portfolio"}},
|
||
{"positions", "Позиции и деньги клиента", "Остатки бумаг по торговым счетам (лимиты ДЕПО), денежные лимиты и позиции по счетам.",
|
||
[]string{
|
||
"Личный кабинет: портфель клиента по торговым счетам — что есть и сколько заблокировано",
|
||
"M2M: проверка позиции в торговой системе перед списанием бумаг и сверка с остатком депозитария",
|
||
"Бэк-офис: расхождения между учётом депозитария и торговой системой",
|
||
}, "средний — десятки МБ/сутки", "", []string{"DepoLimits", "MoneyLimits", "AccountPositions"}},
|
||
{"refs", "Справочники бумаг и счетов", "Инструменты и их коды, классы, фирмы, торговые счета, счета депо, валюты, коды расчётов, календари торгов.",
|
||
[]string{
|
||
"Сопоставление ISIN с кодом бумаги в QUIK — без этого позиции не связать с депозитарными",
|
||
"Личный кабинет: нормальные наименования бумаг вместо кодов",
|
||
"Привязка клиента к его торговому счёту и счёту депо",
|
||
}, "низкий — единицы МБ", "", []string{"Classes", "Securities", "SecuritiesDictionary", "Firms", "Trdaccs", "FirmDepoAccount", "SecDepoAccount", "CurrencyInfo", "SettleCodes", "Calendars"}},
|
||
{"prices", "Цены для оценки портфеля", "Текущие торги: последние цены, изменение к закрытию, параметры инструментов.",
|
||
[]string{
|
||
"Личный кабинет: стоимость портфеля и позиций в рублях, изменение за день",
|
||
"Отчёты и выписки с оценкой по рыночной цене",
|
||
}, "высокий — зависит от числа инструментов и периода «Текущих торгов»", "", []string{"Params"}},
|
||
{"deals", "Сделки и заявки клиента", "Сделки, заявки, стоп-заявки, внебиржевые сделки и отчёты по ним, результаты транзакций.",
|
||
[]string{
|
||
"Личный кабинет: история сделок и заявок клиента",
|
||
"Отчёты брокера и сверка операций",
|
||
}, "высокий — сотни МБ в активные дни", "", []string{"Trades", "Orders", "StopOrders", "NegDeals", "NegReports", "UsTrades", "TransResult"}},
|
||
{"futures", "Срочный рынок", "Позиции и лимиты на срочном рынке (FORTS).",
|
||
[]string{
|
||
"Личный кабинет и НПР: показатели клиентов, торгующих фьючерсами",
|
||
}, "средний", "", []string{"FuturesHolding", "FuturesLimits"}},
|
||
{"journals", "Журналы изменений лимитов", "История изменения денежных лимитов, лимитов по бумагам и позиций срочного рынка.",
|
||
[]string{
|
||
"Разбор спорных ситуаций: когда и на сколько изменилась позиция клиента",
|
||
"Аудит и внутренний контроль",
|
||
}, "высокий — растёт постоянно", "Журналы не очищаются экспортёром: следите за местом на диске.", []string{"MoneyLimitsJournal", "DepoLimitsJournal", "FuturesHoldingJournal"}},
|
||
{"ncc", "Риск-параметры НКЦ", "Обеспечение, ценовые коридоры, индикативные курсы, спреды, статистика маркет-мейкера.",
|
||
[]string{
|
||
"Риск-менеджмент и отчётность по требованиям НКЦ",
|
||
"Проверка ценовых коридоров при оценке обеспечения",
|
||
}, "низкий — средний", "", []string{"CCPHold", "CCPIndicative", "CCPPriceRange", "RmHold", "RmIndicative", "RmPriceRange", "RmPriceRangeFirm", "RmProductSpreadCurr", "MarketMakerStats", "MarketMakerForts", "MarketMakerExtStats"}},
|
||
{"market", "Весь рынок: обезличенные сделки и стаканы", "Все сделки рынка и котировки (стаканы) по инструментам.",
|
||
[]string{
|
||
"Аналитика и собственные расчёты по рынку",
|
||
}, "очень высокий — гигабайты в сутки", "Включайте только при явной необходимости и запасе места на диске: нагрузка на сервер QUIK и базу возрастает в разы.", []string{"AllTrades", "Quotes"}},
|
||
{"misc", "Прочее", "Внутренние переводы бумаг, переводы НКЦ, запреты, алгоритмические заявки.",
|
||
[]string{
|
||
"Редкие задачи бэк-офиса и расследования",
|
||
}, "низкий", "", []string{"SecAccIntEx", "NCCTransfers", "Prohibitions", "AlgoOrders", "AlgoLinkedOrders", "AlgoLinkedTrades"}},
|
||
}
|
||
|
||
// quikExportPackageTables — таблицы выбранных пакетов (Portfolio входит всегда: на нём держится НПР).
|
||
func quikExportPackageTables(keys []string) []string {
|
||
on := map[string]bool{"Portfolio": true}
|
||
for _, k := range keys {
|
||
for _, p := range quikExportPackages {
|
||
if p.Key == k {
|
||
for _, t := range p.Tables {
|
||
on[t] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out := make([]string, 0, len(on))
|
||
for _, t := range quikExportTables { // порядок каталога
|
||
if on[t.Name] {
|
||
out = append(out, t.Name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// quikExportPackagesOf — какие пакеты покрыты набором таблиц (все таблицы пакета включены).
|
||
func quikExportPackagesOf(tables []string) []string {
|
||
on := map[string]bool{}
|
||
for _, t := range tables {
|
||
on[t] = true
|
||
}
|
||
var out []string
|
||
for _, p := range quikExportPackages {
|
||
all := true
|
||
for _, t := range p.Tables {
|
||
if !on[t] {
|
||
all = false
|
||
break
|
||
}
|
||
}
|
||
if all {
|
||
out = append(out, p.Key)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
var quikExportPresets = []struct{ Key, Title, Desc string }{
|
||
{"npr", "Минимум — только НПР/УДС", "Portfolio. Достаточно для модуля «НПР / УДС». ≈20 МБ/сутки."},
|
||
{"depo", "Депозитарий — портфели, позиции, справочники", "Portfolio + DepoLimits, MoneyLimits, AccountPositions + справочники бумаг и счетов. Даёт bj дополнительные проверки при M2M (позиция клиента в QUIK). ≈50–100 МБ/сутки."},
|
||
{"trading", "Торговля — плюс сделки и заявки", "Всё из «Депозитарий» + Trades, Orders, StopOrders, NegDeals/NegReports, UsTrades, TransResult. Сотни МБ/сутки в активные дни."},
|
||
{"full", "Полный набор", "Все таблицы, включая обезличенные сделки и стаканы. Гигабайты в сутки — только при явной необходимости."},
|
||
{"custom", "Свой набор", "Отметьте таблицы вручную."},
|
||
}
|
||
|
||
func quikExportPresetTables(preset string) []string {
|
||
depo := []string{"Portfolio", "DepoLimits", "MoneyLimits", "AccountPositions", "Classes", "Securities", "Firms", "Trdaccs", "FirmDepoAccount", "SecDepoAccount", "CurrencyInfo"}
|
||
switch preset {
|
||
case "depo":
|
||
return depo
|
||
case "trading":
|
||
return append(depo, "Trades", "Orders", "StopOrders", "NegDeals", "NegReports", "UsTrades", "TransResult")
|
||
case "full":
|
||
out := make([]string, 0, len(quikExportTables))
|
||
for _, t := range quikExportTables {
|
||
out = append(out, t.Name)
|
||
}
|
||
return out
|
||
}
|
||
return []string{"Portfolio"}
|
||
}
|
||
|
||
// ---------- сборка ini ----------
|
||
|
||
// iniSet — в тексте ini выставляет key=value в секции section (раскомментирует
|
||
// «;key=» при необходимости, иначе добавляет в конец секции). Секция создаётся,
|
||
// если её нет. Прочие строки сохраняются как есть.
|
||
func iniSet(text, section, key, value string) string {
|
||
lines := strings.Split(text, "\n")
|
||
inSec, done := false, false
|
||
secStart, secEnd := -1, -1
|
||
for i, ln := range lines {
|
||
t := strings.TrimSpace(ln)
|
||
if strings.HasPrefix(t, "[") {
|
||
if inSec && secEnd < 0 {
|
||
secEnd = i
|
||
}
|
||
inSec = strings.EqualFold(t, "["+section+"]")
|
||
if inSec {
|
||
secStart = i
|
||
}
|
||
continue
|
||
}
|
||
if !inSec || done {
|
||
continue
|
||
}
|
||
k := strings.TrimLeft(t, "; \t")
|
||
if eq := strings.Index(k, "="); eq > 0 && strings.EqualFold(strings.TrimSpace(k[:eq]), key) {
|
||
lines[i] = key + "=" + value
|
||
done = true
|
||
}
|
||
}
|
||
if done {
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
if secStart < 0 {
|
||
return strings.TrimRight(text, "\n") + "\n\n[" + section + "]\n" + key + "=" + value + "\n"
|
||
}
|
||
if secEnd < 0 {
|
||
secEnd = len(lines)
|
||
}
|
||
// вставляем перед пустыми строками в конце секции
|
||
ins := secEnd
|
||
for ins > secStart+1 && strings.TrimSpace(lines[ins-1]) == "" {
|
||
ins--
|
||
}
|
||
out := append([]string{}, lines[:ins]...)
|
||
out = append(out, key+"="+value)
|
||
out = append(out, lines[ins:]...)
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// iniOutputTables — в секции [Output tables] включает ровно перечисленные таблицы.
|
||
func iniOutputTables(text string, enabled []string) string {
|
||
on := map[string]bool{}
|
||
for _, n := range enabled {
|
||
on[n] = true
|
||
}
|
||
lines := strings.Split(text, "\n")
|
||
inSec := false
|
||
seen := map[string]bool{}
|
||
insertAt := -1
|
||
for i, ln := range lines {
|
||
t := strings.TrimSpace(ln)
|
||
if strings.HasPrefix(t, "[") {
|
||
if inSec {
|
||
insertAt = i
|
||
}
|
||
inSec = strings.EqualFold(t, "[Output tables]")
|
||
continue
|
||
}
|
||
if !inSec {
|
||
continue
|
||
}
|
||
k := strings.TrimLeft(t, "; \t")
|
||
eq := strings.Index(k, "=")
|
||
if eq <= 0 {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(k[:eq])
|
||
seen[name] = true
|
||
if on[name] {
|
||
lines[i] = name + "=" + strings.TrimSpace(k[eq+1:])
|
||
} else {
|
||
lines[i] = ";" + name + "=" + strings.TrimSpace(k[eq+1:])
|
||
}
|
||
}
|
||
// таблицы, которых нет в шаблоне
|
||
var extra []string
|
||
for _, n := range enabled {
|
||
if !seen[n] {
|
||
extra = append(extra, n+"="+n)
|
||
}
|
||
}
|
||
if len(extra) > 0 {
|
||
if insertAt < 0 {
|
||
insertAt = len(lines)
|
||
}
|
||
for insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) == "" {
|
||
insertAt--
|
||
}
|
||
out := append([]string{}, lines[:insertAt]...)
|
||
out = append(out, extra...)
|
||
out = append(out, lines[insertAt:]...)
|
||
lines = out
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// iniGet — значение ключа в секции (пустая строка, если нет или закомментирован).
|
||
func iniGet(text, section, key string) string {
|
||
inSec := false
|
||
for _, ln := range strings.Split(text, "\n") {
|
||
t := strings.TrimSpace(ln)
|
||
if strings.HasPrefix(t, "[") {
|
||
inSec = strings.EqualFold(t, "["+section+"]")
|
||
continue
|
||
}
|
||
if !inSec || strings.HasPrefix(t, ";") {
|
||
continue
|
||
}
|
||
if eq := strings.Index(t, "="); eq > 0 && strings.EqualFold(strings.TrimSpace(t[:eq]), key) {
|
||
return strings.TrimSpace(t[eq+1:])
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// iniEnabledOutputTables — включённые таблицы из [Output tables].
|
||
func iniEnabledOutputTables(text string) []string {
|
||
var out []string
|
||
inSec := false
|
||
for _, ln := range strings.Split(text, "\n") {
|
||
t := strings.TrimSpace(ln)
|
||
if strings.HasPrefix(t, "[") {
|
||
inSec = strings.EqualFold(t, "[Output tables]")
|
||
continue
|
||
}
|
||
if !inSec || strings.HasPrefix(t, ";") {
|
||
continue
|
||
}
|
||
if eq := strings.Index(t, "="); eq > 0 {
|
||
out = append(out, strings.TrimSpace(t[:eq]))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cp1251Decode(b []byte) string {
|
||
s, err := charmap.Windows1251.NewDecoder().Bytes(b)
|
||
if err != nil {
|
||
return string(b)
|
||
}
|
||
return string(s)
|
||
}
|
||
|
||
func cp1251Encode(s string) []byte {
|
||
b, err := charmap.Windows1251.NewEncoder().Bytes([]byte(s))
|
||
if err != nil {
|
||
return []byte(s)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// quikExportRenderExportINI — export.ini из вшитого шаблона ARQA и настроек.
|
||
func quikExportRenderExportINI(s QuikExportSettings) (string, error) {
|
||
b, err := quikExportFS.ReadFile("web/quikexport/export.ini.tmpl")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
t := cp1251Decode(b)
|
||
t = iniSet(t, "DB", "DSN", "quik_export")
|
||
t = iniSet(t, "DB", "User", s.DBUser)
|
||
t = iniSet(t, "DB", "Password", s.DBPassword)
|
||
t = iniSet(t, "Service", "name", "ExportService")
|
||
t = iniSet(t, "Service", "displayname", "Export Service")
|
||
t = iniSet(t, "QUIK Server", "User", s.QuikUser)
|
||
t = iniSet(t, "QUIK Server", "Password", s.QuikPassword)
|
||
t = iniSet(t, "General", "DebugLevel", strconv.Itoa(s.DebugLevel))
|
||
t = iniSet(t, "General", "StatisticPeriod", strconv.Itoa(s.StatisticPeriod))
|
||
t = iniSet(t, "General", "Memory", "10")
|
||
t = iniSet(t, "General", "ParamPeriod", strconv.Itoa(s.ParamPeriod))
|
||
t = iniSet(t, "General", "PortfolioPeriod", strconv.Itoa(s.PortfolioPeriod))
|
||
t = iniSet(t, "General", "UpdateDealLibs", "0")
|
||
t = iniSet(t, "General", "LimitKinds", s.LimitKinds)
|
||
t = iniSet(t, "Files", "LogDepth", "3")
|
||
t = iniOutputTables(t, s.Tables)
|
||
return t, nil
|
||
}
|
||
|
||
func quikExportRenderInfoINI(s QuikExportSettings) (string, error) {
|
||
b, err := quikExportFS.ReadFile("web/quikexport/info.ini.tmpl")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
t := cp1251Decode(b)
|
||
t = iniSet(t, "connect", "trade-server-ip-address", s.QuikHost)
|
||
t = iniSet(t, "connect", "auth-name", s.AuthName)
|
||
t = iniSet(t, "connect", "check-tmo", "150")
|
||
t = iniSet(t, "connect", "port", strconv.Itoa(s.QuikPort))
|
||
t = iniSet(t, "connect", "user_name", s.QuikUser)
|
||
t = iniSet(t, "connect", "user_password", s.QuikPassword)
|
||
t = iniSet(t, "connect", "RememberLogin", "1")
|
||
t = iniSet(t, "proxy", "use-proxy", "0")
|
||
t = iniSet(t, "transport", "module", "wt_dtcp.dll")
|
||
return t, nil
|
||
}
|
||
|
||
func quikExportRenderODBC(s QuikExportSettings) string {
|
||
return fmt.Sprintf("[quik_export]\nDescription = QUIK export to PostgreSQL (%s)\nDriver = PostgreSQL ANSI\nServername = %s\nPort = %d\nDatabase = %s\nConnSettings = SET client_encoding TO 'WIN1251'\nTrace = No\n",
|
||
s.DBMode, s.dbHost(), s.DBPort, s.DBName)
|
||
}
|
||
|
||
// ---------- root-операции ----------
|
||
|
||
func quikExportRootOp(ctx context.Context, action, staging, extra string) (map[string]any, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||
defer cancel()
|
||
return rootOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra})
|
||
}
|
||
|
||
func quikExportNewStaging() (string, error) {
|
||
if err := os.MkdirAll(quikExportStaging, 0o750); err != nil {
|
||
return "", err
|
||
}
|
||
dir := filepath.Join(quikExportStaging, time.Now().Format("20060102-150405"))
|
||
return dir, os.MkdirAll(dir, 0o750)
|
||
}
|
||
|
||
// quikExportApply — собирает файлы, кладёт в staging и просит root установить их.
|
||
func quikExportApply(ctx context.Context, st *Store, restart bool) (map[string]any, error) {
|
||
s := st.Get()
|
||
if s.QuikHost == "" || s.QuikUser == "" {
|
||
return nil, fmt.Errorf("не заданы сервер QUIK и логин")
|
||
}
|
||
if s.DBPassword == "" {
|
||
return nil, fmt.Errorf("не задан пароль пользователя базы экспорта")
|
||
}
|
||
dir, err := quikExportNewStaging()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
exp, err := quikExportRenderExportINI(s)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
inf, err := quikExportRenderInfoINI(s)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "export.ini"), cp1251Encode(exp), 0o640); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "info.ini"), cp1251Encode(inf), 0o640); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "odbc.ini"), []byte(quikExportRenderODBC(s)), 0o640); err != nil {
|
||
return nil, err
|
||
}
|
||
for _, k := range []string{"pubring.txk", "secring.txk"} {
|
||
if b, err := os.ReadFile(filepath.Join(quikExportKeysDir, k)); err == nil {
|
||
_ = os.WriteFile(filepath.Join(dir, k), b, 0o640)
|
||
}
|
||
}
|
||
mode := "none"
|
||
if restart && s.Enabled {
|
||
mode = "restart"
|
||
} else if !s.Enabled {
|
||
mode = "stop"
|
||
}
|
||
res, err := quikExportRootOp(ctx, "apply", dir, mode)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
s.AppliedAt = time.Now()
|
||
_ = st.Save(s)
|
||
return res, nil
|
||
}
|
||
|
||
// quikExportImport — читает текущие export.ini/info.ini экспортёра и заполняет настройки.
|
||
func quikExportImport(ctx context.Context, st *Store) (string, error) {
|
||
res, err := quikExportRootOp(ctx, "dump", "", "")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
s := st.Get()
|
||
dec := func(k string) string {
|
||
v, _ := res[k].(string)
|
||
b, _ := base64.StdEncoding.DecodeString(v)
|
||
return cp1251Decode(b)
|
||
}
|
||
exp, inf := dec("export.ini"), dec("info.ini")
|
||
if exp == "" {
|
||
return "", fmt.Errorf("export.ini не найден в каталоге экспортёра")
|
||
}
|
||
set := func(dst *string, v string) {
|
||
if v != "" {
|
||
*dst = v
|
||
}
|
||
}
|
||
set(&s.DBUser, iniGet(exp, "DB", "User"))
|
||
set(&s.DBPassword, iniGet(exp, "DB", "Password"))
|
||
set(&s.QuikUser, iniGet(exp, "QUIK Server", "User"))
|
||
set(&s.QuikPassword, iniGet(exp, "QUIK Server", "Password"))
|
||
set(&s.LimitKinds, iniGet(exp, "General", "LimitKinds"))
|
||
if v, _ := strconv.Atoi(iniGet(exp, "General", "PortfolioPeriod")); v > 0 {
|
||
s.PortfolioPeriod = v
|
||
}
|
||
if v, _ := strconv.Atoi(iniGet(exp, "General", "ParamPeriod")); v > 0 {
|
||
s.ParamPeriod = v
|
||
}
|
||
if v, _ := strconv.Atoi(iniGet(exp, "General", "StatisticPeriod")); v > 0 {
|
||
s.StatisticPeriod = v
|
||
}
|
||
if v, _ := strconv.Atoi(iniGet(exp, "General", "DebugLevel")); v > 0 {
|
||
s.DebugLevel = v
|
||
}
|
||
if tabs := iniEnabledOutputTables(exp); len(tabs) > 0 {
|
||
s.Tables = tabs
|
||
s.Packages = quikExportPackagesOf(tabs)
|
||
s.Preset = "custom" // набор из файла: пакетами описывается, только если совпадает точно
|
||
if same(tabs, quikExportPackageTables(s.Packages)) {
|
||
s.Preset = "packages"
|
||
}
|
||
}
|
||
if inf != "" {
|
||
set(&s.QuikHost, iniGet(inf, "connect", "trade-server-ip-address"))
|
||
set(&s.AuthName, iniGet(inf, "connect", "auth-name"))
|
||
if v, _ := strconv.Atoi(iniGet(inf, "connect", "port")); v > 0 {
|
||
s.QuikPort = v
|
||
}
|
||
set(&s.QuikUser, iniGet(inf, "connect", "user_name"))
|
||
set(&s.QuikPassword, iniGet(inf, "connect", "user_password"))
|
||
}
|
||
if kf, ok := res["keyfiles"].(map[string]any); ok && len(kf) > 0 {
|
||
_ = os.MkdirAll(quikExportKeysDir, 0o700)
|
||
for name, v := range kf {
|
||
if name != "pubring.txk" && name != "secring.txk" {
|
||
continue
|
||
}
|
||
if b, err := base64.StdEncoding.DecodeString(fmt.Sprint(v)); err == nil && len(b) > 0 {
|
||
_ = os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600)
|
||
}
|
||
}
|
||
}
|
||
if keys, ok := res["keys"].([]any); ok && len(keys) > 0 {
|
||
s.KeysNames = nil
|
||
for _, k := range keys {
|
||
s.KeysNames = append(s.KeysNames, fmt.Sprint(k))
|
||
}
|
||
if s.KeysAt.IsZero() {
|
||
s.KeysAt = time.Now()
|
||
}
|
||
}
|
||
// Пароли базы: если bj хранит их в db.env (перенос 14.09) — берём оттуда.
|
||
if env, err := os.ReadFile("/var/lib/bj/.bj/quik-export/db.env"); err == nil {
|
||
for _, ln := range strings.Split(string(env), "\n") {
|
||
k, v, ok := strings.Cut(ln, "=")
|
||
if !ok {
|
||
continue
|
||
}
|
||
v = strings.Trim(strings.TrimSpace(v), `"'`)
|
||
switch strings.TrimSpace(k) {
|
||
case "reader_user":
|
||
set(&s.ROUser, v)
|
||
case "reader_password":
|
||
set(&s.ROPassword, v)
|
||
case "writer_password":
|
||
if s.DBPassword == "" {
|
||
s.DBPassword = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
s.Imported = true
|
||
if err := st.Save(s); err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("сервер QUIK %s:%d, пользователь %s, таблиц включено %d (%s), период портфелей %d с", s.QuikHost, s.QuikPort, s.QuikUser, len(s.Tables), s.Preset, s.PortfolioPeriod), nil
|
||
}
|
||
|
||
func quikExportDetectPreset(tabs []string) string {
|
||
same := func(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
m := map[string]bool{}
|
||
for _, x := range a {
|
||
m[x] = true
|
||
}
|
||
for _, x := range b {
|
||
if !m[x] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
for _, p := range []string{"npr", "depo", "trading", "full"} {
|
||
if same(tabs, quikExportPresetTables(p)) {
|
||
return p
|
||
}
|
||
}
|
||
return "custom"
|
||
}
|
||
|
||
// ---------- состояние ----------
|
||
|
||
type quikExportServiceState struct {
|
||
Active string // active | inactive | failed | unknown
|
||
Sub string
|
||
PID int
|
||
Since string
|
||
MemoryMB int
|
||
Restarts int
|
||
Enabled bool
|
||
Installed bool
|
||
}
|
||
|
||
func quikExportServiceStatus(dir string) quikExportServiceState {
|
||
st := quikExportServiceState{Active: "unknown"}
|
||
if fi, err := os.Stat(filepath.Join(dir, "quikexport")); err == nil && !fi.IsDir() {
|
||
st.Installed = true
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
out, err := exec.CommandContext(ctx, "systemctl", "show", "quikexport", "-p", "ActiveState,SubState,MainPID,ActiveEnterTimestamp,MemoryCurrent,NRestarts,UnitFileState", "--no-pager").Output()
|
||
if err != nil {
|
||
return st
|
||
}
|
||
for _, ln := range strings.Split(string(out), "\n") {
|
||
k, v, _ := strings.Cut(strings.TrimSpace(ln), "=")
|
||
switch k {
|
||
case "ActiveState":
|
||
st.Active = v
|
||
case "SubState":
|
||
st.Sub = v
|
||
case "MainPID":
|
||
st.PID, _ = strconv.Atoi(v)
|
||
case "ActiveEnterTimestamp":
|
||
st.Since = v
|
||
case "MemoryCurrent":
|
||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||
st.MemoryMB = int(n >> 20)
|
||
}
|
||
case "NRestarts":
|
||
st.Restarts, _ = strconv.Atoi(v)
|
||
case "UnitFileState":
|
||
st.Enabled = v == "enabled"
|
||
}
|
||
}
|
||
return st
|
||
}
|
||
|
||
type quikExportFileInfo struct {
|
||
Name string
|
||
SizeMB float64
|
||
ModTime time.Time
|
||
Exists bool
|
||
}
|
||
|
||
func quikExportFile(path string) quikExportFileInfo {
|
||
fi, err := os.Stat(path)
|
||
if err != nil {
|
||
return quikExportFileInfo{Name: filepath.Base(path)}
|
||
}
|
||
return quikExportFileInfo{Name: filepath.Base(path), SizeMB: float64(fi.Size()) / (1 << 20), ModTime: fi.ModTime(), Exists: true}
|
||
}
|
||
|
||
func dirSizeMB(path string) float64 {
|
||
var total int64
|
||
_ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||
if err == nil && !info.IsDir() {
|
||
total += info.Size()
|
||
}
|
||
return nil
|
||
})
|
||
return float64(total) / (1 << 20)
|
||
}
|
||
|
||
type diskInfo struct {
|
||
Path string
|
||
TotalGB, FreeGB float64
|
||
UsedPct int
|
||
OK bool
|
||
}
|
||
|
||
func diskUsage(path string) diskInfo {
|
||
var st syscall.Statfs_t
|
||
if err := syscall.Statfs(path, &st); err != nil {
|
||
return diskInfo{Path: path}
|
||
}
|
||
total := float64(st.Blocks) * float64(st.Bsize)
|
||
free := float64(st.Bavail) * float64(st.Bsize)
|
||
d := diskInfo{Path: path, TotalGB: total / (1 << 30), FreeGB: free / (1 << 30), OK: true}
|
||
if total > 0 {
|
||
d.UsedPct = int((total - free) / total * 100)
|
||
}
|
||
return d
|
||
}
|
||
|
||
// quikExportDBStats — объём базы и скорость роста (логином только для чтения).
|
||
type quikExportDBStats struct {
|
||
OK bool
|
||
Error string
|
||
DBSizeMB float64
|
||
Tables []struct {
|
||
Name, Size string
|
||
Rows int64
|
||
}
|
||
NonEmpty map[string]bool // таблицы экспортёра, в которых есть данные
|
||
HistoryRows int64
|
||
HistoryFrom time.Time
|
||
Rows24h int64
|
||
GrowthMBDay float64
|
||
LastTick time.Time
|
||
TickAgeSec int
|
||
Clients int64
|
||
ExporterUser string
|
||
}
|
||
|
||
var (
|
||
qxPoolMu sync.Mutex
|
||
qxPool *pgxpool.Pool
|
||
qxPoolDSN string
|
||
)
|
||
|
||
func quikExportPool(s QuikExportSettings) (*pgxpool.Pool, error) {
|
||
dsn := s.RODSN(true)
|
||
qxPoolMu.Lock()
|
||
defer qxPoolMu.Unlock()
|
||
if qxPool != nil && qxPoolDSN == dsn {
|
||
return qxPool, nil
|
||
}
|
||
if qxPool != nil {
|
||
qxPool.Close()
|
||
qxPool = nil
|
||
}
|
||
cfg, err := pgxpool.ParseConfig(dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cfg.MaxConns = 3
|
||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
defer cancel()
|
||
p, err := pgxpool.NewWithConfig(ctx, cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
qxPool, qxPoolDSN = p, dsn
|
||
return p, nil
|
||
}
|
||
|
||
func quikExportStats(s QuikExportSettings) quikExportDBStats {
|
||
st := quikExportDBStats{ExporterUser: s.DBUser}
|
||
if s.ROPassword == "" {
|
||
st.Error = "не задан пароль логина только для чтения"
|
||
return st
|
||
}
|
||
p, err := quikExportPool(s)
|
||
if err != nil {
|
||
st.Error = err.Error()
|
||
return st
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
var size int64
|
||
if err := p.QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size); err != nil {
|
||
st.Error = err.Error()
|
||
return st
|
||
}
|
||
st.DBSizeMB = float64(size) / (1 << 20)
|
||
rows, err := p.Query(ctx, `SELECT s.schemaname||'.'||s.relname, pg_size_pretty(pg_total_relation_size(s.relid)), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid=s.relid),0) FROM pg_stat_user_tables s ORDER BY pg_total_relation_size(s.relid) DESC LIMIT 8`)
|
||
if err == nil {
|
||
for rows.Next() {
|
||
var t struct {
|
||
Name, Size string
|
||
Rows int64
|
||
}
|
||
if rows.Scan(&t.Name, &t.Size, &t.Rows) == nil {
|
||
st.Tables = append(st.Tables, t)
|
||
}
|
||
}
|
||
rows.Close()
|
||
}
|
||
var histBytes int64
|
||
var from *time.Time
|
||
if err := p.QueryRow(ctx, `SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), pg_total_relation_size('npr.history'), (SELECT min(ts) FROM npr.history), (SELECT count(*) FROM npr.history WHERE ts >= now() - interval '24 hours')`).Scan(&st.HistoryRows, &histBytes, &from, &st.Rows24h); err == nil {
|
||
if from != nil {
|
||
st.HistoryFrom = *from
|
||
}
|
||
if st.HistoryRows > 0 {
|
||
st.GrowthMBDay = float64(histBytes) / float64(st.HistoryRows) * float64(st.Rows24h) / (1 << 20)
|
||
}
|
||
}
|
||
var tick *time.Time
|
||
if err := p.QueryRow(ctx, "SELECT last_tick FROM npr.heartbeat WHERE id=1").Scan(&tick); err == nil && tick != nil {
|
||
st.LastTick = *tick
|
||
st.TickAgeSec = int(time.Since(*tick).Seconds())
|
||
}
|
||
st.NonEmpty = map[string]bool{} // по каким таблицам экспортёр реально приносит данные
|
||
if rows, err := p.Query(ctx, `SELECT relname FROM pg_stat_user_tables WHERE schemaname='public' AND (n_live_tup > 0 OR reltuples > 0)`); err == nil {
|
||
for rows.Next() {
|
||
var n string
|
||
if rows.Scan(&n) == nil {
|
||
st.NonEmpty[n] = true
|
||
}
|
||
}
|
||
rows.Close()
|
||
}
|
||
_ = p.QueryRow(ctx, `SELECT count(DISTINCT "ClientCode") FROM "Portfolio"`).Scan(&st.Clients)
|
||
st.OK = true
|
||
return st
|
||
}
|
||
|
||
// QuikExportView — данные раздела настроек.
|
||
type QuikExportView struct {
|
||
S QuikExportSettings
|
||
Licensed bool
|
||
Service quikExportServiceState
|
||
Files []quikExportFileInfo // export.ini, info.ini, ключи
|
||
Logs []quikExportFileInfo
|
||
LogsMB float64
|
||
QAPIMB float64
|
||
DiskDB diskInfo
|
||
DiskExp diskInfo
|
||
DB quikExportDBStats
|
||
LogTail []string
|
||
ErrTail []string
|
||
Tables []quikExportTable
|
||
Groups []string
|
||
Presets []struct{ Key, Title, Desc string }
|
||
Packages []quikExportPackage
|
||
PkgOn map[string]bool // пакет выбран в настройках
|
||
PkgLive map[string]bool // пакет уже выгружается экспортёром (по export.ini)
|
||
Enabled map[string]bool
|
||
RODSN string
|
||
RODSNFull string
|
||
ShowRO bool
|
||
ServerIP string
|
||
Warnings []string
|
||
|
||
ModuleVersion string // bj-quikexport
|
||
ExporterVersion string // ARQA quikexport (\"\" — не определена)
|
||
}
|
||
|
||
var (
|
||
qxViewMu sync.Mutex
|
||
qxViewAt time.Time
|
||
qxViewLast QuikExportView
|
||
)
|
||
|
||
func quikExportView(st *Store, licensed, showRO bool) QuikExportView {
|
||
qxViewMu.Lock()
|
||
defer qxViewMu.Unlock()
|
||
if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" && qxViewLast.Licensed == licensed {
|
||
v := qxViewLast
|
||
v.ShowRO = showRO
|
||
return v
|
||
}
|
||
s := st.Get()
|
||
v := QuikExportView{S: s, Licensed: licensed, Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO}
|
||
v.Enabled = map[string]bool{}
|
||
for _, t := range s.Tables {
|
||
v.Enabled[t] = true
|
||
}
|
||
v.Packages = quikExportPackages
|
||
v.PkgOn = map[string]bool{}
|
||
for _, k := range s.Packages {
|
||
v.PkgOn[k] = true
|
||
}
|
||
v.PkgLive = map[string]bool{} // по каким пакетам данные уже есть в базе
|
||
seen := map[string]bool{}
|
||
for _, t := range quikExportTables {
|
||
if !seen[t.Group] {
|
||
seen[t.Group] = true
|
||
v.Groups = append(v.Groups, t.Group)
|
||
}
|
||
}
|
||
v.Service = quikExportServiceStatus(s.Dir)
|
||
for _, f := range []string{"export.ini", "info.ini", "pubring.txk", "secring.txk"} {
|
||
v.Files = append(v.Files, quikExportFile(filepath.Join(s.Dir, f)))
|
||
}
|
||
for _, f := range []string{"export.log", "export.err", "quikapi.log"} {
|
||
fi := quikExportFile(filepath.Join(s.Dir, "logs", f))
|
||
v.Logs = append(v.Logs, fi)
|
||
v.LogsMB += fi.SizeMB
|
||
}
|
||
v.QAPIMB = dirSizeMB(filepath.Join(s.Dir, "QAPI"))
|
||
v.DiskExp = diskUsage(s.Dir)
|
||
if s.DBMode != "external" {
|
||
v.DiskDB = diskUsage("/var/lib/postgresql")
|
||
}
|
||
v.DB = quikExportStats(s)
|
||
if len(v.DB.NonEmpty) > 0 { // пакет отмечаем «выгружается», когда его таблицы в базе не пусты
|
||
var have []string
|
||
for t := range v.DB.NonEmpty {
|
||
have = append(have, t)
|
||
}
|
||
for _, k := range quikExportPackagesOf(have) {
|
||
v.PkgLive[k] = true
|
||
}
|
||
}
|
||
v.LogTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.log"), 12)
|
||
v.ErrTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.err"), 6)
|
||
v.RODSN = s.RODSN(false)
|
||
v.RODSNFull = s.RODSN(true)
|
||
v.ServerIP = quikExportServerIP()
|
||
if v.Service.Installed && v.Service.Active != "active" && s.Enabled {
|
||
v.Warnings = append(v.Warnings, "Служба экспортёра не запущена.")
|
||
}
|
||
if !v.Service.Installed {
|
||
v.Warnings = append(v.Warnings, "Экспортёр ARQA не установлен в "+s.Dir+".")
|
||
}
|
||
if v.DB.OK && v.DB.TickAgeSec > 3*s.PortfolioPeriod+60 && v.Service.Active == "active" {
|
||
v.Warnings = append(v.Warnings, fmt.Sprintf("Данные не обновлялись %d с при периоде %d с — проверьте лог экспортёра (подключение к QUIK, «Вы уже работаете в системе»).", v.DB.TickAgeSec, s.PortfolioPeriod))
|
||
}
|
||
if v.LogsMB > 1024 {
|
||
v.Warnings = append(v.Warnings, fmt.Sprintf("Логи экспортёра занимают %.0f МБ — ротация настраивается при «Применить настройки».", v.LogsMB))
|
||
}
|
||
if v.DiskDB.OK && v.DiskDB.FreeGB < 10 {
|
||
v.Warnings = append(v.Warnings, fmt.Sprintf("На диске базы данных осталось %.1f ГБ.", v.DiskDB.FreeGB))
|
||
}
|
||
if s.Enabled && !v.Licensed {
|
||
v.Warnings = append(v.Warnings, "Лицензия не содержит модуль «Экспорт биржевой информации» — модуль выключен.")
|
||
}
|
||
qxViewAt, qxViewLast = time.Now(), v
|
||
return v
|
||
}
|
||
|
||
func quikExportInvalidate() {
|
||
qxViewMu.Lock()
|
||
qxViewAt = time.Time{}
|
||
qxViewMu.Unlock()
|
||
}
|
||
|
||
// quikExportTail — последние n строк лога (cp1251).
|
||
func quikExportTail(path string, n int) []string {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer f.Close()
|
||
fi, _ := f.Stat()
|
||
const chunk = 64 << 10
|
||
off := int64(0)
|
||
if fi.Size() > chunk {
|
||
off = fi.Size() - chunk
|
||
}
|
||
b := make([]byte, fi.Size()-off)
|
||
if _, err := f.ReadAt(b, off); err != nil && err != io.EOF {
|
||
return nil
|
||
}
|
||
var lines []string
|
||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||
for sc.Scan() {
|
||
if t := strings.TrimSpace(sc.Text()); t != "" {
|
||
lines = append(lines, cp1251Decode([]byte(t)))
|
||
}
|
||
}
|
||
if off > 0 && len(lines) > 0 {
|
||
lines = lines[1:] // первая строка может быть обрезана
|
||
}
|
||
if len(lines) > n {
|
||
lines = lines[len(lines)-n:]
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func quikExportServerIP() string {
|
||
out, err := exec.Command("hostname", "-I").Output()
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
f := strings.Fields(string(out))
|
||
if len(f) == 0 {
|
||
return ""
|
||
}
|
||
return f[0]
|
||
}
|
||
|
||
// ---------- обработчики ----------
|
||
|
||
func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
s := srv.store.Get()
|
||
f := r.FormValue
|
||
atoi := func(v string, def int) int {
|
||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
|
||
return n
|
||
}
|
||
return def
|
||
}
|
||
switch f("part") {
|
||
case "quik":
|
||
s.QuikHost = strings.TrimSpace(f("quik_host"))
|
||
s.QuikPort = atoi(f("quik_port"), 15100)
|
||
s.QuikUser = strings.TrimSpace(f("quik_user"))
|
||
if p := f("quik_password"); p != "" {
|
||
s.QuikPassword = p
|
||
}
|
||
s.AuthName = strings.TrimSpace(f("auth_name"))
|
||
case "db":
|
||
s.DBMode = f("db_mode")
|
||
if s.DBMode != "external" {
|
||
s.DBMode = "local"
|
||
}
|
||
s.DBHost = strings.TrimSpace(f("db_host"))
|
||
s.DBPort = atoi(f("db_port"), 5432)
|
||
s.DBName = strings.TrimSpace(f("db_name"))
|
||
s.DBUser = strings.TrimSpace(f("db_user"))
|
||
if p := f("db_password"); p != "" {
|
||
s.DBPassword = p
|
||
}
|
||
s.ROUser = strings.TrimSpace(f("ro_user"))
|
||
if p := f("ro_password"); p != "" {
|
||
s.ROPassword = p
|
||
}
|
||
case "volume":
|
||
if f("mode") == "custom" {
|
||
s.Preset = "custom"
|
||
s.Tables = nil
|
||
for _, t := range r.Form["tables"] {
|
||
s.Tables = append(s.Tables, t)
|
||
}
|
||
if len(s.Tables) == 0 {
|
||
s.Tables = []string{"Portfolio"}
|
||
}
|
||
sort.Strings(s.Tables)
|
||
s.Packages = quikExportPackagesOf(s.Tables)
|
||
} else {
|
||
s.Preset = "packages"
|
||
s.Packages = append([]string(nil), r.Form["packages"]...)
|
||
s.Tables = quikExportPackageTables(s.Packages)
|
||
}
|
||
s.PortfolioPeriod = atoi(f("portfolio_period"), 60)
|
||
if s.PortfolioPeriod < 10 {
|
||
s.PortfolioPeriod = 10
|
||
}
|
||
s.ParamPeriod = atoi(f("param_period"), 30)
|
||
s.DebugLevel = atoi(f("debug_level"), 1)
|
||
s.LimitKinds = strings.TrimSpace(f("limit_kinds"))
|
||
case "module":
|
||
s.Enabled = f("enabled") != ""
|
||
}
|
||
s = s.withDefaults()
|
||
if err := srv.store.Save(s); err != nil {
|
||
flash(w, "Экспорт QUIK: ошибка сохранения: "+err.Error())
|
||
return
|
||
}
|
||
quikExportInvalidate()
|
||
flash(w, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».")
|
||
}
|
||
|
||
func (srv *Server) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||
flash(w, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error())
|
||
return
|
||
}
|
||
if err := os.MkdirAll(quikExportKeysDir, 0o700); err != nil {
|
||
flash(w, "Экспорт QUIK: "+err.Error())
|
||
return
|
||
}
|
||
s := srv.store.Get()
|
||
var saved []string
|
||
for _, field := range []string{"pubring", "secring"} {
|
||
file, hdr, err := r.FormFile(field)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
b, err := io.ReadAll(io.LimitReader(file, 1<<20))
|
||
file.Close()
|
||
if err != nil || len(b) == 0 {
|
||
continue
|
||
}
|
||
name := field + ".txk"
|
||
if err := os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600); err != nil {
|
||
flash(w, "Экспорт QUIK: запись ключа: "+err.Error())
|
||
return
|
||
}
|
||
saved = append(saved, fmt.Sprintf("%s (%s, %d байт)", name, hdr.Filename, len(b)))
|
||
}
|
||
if len(saved) == 0 {
|
||
flash(w, "Экспорт QUIK: файлы ключей не выбраны.")
|
||
return
|
||
}
|
||
s.KeysAt = time.Now()
|
||
s.KeysNames = nil
|
||
for _, k := range []string{"pubring.txk", "secring.txk"} {
|
||
if _, err := os.Stat(filepath.Join(quikExportKeysDir, k)); err == nil {
|
||
s.KeysNames = append(s.KeysNames, k)
|
||
}
|
||
}
|
||
_ = srv.store.Save(s)
|
||
quikExportInvalidate()
|
||
flash(w, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».")
|
||
}
|
||
|
||
func (srv *Server) applyQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if !licensed(r) {
|
||
flash(w, "Экспорт QUIK: модуль не входит в лицензию.")
|
||
return
|
||
}
|
||
res, err := quikExportApply(r.Context(), srv.store, true)
|
||
quikExportInvalidate()
|
||
if err != nil {
|
||
flash(w, "Экспорт QUIK: применить не удалось — "+err.Error())
|
||
return
|
||
}
|
||
applied, _ := res["applied"].([]any)
|
||
var names []string
|
||
for _, a := range applied {
|
||
names = append(names, fmt.Sprint(a))
|
||
}
|
||
flash(w, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"]))
|
||
}
|
||
|
||
func (srv *Server) importQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
msg, err := quikExportImport(r.Context(), srv.store)
|
||
quikExportInvalidate()
|
||
if err != nil {
|
||
flash(w, "Экспорт QUIK: импорт не удался — "+err.Error())
|
||
return
|
||
}
|
||
flash(w, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg)
|
||
}
|
||
|
||
func (srv *Server) serviceQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
op := r.FormValue("op")
|
||
switch op {
|
||
case "start", "stop", "restart", "enable":
|
||
default:
|
||
flash(w, "Экспорт QUIK: неизвестная операция.")
|
||
return
|
||
}
|
||
_, err := quikExportRootOp(r.Context(), "service", "", op)
|
||
quikExportInvalidate()
|
||
if err != nil {
|
||
flash(w, "Экспорт QUIK: служба — "+err.Error())
|
||
return
|
||
}
|
||
flash(w, "Экспорт QUIK: операция «"+op+"» выполнена.")
|
||
}
|
||
|
||
func (srv *Server) dbInitQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
s := srv.store.Get()
|
||
if s.DBMode == "external" {
|
||
flash(w, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).")
|
||
return
|
||
}
|
||
if s.DBPassword == "" || s.ROPassword == "" {
|
||
flash(w, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.")
|
||
return
|
||
}
|
||
dir, err := quikExportNewStaging()
|
||
if err != nil {
|
||
flash(w, "Экспорт QUIK: "+err.Error())
|
||
return
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
for _, f := range []string{"exporter_pg.sql", "npr_migration.sql"} {
|
||
b, _ := quikExportFS.ReadFile("web/quikexport/" + f)
|
||
_ = os.WriteFile(filepath.Join(dir, f), b, 0o640)
|
||
}
|
||
dbj, _ := json.Marshal(map[string]string{"db": s.DBName, "user": s.DBUser, "password": s.DBPassword, "ro_user": s.ROUser, "ro_password": s.ROPassword})
|
||
_ = os.WriteFile(filepath.Join(dir, "db.json"), dbj, 0o640)
|
||
res, err := quikExportRootOp(r.Context(), "db-init", dir, "")
|
||
quikExportInvalidate()
|
||
if err != nil {
|
||
flash(w, "Экспорт QUIK: база — "+err.Error())
|
||
return
|
||
}
|
||
flash(w, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"]))
|
||
}
|
||
|
||
// sqlQuikExport — отдаёт SQL для ручного создания базы на внешнем сервере.
|
||
func (srv *Server) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
s := srv.store.Get()
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="quik_export_setup.sql"`)
|
||
fmt.Fprintf(w, "-- База экспорта QUIK для bj. Выполнить на сервере PostgreSQL %s:%d от суперпользователя.\n", s.DBHost, s.DBPort)
|
||
fmt.Fprintf(w, "CREATE ROLE %s LOGIN PASSWORD '<пароль>';\nCREATE ROLE %s LOGIN PASSWORD '<пароль>';\nALTER ROLE %s SET log_min_messages = fatal;\nCREATE DATABASE %s OWNER %s ENCODING 'UTF8' TEMPLATE template0;\n\\c %s %s\n", s.DBUser, s.ROUser, s.DBUser, s.DBName, s.DBUser, s.DBName, s.DBUser)
|
||
for _, f := range []string{"exporter_pg.sql", "npr_migration.sql"} {
|
||
b, _ := quikExportFS.ReadFile("web/quikexport/" + f)
|
||
fmt.Fprintf(w, "\n-- ===== %s =====\n", f)
|
||
w.Write(bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF}))
|
||
}
|
||
fmt.Fprintf(w, "\nGRANT CONNECT ON DATABASE %s TO %s;\nGRANT USAGE ON SCHEMA public, npr TO %s;\nGRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;\nGRANT SELECT ON ALL TABLES IN SCHEMA npr TO %s;\nALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA public GRANT SELECT ON TABLES TO %s;\nALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA npr GRANT SELECT ON TABLES TO %s;\n", s.DBName, s.ROUser, s.ROUser, s.ROUser, s.ROUser, s.DBUser, s.ROUser, s.DBUser, s.ROUser)
|
||
}
|
||
|
||
// previewQuikExport — показать export.ini/info.ini, которые будут установлены (пароли скрыты).
|
||
func (srv *Server) previewQuikExport(w http.ResponseWriter, r *http.Request) {
|
||
s := srv.store.Get()
|
||
exp, err := quikExportRenderExportINI(s)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), 500)
|
||
return
|
||
}
|
||
inf, _ := quikExportRenderInfoINI(s)
|
||
mask := func(t string) string {
|
||
for _, p := range []string{s.DBPassword, s.QuikPassword} {
|
||
if p != "" {
|
||
t = strings.ReplaceAll(t, "="+p, "=•••••")
|
||
}
|
||
}
|
||
return t
|
||
}
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp))
|
||
}
|
||
|
||
// same — одинаковые ли наборы имён таблиц.
|
||
func same(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
m := map[string]bool{}
|
||
for _, v := range a {
|
||
m[v] = true
|
||
}
|
||
for _, v := range b {
|
||
if !m[v] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|