Снимок 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
This commit is contained in:
fontvielle
2026-10-22 13:10:04 +03:00
parent 8f32f79372
commit aa34878199
4 changed files with 331 additions and 11 deletions
+304
View File
@@ -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)
}
}()
}
+12 -11
View File
@@ -215,17 +215,18 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerOutgoing(mux, adminTpl, outDeps) registerOutgoing(mux, adminTpl, outDeps)
registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям
registerJournal(mux, adminTpl, svc, incomingStore) registerJournal(mux, adminTpl, svc, incomingStore)
registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) registerBackup(mux, rc) // резервное копирование и восстановление (backup.go)
registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go)
registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10) registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10)
registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение) registerDepoSnapshot(mux, rc) // снимок объектов bj в DEPO (история изменений)
registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение)
registerTime(mux, rc) // сервер времени (NTP) registerHTTPS(mux, rc) // HTTPS веб-интерфейса: перенаправление, свой сертификат
registerTimeZone(mux) // часовой пояс сервера registerTime(mux, rc) // сервер времени (NTP)
registerHostname(mux, rc) // доменные имена сервера (сертификат HTTPS) registerTimeZone(mux) // часовой пояс сервера
registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO registerHostname(mux, rc) // доменные имена сервера (сертификат HTTPS)
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerLetters(mux, rc) // готовые письма: НРД, ARQA, администратор DEPO
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
registerHealth(mux) registerHealth(mux)
registerQuikOps(mux, rc, svc) registerQuikOps(mux, rc, svc)
registerNPR(mux, adminTpl, rc) registerNPR(mux, adminTpl, rc)
@@ -932,6 +932,15 @@
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.RedirectOn}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label> <label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.RedirectOn}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
<button type="submit" class="btn" style="margin-top:8px">Сохранить</button> <button type="submit" class="btn" style="margin-top:8px">Сохранить</button>
</form> </form>
<form method="post" action="/admin/setup/hostname" style="margin:12px 0 8px">
<input type="hidden" name="section" value="sec-https">
<label for="set-hostnames" style="display:block;margin-bottom:4px">Доменное имя сервера bj</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input id="set-hostnames" name="hostnames" value="{{range $i, $n := .Settings.HTTPS.Hostnames}}{{if $i}} {{end}}{{$n}}{{end}}" placeholder="например, m2m.corp.example.ru (несколько — через пробел)" style="min-width:320px;flex:1">
<button type="submit" class="btn">Сохранить</button>
</div>
<p class="muted" style="font-size:12.5px;margin:4px 0 0">bj впишет имя в свой сертификат — интерфейс будет открываться по нему без предупреждения о несовпадении имени. DNS-запись заводит администратор сети.</p>
</form>
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary> <details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary>
<form method="post" action="/admin/setup/https/cert" enctype="multipart/form-data" style="display:grid;gap:8px;max-width:520px;margin-top:8px"> <form method="post" action="/admin/setup/https/cert" enctype="multipart/form-data" style="display:grid;gap:8px;max-width:520px;margin-top:8px">
<label>Сертификат (PEM, с цепочкой)<input type="file" name="cert" accept=".pem,.crt,.cer" style="width:100%"></label> <label>Сертификат (PEM, с цепочкой)<input type="file" name="cert" accept=".pem,.crt,.cer" style="width:100%"></label>
@@ -155,6 +155,12 @@
{{end}} {{end}}
</div>{{end}} </div>{{end}}
<div class="card">
<h2>DEPO: что нужно bj</h2>
<p class="muted">Снимок объектов bj в базе DEPO: трансферные таблицы и процедуры FTV_M2M* (даты изменения), зависимости поставки Fancy, права логина bj, счётчики по статусам. Данных клиентов в снимке нет. bj снимает его раз в сутки и хранит историю — по ней видно, что и когда меняли в DEPO.</p>
<a class="btn" href="/admin/status/depo-snapshot" target="_blank" rel="noopener">Снять снимок сейчас (JSON)</a>
</div>
{{if devOn}}{{/* дорожная карта разработки — только в режиме разработки */}} {{if devOn}}{{/* дорожная карта разработки — только в режиме разработки */}}
<div class="card"> <div class="card">
<h2>Что подключается на следующих этапах</h2> <h2>Что подключается на следующих этапах</h2>