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) t = iniClearTables(t, s.Tables) return t, nil } // iniClearTables — [ClearTables]: таблицы-срезы экспортёр очищает в начале сессии. Без этого повторная // загрузка бьётся о первичный ключ (ошибки вставки текут памятью, как было с Portfolio) либо копит // дубликаты. Portfolio не очищаем — на нём история НПР; журналы изменений очищать нельзя по смыслу. func iniClearTables(text string, enabled []string) string { for _, name := range enabled { if name == "Portfolio" || strings.HasSuffix(name, "Journal") { continue } text = iniSet(text, "ClearTables", name, "1") } return text } 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 s.relname FROM pg_stat_user_tables s JOIN pg_class c ON c.oid = s.relid WHERE s.schemaname='public' AND (s.n_live_tup > 0 OR c.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 }