aa34878199
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
305 lines
12 KiB
Go
305 lines
12 KiB
Go
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)
|
||
}
|
||
}()
|
||
}
|