Files
Bridge-and-Join-s/internal/lkgateway/quikexport.go
T
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

1241 lines
45 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 lkgateway
// Модуль «Экспорт биржевой информации» — управление ARQA quikexport из bj:
// ключи QUIK, логин/пароль, где держим базу (наш PostgreSQL или внешний),
// какой объём данных выгружаем (набор таблиц), состояние службы, объём базы,
// свободное место и доступ для внешних систем. Файлы экспортёра принадлежат
// arqasrv, служба — systemd, поэтому запись и перезапуск идут через root-воркер
// (bj-keymedia-worker → /usr/local/sbin/bj-quikexport-admin), а чтение состояния
// (systemctl show, размеры логов, статистика базы) bj делает сам.
//
// Лицензионный признак модуля: 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"` // npr | depo | trading | full | custom
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 len(s.Tables) == 0 {
s.Tables = quikExportPresetTables(s.Preset)
}
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)
}
func (r *RuntimeConfig) UpdateQuikExport(s QuikExportSettings) error {
r.mu.Lock()
r.data.QuikExport = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// ---------- каталог таблиц экспорта ----------
// 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", "Внутренние переводы бумаг", "Прочее", "низкий", ""},
}
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 runKeymediaOp(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, rc *RuntimeConfig, restart bool) (map[string]any, error) {
s := rc.Snapshot().QuikExport.withDefaults()
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()
_ = rc.UpdateQuikExport(s)
return res, nil
}
// quikExportImport — читает текущие export.ini/info.ini экспортёра и заполняет настройки.
func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) {
res, err := quikExportRootOp(ctx, "dump", "", "")
if err != nil {
return "", err
}
s := rc.Snapshot().QuikExport.withDefaults()
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.Preset = quikExportDetectPreset(tabs)
}
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 := rc.UpdateQuikExport(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 }
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())
}
_ = 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 }
Enabled map[string]bool
RODSN string
RODSNFull string
ShowRO bool
ServerIP string
Warnings []string
}
var (
qxViewMu sync.Mutex
qxViewAt time.Time
qxViewLast QuikExportView
)
func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
qxViewMu.Lock()
defer qxViewMu.Unlock()
if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" {
v := qxViewLast
v.ShowRO = showRO
return v
}
s := rc.Snapshot().QuikExport.withDefaults()
v := QuikExportView{S: s, Licensed: moduleAllowed(rc, quikExportFeature), Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO}
v.Enabled = map[string]bool{}
for _, t := range s.Tables {
v.Enabled[t] = true
}
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)
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 (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot().QuikExport.withDefaults()
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":
s.Preset = f("preset")
if 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"}
}
} else {
s.Tables = quikExportPresetTables(s.Preset)
}
sort.Strings(s.Tables)
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 := h.rc.UpdateQuikExport(s); err != nil {
setupFlash(w, r, "Экспорт QUIK: ошибка сохранения: "+err.Error())
return
}
quikExportInvalidate()
setupFlash(w, r, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».")
}
func (h *setupHandlers) 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 {
setupFlash(w, r, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error())
return
}
if err := os.MkdirAll(quikExportKeysDir, 0o700); err != nil {
setupFlash(w, r, "Экспорт QUIK: "+err.Error())
return
}
s := h.rc.Snapshot().QuikExport.withDefaults()
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 {
setupFlash(w, r, "Экспорт QUIK: запись ключа: "+err.Error())
return
}
saved = append(saved, fmt.Sprintf("%s (%s, %d байт)", name, hdr.Filename, len(b)))
}
if len(saved) == 0 {
setupFlash(w, r, "Экспорт 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)
}
}
_ = h.rc.UpdateQuikExport(s)
quikExportInvalidate()
setupFlash(w, r, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».")
}
func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if !moduleAllowed(h.rc, quikExportFeature) {
setupFlash(w, r, "Экспорт QUIK: модуль не входит в лицензию.")
return
}
res, err := quikExportApply(r.Context(), h.rc, true)
quikExportInvalidate()
if err != nil {
setupFlash(w, r, "Экспорт QUIK: применить не удалось — "+err.Error())
return
}
applied, _ := res["applied"].([]any)
var names []string
for _, a := range applied {
names = append(names, fmt.Sprint(a))
}
setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"]))
}
func (h *setupHandlers) importQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
msg, err := quikExportImport(r.Context(), h.rc)
quikExportInvalidate()
if err != nil {
setupFlash(w, r, "Экспорт QUIK: импорт не удался — "+err.Error())
return
}
setupFlash(w, r, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg)
}
func (h *setupHandlers) 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:
setupFlash(w, r, "Экспорт QUIK: неизвестная операция.")
return
}
_, err := quikExportRootOp(r.Context(), "service", "", op)
quikExportInvalidate()
if err != nil {
setupFlash(w, r, "Экспорт QUIK: служба — "+err.Error())
return
}
setupFlash(w, r, "Экспорт QUIK: операция «"+op+"» выполнена.")
}
func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot().QuikExport.withDefaults()
if s.DBMode == "external" {
setupFlash(w, r, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).")
return
}
if s.DBPassword == "" || s.ROPassword == "" {
setupFlash(w, r, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.")
return
}
dir, err := quikExportNewStaging()
if err != nil {
setupFlash(w, r, "Экспорт 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 {
setupFlash(w, r, "Экспорт QUIK: база — "+err.Error())
return
}
setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"]))
}
// sqlQuikExport — отдаёт SQL для ручного создания базы на внешнем сервере.
func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
s := h.rc.Snapshot().QuikExport.withDefaults()
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 (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request) {
s := h.rc.Snapshot().QuikExport.withDefaults()
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))
}
func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) {
mux.HandleFunc("/admin/setup/quikexport", h.saveQuikExport)
mux.HandleFunc("/admin/setup/quikexport/keys", h.uploadQuikExportKeys)
mux.HandleFunc("/admin/setup/quikexport/apply", h.applyQuikExport)
mux.HandleFunc("/admin/setup/quikexport/import", h.importQuikExport)
mux.HandleFunc("/admin/setup/quikexport/service", h.serviceQuikExport)
mux.HandleFunc("/admin/setup/quikexport/dbinit", h.dbInitQuikExport)
mux.HandleFunc("/admin/setup/quikexport/sql", h.sqlQuikExport)
mux.HandleFunc("/admin/setup/quikexport/preview", h.previewQuikExport)
}