Files
Bridge-and-Join-s/internal/lkgateway/depo_snapshot.go
T
fontvielle aa34878199 Снимок DEPO для bj; доменное имя сервера — и в настройках
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 13:10:04 +03:00

305 lines
12 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
// 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)
}
}()
}