Files
Bridge-and-Join-s/internal/lkgateway/depo_snapshot.go
T
fontvielle 50e420f4a0 DEPO на Firebird в мастере: подготовка базы одним действием, как для MSSQL
Мастер, шаг «Учёт DEPO», и «Обмен с DEPO» в настройках: СУБД «Firebird» (Fancy на Firebird 2.5).
- Подключение: драйвер nakagami/firebirdsql, путь к файлу базы или псевдоним, WIN1251. Запросы bj к DEPO
  (T-SQL) подключение переводит само (fancyfb.go): механически (dbo., TOP, ISNULL, GETDATE, LEN, N, exec)
  вне строк и комментариев, точные тексты — fsql/fb.go (каталоги, LIKE [_], COUNT(*) OVER, SELECT без FROM,
  PATINDEX); @имена — позиционные ?, GUID — в верхнем регистре. Системные запросы из кода — в fsql/depocatalog.go.
- «Подготовить» логином SYSDBA (depo_setup_fb.go): проверка базы и UDF Fancy, логин SYSDBA_BJ (Fancy даёт права
  на документы логинам SYSDBA%; для Firebird — обычный пользователь), оператор-робот Fancy, кодировщик M2M,
  привязки FTV_M2M_SETTINGS по признакам Fancy, таблицы, процедуры, права; обработку запускает bj.
  «Получить скрипт» — то же для isql в WIN1251; письмо администратору, установка процедур, плашка обновления.
- Скрипты Firebird встроены (depoprocs/firebird, тест сверяет с deploy/); количества — DOUBLE PRECISION: из
  диалекта 3 в базу диалекта 1 процедуру с NUMERIC(18,7) не создать.
- Проверено на копии базы Fancy (отдельный экземпляр bj): мастер с нуля — все проверки, BJ_PING; тестовый режим
  DEPO — К1 Бубликов, зачисление и списание до исполнения без ошибок; очередь, справочник НРД.
  Тест FBLive готовит на базе все запросы bj к DEPO: 94 из 101, оставшиеся 7 — ЛК и ручной ввод (следующий этап).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-27 18:55:02 +03:00

308 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 не настроено")
}
if fs.IsFirebird() { // снимок читает каталог SQL Server (sys.objects, sys.sql_modules)
return nil, fmt.Errorf("снимок объектов DEPO снимается только с Microsoft SQL Server")
}
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)
}
}()
}