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) } }() }