Files
Bridge-and-Join-s/modules/quikexport/quikexport.go
T

1407 lines
55 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}