package lkgateway import ( "context" "database/sql" "errors" "fmt" "strings" "time" mssql "github.com/microsoft/go-mssqldb" ) // mssql18456States — расшифровка State для ошибки 18456 (Login failed). // Помогает коллегам понять точную причину отказа логина. var mssql18456States = map[uint8]string{ 1: "сервер СКРЫВАЕТ точную причину от внешних клиентов (защита). Реальный State — только в SQL Server ERRORLOG на стороне QUIK. Попросите коллег глянуть ERRORLOG: там будет 8=неверный пароль, 5=нет логина, 38/40=недоступна БД по умолчанию", 2: "неверный ID пользователя (логина нет)", 5: "неверный ID пользователя (логина нет)", 6: "попытка входа SQL-логином, но настроена только Windows-аутентификация", 7: "логин отключён И неверный пароль", 8: "НЕВЕРНЫЙ ПАРОЛЬ", 9: "недопустимый пароль", 11: "вход разрешён, но доступ к серверу запрещён (нет прав)", 12: "вход разрешён, но доступ к серверу запрещён (нет прав)", 18: "требуется смена пароля (пароль истёк)", 38: "не найдена/недоступна база данных, указанная в подключении", 40: "не удалось открыть БД по умолчанию для этого логина", 46: "не найдена БД по умолчанию для логина", } // quikErrorDetail разбирает ошибку подключения и возвращает подробную // диагностику (для копирования и показа коллегам/аутсорсеру). func quikErrorDetail(err error) string { if err == nil { return "" } var b strings.Builder var me mssql.Error if errors.As(err, &me) { fmt.Fprintf(&b, "Ответ сервера MSSQL:\n") fmt.Fprintf(&b, " Сообщение: %s\n", strings.TrimSpace(me.Message)) fmt.Fprintf(&b, " Номер ошибки: %d\n", me.Number) fmt.Fprintf(&b, " State: %d", me.State) if me.Number == 18456 { if s, ok := mssql18456States[me.State]; ok { fmt.Fprintf(&b, " → %s", s) } else { fmt.Fprintf(&b, " → (см. таблицу state для 18456)") } } b.WriteByte('\n') fmt.Fprintf(&b, " Class (severity):%d\n", me.Class) if me.ServerName != "" { fmt.Fprintf(&b, " Сервер: %s\n", me.ServerName) } if me.ProcName != "" { fmt.Fprintf(&b, " Процедура: %s\n", me.ProcName) } if me.LineNo != 0 { fmt.Fprintf(&b, " Строка: %d\n", me.LineNo) } } fmt.Fprintf(&b, "Сырая ошибка драйвера:\n %s", err.Error()) return b.String() } // Коннектор к БД Модуля неторговых поручений QUIK (MSSQL). БД БОЕВАЯ — здесь // только чтение (версия, метаданные, справочники) и, позже, штатные хранимые // процедуры API МНП (роль InstrEu). Прямых INSERT/UPDATE в таблицы нет. // quikDSN строит ADO-строку подключения (пароль как есть, без URL-кодирования — // у BROKER_88 пароль со спецсимволами). db="" → без указания БД. func quikDSN(s QuikMNPSettings) string { port := s.Port if port == 0 { port = 1433 } enc := s.Encrypt if enc == "" { enc = "true" // боевой сервер обычно требует шифрование } parts := []string{ fmt.Sprintf("server=%s", s.Server), fmt.Sprintf("port=%d", port), fmt.Sprintf("user id=%s", s.User), fmt.Sprintf("password=%s", s.Password), fmt.Sprintf("encrypt=%s", enc), "connection timeout=12", "app name=bj-server", } if s.Trust { parts = append(parts, "TrustServerCertificate=true") } if s.Database != "" { parts = append(parts, fmt.Sprintf("database=%s", s.Database)) } return strings.Join(parts, ";") } func quikOpen(s QuikMNPSettings) (*sql.DB, error) { conn, err := sql.Open("sqlserver", quikDSN(s)) if err != nil { return nil, err } conn.SetConnMaxLifetime(5 * time.Minute) conn.SetMaxOpenConns(3) return conn, nil } // QuikInstruction — неторговое поручение M2M из экспортной таблицы Export. // Только чтение: bj забирает поручения на вывод ЦБ (M2M), которые клиент подал // через QUIK/ЛК и по которым бумаги уже заблокированы на позиции клиента. type QuikInstruction struct { ExtID string // внешний ID (наш идентификатор, если поручение заводили мы) InstrID int64 // ID поручения в МНП Number string // регистрационный номер Type int // тип поручения Status int // статус (см. quikStatusName) StatusName string ClientID string // код клиента QUIK AgreeServ string // договор обслуживания (= contract_number, напр. BR200) ISIN string SecCode string ClassCode string Volume float64 // количество (с 21.4.0 для Ввод/Вывод ЦБ — в ШТУКАХ) QtyBlocked int // сколько заблокировано QtyScale int // масштаб (дробные) Account string // торговый счёт DepoAccount string // счёт депо (составной) DepoCP string // счёт депо контрагента (получателя) SubDepoCP string // раздел счёта контрагента RecipINN string // M2MDepoRecipientINN — ИНН депозитария получателя PassExpense bool // PassExpensesInfo — передавать информацию о расходах CloseIIA bool // AgreeCloseIIA — согласие на закрытие ИИС Registered string // дата/время регистрации (ГГГГ-ММ-ДД ЧЧ:ММ:СС) Deny string // причина отказа/снятия (QueryDeny/CommentDeny) } // quikStatusName — человекочитаемый статус поручения МНП. func quikStatusName(v int) string { switch v { case 0: return "Активно" case 1: return "Исполнено" case 2: return "Снято" case 3: return "Отменено" case 4: return "Запрос на отмену" default: return fmt.Sprintf("код %d", v) } } // quikFmtDateTime собирает дату/время из формата МНП (ГГГГММДД + ЧЧММСС). func quikFmtDateTime(d, t int) string { if d == 0 { return "" } s := fmt.Sprintf("%02d.%02d.%04d", d%100, (d/100)%100, d/10000) if t > 0 { s += fmt.Sprintf(" %02d:%02d:%02d", t/10000, (t/100)%100, t%100) } return s } // quikListM2MInstructions читает поручения M2M из Export (ТОЛЬКО SELECT). // onlyActive=true — только ожидающие обработки (статус «Активно»). func quikListM2MInstructions(ctx context.Context, s QuikMNPSettings, limit int, onlyActive bool) ([]QuikInstruction, error) { conn, err := quikOpen(s) if err != nil { return nil, err } defer conn.Close() cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() if limit <= 0 || limit > 500 { limit = 200 } where := "WHERE ISNULL(IsM2MInstruction,0)=1" if onlyActive { where += " AND ISNULL(Status,0)=0" } q := fmt.Sprintf(`SELECT TOP (%d) ISNULL(ExtID,''), ISNULL(InstrID,0), ISNULL(Number,0), ISNULL(Type,0), ISNULL(Status,0), ISNULL(ClientID,''), ISNULL(AgreeServ,''), ISNULL(ISIN,''), ISNULL(SecCode,''), ISNULL(ClassCode,''), ISNULL(Volume,0), ISNULL(QtyBlocked,0), ISNULL(QtyScale,0), ISNULL(Account,''), ISNULL(DepoAccount,''), ISNULL(DepoCP,''), ISNULL(SubDepoCP,''), ISNULL(M2MDepoRecipientINN,''), ISNULL(PassExpensesInfo,0), ISNULL(AgreeCloseIIA,0), ISNULL(RegisterDate,0), ISNULL(RegisterTime,0), ISNULL(QueryDeny,''), ISNULL(CommentDeny,'') FROM Export %s ORDER BY InstrID DESC`, limit, where) rows, err := conn.QueryContext(cctx, q) if err != nil { return nil, err } defer rows.Close() var out []QuikInstruction for rows.Next() { var it QuikInstruction var num float64 var rd, rt int var qd, cd string if err := rows.Scan(&it.ExtID, &it.InstrID, &num, &it.Type, &it.Status, &it.ClientID, &it.AgreeServ, &it.ISIN, &it.SecCode, &it.ClassCode, &it.Volume, &it.QtyBlocked, &it.QtyScale, &it.Account, &it.DepoAccount, &it.DepoCP, &it.SubDepoCP, &it.RecipINN, &it.PassExpense, &it.CloseIIA, &rd, &rt, &qd, &cd); err != nil { return out, err } it.Number = strings.TrimSuffix(fmt.Sprintf("%.0f", num), ".0") it.StatusName = quikStatusName(it.Status) it.Registered = quikFmtDateTime(rd, rt) it.Deny = strings.TrimSpace(qd + " " + cd) out = append(out, it) } return out, rows.Err() } // QuikSecCheck — результат сверки одной бумаги заявки со справочником QUIK. type QuikSecCheck struct { ISIN string Requested float64 Found bool // бумага есть в справочнике QUIK SecCode string // код инструмента QUIK (напр. GAZP) Name string // наименование выпуска по данным QUIK SecType string // тип бумаги GosRegNum string ClassID int LotSize int Note string } // QuikCheckResult — итог сверки заявки с данными QUIK (ТОЛЬКО ЧТЕНИЕ). // Проверяет наличие клиента и бумаг в справочниках QUIK. Остаток бумаг здесь // не проверяется — он проверяется сервером QUIK при регистрации поручения. type QuikCheckResult struct { Contract string ClientFound bool ClientID string // код клиента в QUIK (из Contracts) ClientName string // ФИО по данным QUIK (из ClientInfo) DepoAccount string // счёт депо клиента в QUIK Securities []QuikSecCheck AllSecsFound bool Summary string } // quikVerifyRequest сверяет заявку (клиент + бумаги) со справочниками QUIK. // Только SELECT: Contracts, ClientInfo, DepoClientAccounts, QuikSecurities. func quikVerifyRequest(ctx context.Context, s QuikMNPSettings, contract string, secs []QuikSecCheck) (QuikCheckResult, error) { res := QuikCheckResult{Contract: contract, AllSecsFound: true} conn, err := quikOpen(s) if err != nil { return res, err } defer conn.Close() cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() // 1. Клиент по номеру договора. err = conn.QueryRowContext(cctx, "SELECT TOP 1 ClientID FROM Contracts WHERE Number=@p1", contract).Scan(&res.ClientID) if err == nil && res.ClientID != "" { res.ClientFound = true _ = conn.QueryRowContext(cctx, "SELECT TOP 1 ISNULL(FullName,'') FROM ClientInfo WHERE ClientCode=@p1", res.ClientID).Scan(&res.ClientName) _ = conn.QueryRowContext(cctx, "SELECT TOP 1 ISNULL(AccountNumber,'') FROM DepoClientAccounts WHERE ClientID=@p1", res.ClientID).Scan(&res.DepoAccount) } // 2. Бумаги по ISIN. for i := range secs { sc := &secs[i] var classID, lot int var code, name, typ, gos string e := conn.QueryRowContext(cctx, `SELECT TOP 1 ISNULL(SecCode,''), ISNULL(DepoName,''), ISNULL(SecTypeDesc,''), ISNULL(GosRegNum,''), ISNULL(ClassId,0), ISNULL(LotSize,0) FROM QuikSecurities WHERE ISIN=@p1 ORDER BY LotSize`, sc.ISIN). Scan(&code, &name, &typ, &gos, &classID, &lot) if e != nil { sc.Found = false sc.Note = "нет в справочнике QUIK" res.AllSecsFound = false } else { sc.Found = true sc.SecCode, sc.Name, sc.SecType, sc.GosRegNum = code, name, typ, gos sc.ClassID, sc.LotSize = classID, lot } } res.Securities = secs switch { case !res.ClientFound && !res.AllSecsFound: res.Summary = "клиент не найден в QUIK, часть бумаг отсутствует в справочнике" case !res.ClientFound: res.Summary = "бумаги в справочнике QUIK есть, но клиент по договору не найден" case !res.AllSecsFound: res.Summary = "клиент найден, но часть бумаг отсутствует в справочнике QUIK" default: res.Summary = "сверка пройдена: клиент и все бумаги есть в QUIK" } return res, nil } // QuikProbeResult — результат тест-подключения к БД МНП QUIK (только чтение). type QuikProbeResult struct { OK bool Version string // @@VERSION (первая строка) DBName string // DB_NAME() Login string // SUSER_SNAME() M2MReady bool // найдены ли ключевые M2M-объекты МНП Objects map[string]bool Note string } // quikProbe — тест подключения к БД МНП QUIK. ТОЛЬКО ЧТЕНИЕ: версия, текущая БД, // логин, наличие ключевых объектов МНП/M2M (Export, M2MDepoRecipients, Contracts, // класс INSTROUTM2M, хранимые процедуры). Никаких вызовов процедур и записи. func quikProbe(ctx context.Context, s QuikMNPSettings) (QuikProbeResult, error) { var r QuikProbeResult r.Objects = map[string]bool{} conn, err := quikOpen(s) if err != nil { return r, err } defer conn.Close() cctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() if err := conn.PingContext(cctx); err != nil { return r, err } // Версия, текущая БД, логин. if err := conn.QueryRowContext(cctx, "SELECT @@VERSION, DB_NAME(), SUSER_SNAME()").Scan(&r.Version, &r.DBName, &r.Login); err != nil { return r, err } if i := strings.IndexAny(r.Version, "\r\n"); i > 0 { r.Version = strings.TrimSpace(r.Version[:i]) } r.OK = true // Наличие ключевых таблиц МНП/M2M (read-only метаданные). tables := []string{"Export", "Export2", "M2MDepoRecipients", "Contracts", "Instructions", "QuikSecurities"} for _, t := range tables { var n int _ = conn.QueryRowContext(cctx, "SELECT COUNT(*) FROM sys.tables WHERE name=@p1", t).Scan(&n) r.Objects["table:"+t] = n > 0 } // Класс INSTROUTM2M в справочнике классов (по нему регистрируется Вывод ЦБ М2М). var cls int _ = conn.QueryRowContext(cctx, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('IClasses','Classes')").Scan(&cls) // Ключевые хранимые процедуры API. procs := []string{"sp_NewInstrSec", "sp_SignInstrSec", "cancel_instruction"} for _, p := range procs { var n int _ = conn.QueryRowContext(cctx, "SELECT COUNT(*) FROM sys.procedures WHERE name=@p1", p).Scan(&n) r.Objects["proc:"+p] = n > 0 } r.M2MReady = r.Objects["table:Export"] && r.Objects["table:Contracts"] && r.Objects["proc:sp_NewInstrSec"] if r.M2MReady { r.Note = "БД похожа на МНП с поддержкой M2M — ключевые объекты найдены." } else { r.Note = "Подключение есть, но не все ключевые объекты МНП/M2M видны под этим логином (проверьте права/имя БД)." } return r, nil }