From aa34878199f09c1537d545c4d15b8999c8a3701f Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 22 Oct 2026 13:10:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA=20DEPO=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20bj;=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=E2=80=94=20=D0=B8=20=D0=B2=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit depo_snapshot.go: объекты FTV_M2M*/bj* (тип, даты создания/изменения, хеш текста, если доступен), столбцы и строки таблиц, счётчики по STATE, зависимости поставки Fancy, константы процедур, задания SQL Agent, права логина bj. Данных клиентов нет. Раз в сутки и по кнопке в «Статусе системы» (GET /admin/status/depo-snapshot), история в /var/lib/bj/.bj/depo-snapshots — сравнить установки (28 и 9) и увидеть ручные правки в DEPO. «Сервер: HTTPS» — поле «Доменное имя сервера bj» (было только в мастере). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- internal/lkgateway/depo_snapshot.go | 304 ++++++++++++++++++ internal/lkgateway/server.go | 23 +- .../lkgateway/web/templates/admin_setup.html | 9 + .../lkgateway/web/templates/admin_status.html | 6 + 4 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 internal/lkgateway/depo_snapshot.go diff --git a/internal/lkgateway/depo_snapshot.go b/internal/lkgateway/depo_snapshot.go new file mode 100644 index 0000000..e2af8c1 --- /dev/null +++ b/internal/lkgateway/depo_snapshot.go @@ -0,0 +1,304 @@ +package lkgateway + +// depo_snapshot.go — снимок того, что bj нужно в базе DEPO (Fancy): объекты расширения FTV_M2M* +// (таблицы, процедуры, триггеры — с хешем текста), зависимости поставки Fancy, константы, вшитые в +// процедуры Фонтвьель (@WHOS, места, типы), задание SQL Agent bj2depo, права логина bj и счётчики +// трансферных таблиц по STATE. Данных клиентов в снимке нет. Нужен, чтобы сравнить установки (28 и 9) +// и поймать ручные правки в DEPO (22.10.2026: программист Фонтвьель вносит изменения в DEPO на 9). +// Снимки копятся в depoSnapshotDir; GET /admin/status/depo-snapshot — свежий снимок (JSON). + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +const depoSnapshotDir = "/var/lib/bj/.bj/depo-snapshots" + +// DepoSnapshot — состояние DEPO для bj. +type DepoSnapshot struct { + At time.Time `json:"at"` + Server string `json:"server"` + Database string `json:"database"` + Version string `json:"version"` + Collation string `json:"collation"` + Login string `json:"login"` + Objects []DepoObject `json:"objects"` // FTV_M2M* и bj* + Deps map[string]bool `json:"deps"` // зависимости поставки Fancy: есть/нет + Consts map[string]string `json:"consts,omitempty"` // константы, вшитые в процедуры + Settings []map[string]any `json:"settings,omitempty"` // FTV_M2M_SETTINGS, если есть + Jobs []DepoJob `json:"jobs,omitempty"` // SQL Agent (msdb), если читается + Grants []string `json:"grants,omitempty"` // права на FTV_M2M* + States map[string]string `json:"states,omitempty"` // таблица → «STATE:кол-во …» + Errors []string `json:"errors,omitempty"` // что не удалось прочитать +} + +// DepoObject — объект базы: тип, даты, для модулей — длина и SHA-256 текста, для таблиц — столбцы. +type DepoObject struct { + Name string `json:"name"` + Type string `json:"type"` + Created string `json:"created"` + Modified string `json:"modified"` + Parent string `json:"parent,omitempty"` // для триггера — таблица + DefLen int `json:"def_len,omitempty"` + DefHash string `json:"def_hash,omitempty"` + Columns []string `json:"columns,omitempty"` + Rows int64 `json:"rows,omitempty"` +} + +// DepoJob — задание SQL Agent, связанное с bj. +type DepoJob struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Steps []string `json:"steps"` + Schedule string `json:"schedule,omitempty"` +} + +var depoSnapDeps = []string{"D_CLISER_M2M", "D_CLISER_M2M_DOL", "OD_M2M_ACCS", "OD_ONLINE_M2M_DEPO", "OD_ONLINE_M2M_DEPO_II", + "FU_NEXT_DOC_NUM", "GEN_ID", "OD_PR_INS_WIRS", "OD_DOC_CATS", "OD_STEP_TYPES", "OD_SYS_TABS"} + +// константы в процедурах Фонтвьель (README deploy/depo/fancy-m2m): «@WHOS int = 383118» и т. п. +var depoSnapConstRe = regexp.MustCompile(`(?i)@(WHOS|PLACE\w*|MARKET\w*|ACC\w*TYPE\w*|DOC\w*CAT\w*|CAT\w*)\s+int\s*=\s*(-?\d+)`) + +// depoSnapshotTake — снять снимок (логином bj из настроек «Обмен Fancy»). +func depoSnapshotTake(ctx context.Context, fs FancyMSSQLSettings) (*DepoSnapshot, error) { + if strings.TrimSpace(fs.Server) == "" { + return nil, fmt.Errorf("подключение к DEPO не настроено") + } + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + return nil, err + } + defer db.Close() + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + s := &DepoSnapshot{At: time.Now(), Server: fs.Server, Deps: map[string]bool{}, Consts: map[string]string{}, States: map[string]string{}} + fail := func(what string, err error) { s.Errors = append(s.Errors, what+": "+err.Error()) } + + if err := db.QueryRowContext(ctx, `SELECT DB_NAME(), CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(64)), CAST(DATABASEPROPERTYEX(DB_NAME(),'Collation') AS nvarchar(128)), SUSER_SNAME()`). + Scan(&s.Database, &s.Version, &s.Collation, &s.Login); err != nil { + fail("сервер", err) + } + + rows, err := db.QueryContext(ctx, `SELECT o.name, o.type_desc, CONVERT(varchar(19),o.create_date,120), CONVERT(varchar(19),o.modify_date,120), + ISNULL(OBJECT_NAME(o.parent_object_id),''), ISNULL(m.definition,'') + FROM sys.objects o LEFT JOIN sys.sql_modules m ON m.object_id=o.object_id + WHERE o.is_ms_shipped=0 AND (o.name LIKE 'FTV[_]M2M%' OR o.name LIKE 'bj[_]%' OR o.name LIKE 'BJ[_]%') AND o.type IN ('U','P','TR','V','FN','IF','TF') + ORDER BY o.type_desc, o.name`) + if err != nil { + fail("объекты", err) + } else { + for rows.Next() { + var o DepoObject + var def string + if rows.Scan(&o.Name, &o.Type, &o.Created, &o.Modified, &o.Parent, &def) != nil { + continue + } + if def != "" { + h := sha256.Sum256([]byte(def)) + o.DefLen, o.DefHash = len([]rune(def)), hex.EncodeToString(h[:8]) + for _, m := range depoSnapConstRe.FindAllStringSubmatch(def, -1) { + s.Consts[o.Name+"."+strings.ToUpper(m[1])] = m[2] + } + } + s.Objects = append(s.Objects, o) + } + rows.Close() + } + for i := range s.Objects { + o := &s.Objects[i] + if o.Type != "USER_TABLE" { + continue + } + cr, err := db.QueryContext(ctx, `SELECT c.name, t.name, c.max_length, c.is_nullable FROM sys.columns c JOIN sys.types t ON t.user_type_id=c.user_type_id + WHERE c.object_id=OBJECT_ID(@t) ORDER BY c.column_id`, sql.Named("t", "dbo."+o.Name)) + if err == nil { + for cr.Next() { + var n, t string + var ml int + var null bool + if cr.Scan(&n, &t, &ml, &null) == nil { + col := fmt.Sprintf("%s %s(%d)", n, t, ml) + if !null { + col += " NOT NULL" + } + o.Columns = append(o.Columns, col) + } + } + cr.Close() + } + _ = db.QueryRowContext(ctx, `SELECT SUM(p.rows) FROM sys.partitions p WHERE p.object_id=OBJECT_ID(@t) AND p.index_id IN (0,1)`, sql.Named("t", "dbo."+o.Name)).Scan(&o.Rows) + hasState := false + for _, c := range o.Columns { + if strings.HasPrefix(c, "STATE ") { + hasState = true + } + } + if hasState { + sr, err := db.QueryContext(ctx, `SELECT ISNULL(CAST(STATE AS nvarchar(40)),'NULL'), COUNT(*) FROM dbo.[`+o.Name+`] GROUP BY STATE ORDER BY 1`) + if err == nil { + var parts []string + for sr.Next() { + var st string + var n int64 + if sr.Scan(&st, &n) == nil { + parts = append(parts, fmt.Sprintf("%s:%d", st, n)) + } + } + sr.Close() + s.States[o.Name] = strings.Join(parts, " ") + } + } + } + + for _, d := range depoSnapDeps { + var n int + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sys.objects WHERE name=@n`, sql.Named("n", d)).Scan(&n) + s.Deps[d] = n > 0 + } + + if sr, err := db.QueryContext(ctx, `IF OBJECT_ID('dbo.FTV_M2M_SETTINGS') IS NOT NULL SELECT * FROM dbo.FTV_M2M_SETTINGS`); err == nil { + cols, _ := sr.Columns() + for sr.Next() { + vals := make([]sql.NullString, len(cols)) + ptr := make([]any, len(cols)) + for i := range vals { + ptr[i] = &vals[i] + } + if sr.Scan(ptr...) == nil { + row := map[string]any{} + for i, c := range cols { + if vals[i].Valid { + row[c] = vals[i].String + } + } + s.Settings = append(s.Settings, row) + } + } + sr.Close() + } + + // задания SQL Agent: шаги — из sysjobs/sysjobsteps; расписание — отдельно (sysschedules часто закрыт) + jr, err := db.QueryContext(ctx, `SELECT j.name, j.enabled, ISNULL(st.step_name,''), ISNULL(st.database_name,''), ISNULL(st.command,''), CAST(j.job_id AS nvarchar(40)) + FROM msdb.dbo.sysjobs j LEFT JOIN msdb.dbo.sysjobsteps st ON st.job_id=j.job_id + WHERE j.name LIKE '%bj%' OR st.command LIKE '%FTV[_]M2M%' ORDER BY j.name, st.step_id`) + if err != nil { + fail("задания SQL Agent (msdb)", err) + } else { + byName := map[string]*DepoJob{} + ids := map[string]string{} + var order []string + for jr.Next() { + var name, step, dbn, cmd, id string + var en bool + if jr.Scan(&name, &en, &step, &dbn, &cmd, &id) != nil { + continue + } + j := byName[name] + if j == nil { + j = &DepoJob{Name: name, Enabled: en} + byName[name], ids[name] = j, id + order = append(order, name) + } + if step != "" { + j.Steps = append(j.Steps, fmt.Sprintf("%s [%s]: %s", step, dbn, strings.Join(strings.Fields(cmd), " "))) + } + } + jr.Close() + for _, n := range order { + var sched string + if err := db.QueryRowContext(ctx, `SELECT TOP 1 CONCAT('freq_type=',sc.freq_type,' subday=',sc.freq_subday_type,'/',sc.freq_subday_interval,' enabled=',sc.enabled) + FROM msdb.dbo.sysjobschedules js JOIN msdb.dbo.sysschedules sc ON sc.schedule_id=js.schedule_id WHERE CAST(js.job_id AS nvarchar(40))=@id`, sql.Named("id", ids[n])).Scan(&sched); err == nil { + byName[n].Schedule = sched + } + s.Jobs = append(s.Jobs, *byName[n]) + } + } + noDef := 0 + for _, o := range s.Objects { + if o.Type != "USER_TABLE" && o.DefHash == "" { + noDef++ + } + } + if noDef > 0 { + s.Errors = append(s.Errors, fmt.Sprintf("тексты %d процедур недоступны логину bj (нет VIEW DEFINITION) — изменения видны по дате modified", noDef)) + } + + gr, err := db.QueryContext(ctx, `SELECT pr.name, p.permission_name, p.state_desc, ISNULL(OBJECT_NAME(p.major_id),'(база)') + FROM sys.database_permissions p JOIN sys.database_principals pr ON pr.principal_id=p.grantee_principal_id + WHERE p.major_id=0 OR OBJECT_NAME(p.major_id) LIKE 'FTV[_]M2M%' ORDER BY 1,4,2`) + if err != nil { + fail("права", err) + } else { + for gr.Next() { + var who, perm, st, obj string + if gr.Scan(&who, &perm, &st, &obj) == nil && (obj != "(база)" || strings.Contains(strings.ToLower(who), "bj")) { + s.Grants = append(s.Grants, fmt.Sprintf("%s: %s %s на %s", who, st, perm, obj)) + } + } + gr.Close() + } + if rr, err := db.QueryContext(ctx, `SELECT r.name FROM sys.database_role_members m JOIN sys.database_principals r ON r.principal_id=m.role_principal_id + JOIN sys.database_principals u ON u.principal_id=m.member_principal_id WHERE u.name=USER_NAME()`); err == nil { + for rr.Next() { + var r string + if rr.Scan(&r) == nil { + s.Grants = append(s.Grants, "роль логина bj: "+r) + } + } + rr.Close() + } + sort.Strings(s.Grants) + return s, nil +} + +// depoSnapshotSave — записать снимок в историю (depo-snapshots/ГГГГММДД-ЧЧММСС.json); путь к файлу. +func depoSnapshotSave(s *DepoSnapshot) (string, error) { + if err := os.MkdirAll(depoSnapshotDir, 0o750); err != nil { + return "", err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return "", err + } + p := filepath.Join(depoSnapshotDir, s.At.Format("20060102-150405")+".json") + return p, os.WriteFile(p, b, 0o640) +} + +// registerDepoSnapshot — GET /admin/status/depo-snapshot: снять, сохранить и отдать снимок (JSON). +// Плюс снимок раз в сутки в фоне (первый — через 3 минуты после запуска): история изменений DEPO. +func registerDepoSnapshot(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/admin/status/depo-snapshot", func(w http.ResponseWriter, r *http.Request) { + s, err := depoSnapshotTake(r.Context(), rc.Snapshot().Fancy) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + _, _ = depoSnapshotSave(s) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(s) + }) + go func() { + time.Sleep(3 * time.Minute) + for { + if s, err := depoSnapshotTake(context.Background(), rc.Snapshot().Fancy); err == nil { + _, _ = depoSnapshotSave(s) + } + time.Sleep(24 * time.Hour) + } + }() +} diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 09d6b92..ea4372c 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -215,17 +215,18 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerOutgoing(mux, adminTpl, outDeps) registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям registerJournal(mux, adminTpl, svc, incomingStore) - registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» - registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) - registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) - registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10) - registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение) - registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат - registerTime(mux, rc) // сервер времени (NTP) - registerTimeZone(mux) // часовой пояс сервера - registerHostname(mux, rc) // доменные имена сервера (сертификат HTTPS) - registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO - registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) + registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» + registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) + registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) + registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10) + registerDepoSnapshot(mux, rc) // снимок объектов bj в DEPO (история изменений) + registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение) + registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат + registerTime(mux, rc) // сервер времени (NTP) + registerTimeZone(mux) // часовой пояс сервера + registerHostname(mux, rc) // доменные имена сервера (сертификат HTTPS) + registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO + registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) registerQuikOps(mux, rc, svc) registerNPR(mux, adminTpl, rc) diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 5b85b38..3ffea13 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -932,6 +932,15 @@ +
+ + +
+ + +
+

bj впишет имя в свой сертификат — интерфейс будет открываться по нему без предупреждения о несовпадении имени. DNS-запись заводит администратор сети.

+
Сертификат организации
diff --git a/internal/lkgateway/web/templates/admin_status.html b/internal/lkgateway/web/templates/admin_status.html index ead691d..124f1b9 100644 --- a/internal/lkgateway/web/templates/admin_status.html +++ b/internal/lkgateway/web/templates/admin_status.html @@ -155,6 +155,12 @@ {{end}} {{end}} +
+

DEPO: что нужно bj

+

Снимок объектов bj в базе DEPO: трансферные таблицы и процедуры FTV_M2M* (даты изменения), зависимости поставки Fancy, права логина bj, счётчики по статусам. Данных клиентов в снимке нет. bj снимает его раз в сутки и хранит историю — по ней видно, что и когда меняли в DEPO.

+ Снять снимок сейчас (JSON) +
+ {{if devOn}}{{/* дорожная карта разработки — только в режиме разработки */}}

Что подключается на следующих этапах