Files
Bridge-and-Join-s/internal/lkgateway/depoqueue.go
T
zuevav 7ce4c2c79f Защита рецептов: SQL DEPO/SPECTRE/QUIK вынесен в пакет fsql под garble
Реализация варианта B из аудита защиты знаний. Прямой garble всего bj-server
ломает html/template (он обращается к полям структур по имени, garble их
переименовывает). Решение — селективная обфускация: GOGARBLE обфусцирует ТОЛЬКО
пакет с секретами, шаблоны и обработчики не затрагиваются.

95 SQL-запросов к DEPO/SPECTRE/QUIK и трансферным таблицам FTV_M2M вынесены из
lkgateway в отдельный пакет internal/lkgateway/fsql как переменные (именно var,
не const: const компилятор вставил бы обратно в необфусцируемый пакет, и
шифрование бы не сработало — проверено). Сами обработчики, типы и параметры
sql.Named остались на месте; пакет держит только строки запросов.

build-release.sh собирает bj-server с `GOGARBLE=.../fsql garble -literals`
(нужен отдельный SDK go1.26 — модуль-кэшный тулчейн garble не подходит); если
garble/go1.26 не найдены — обычная сборка с предупреждением.

Проверено на реальном garble-бинаре (запускается, /admin и страницы на
структурах работают): рецепт чтения чужих систем из бинаря исчез —
OD_RESTS 13→0, OD_WIRING 5→0, OD_TURNS 1→0, OD_BALANCES 3→0, D_B_CONTRACTS 1→0,
OD_CLIENTS_PORTS 10→0, DepoLimits/MoneyLimits →0. Остаток FTV_M2M — упоминания
имён таблиц в операторских embed-шаблонах и логах, не сам рецепт.

Стенд собран обычной сборкой (без garble) — трассировки остаются читаемыми.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 20:29:47 +03:00

315 lines
9.7 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 (Fancy)» — /admin/fancy-queue. Показывает реальный
// обмен через трансферные таблицы FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS:
// какие переводы bj положил в DEPO, что ответил job bj2depo (DEPO_ORDER_CREATED /
// DEPO_ERROR), в каком состоянии документ в депозитарии и какие события ещё
// не обработаны. Заменила прежнюю «очередь на проверку крана».
import (
"context"
"database/sql"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type depoQueueLine struct {
ISIN, SecCode string
Amount string
State string
Comment string
}
type depoQueueEvent struct {
Ref, State, Type, Codes, Comment string
At time.Time
}
type depoQueueRow struct {
GUID string
State string
Direction string
Deponent string
DeponentDoc string
CP string
CreatedAt time.Time
Doc string
DocState string
DocAt time.Time
Comment string
Lines []depoQueueLine
Events []depoQueueEvent
Pending int // события со STATE=TODO (job ещё не обработал)
Errors []string
LastEvent string
LastAt time.Time
Link *DepoLink
}
func (r depoQueueRow) DirectionRu() string {
if strings.EqualFold(r.Direction, "OUT") {
return "↗ списание (мы отдаём)"
}
return "↘ зачисление (мы принимаем)"
}
// StageRu — человеческое состояние перевода по совокупности признаков.
func (r depoQueueRow) StageRu() (cls, text string) {
up := strings.ToUpper(r.State)
switch {
case len(r.Errors) > 0 || up == "ERROR":
return "rejected", "ошибка DEPO"
case strings.Contains(strings.ToLower(r.DocState), "отмен"):
return "rejected", "документ отменён в DEPO"
case strings.Contains(strings.ToLower(r.DocState), "конец"):
return "confirmed", "документ проведён"
case r.Doc != "":
return "sent", "документ создан: " + r.DocState
case r.Pending > 0:
return "awaiting_decision", "ждёт job bj2depo"
case up == "SUCCESS" || up == "DONE":
return "confirmed", "обработан"
}
if up == "" {
return "", "—"
}
return "", up
}
type depoQueueSummary struct {
Total, Pending, WithDoc, Errors int
Handbook int
HandbookAt time.Time
HasHandbook bool
LastEventAt time.Time
}
func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQueueRow, depoQueueSummary, error) {
var sum depoQueueSummary
db, err := depoEventsDB(rc)
if err != nil {
return nil, sum, err
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if !depoTableExists(ctx, db, "FTV_M2M") {
return nil, sum, errNoDepoTables
}
docCol := "NULL"
if depoHasColumn(ctx, db, "FTV_M2M", "DOC") {
docCol = "CAST(m.DOC AS varchar(32))"
}
timeCol := "NULL"
for _, c := range []string{"TimeNew", "TIME_NEW", "CREATED_AT"} {
if depoHasColumn(ctx, db, "FTV_M2M", c) {
timeCol = "m." + c
break
}
}
rows, err := db.QueryContext(ctx, fsql.Que01+timeCol+`, ISNULL(`+docCol+fsql.Que02+docCol+fsql.Que03+docCol+`)
FROM dbo.FTV_M2M m ORDER BY `+strings.Replace(timeCol, "NULL", "m.GUID", 1)+` DESC`, sql.Named("n", limit))
if err != nil {
return nil, sum, err
}
var out []depoQueueRow
idx := map[string]int{}
for rows.Next() {
var r depoQueueRow
var created, docAt sql.NullTime
if err := rows.Scan(&r.GUID, &r.State, &r.Direction, &r.Deponent, &r.DeponentDoc, &r.CP, &created, &r.Doc, &r.Comment, &r.DocState, &docAt); err != nil {
rows.Close()
return nil, sum, err
}
if created.Valid {
r.CreatedAt = created.Time
}
if docAt.Valid {
r.DocAt = docAt.Time
}
r.GUID = strings.ToUpper(r.GUID)
r.Link = globalDepoLinks.Get(r.GUID)
idx[r.GUID] = len(out)
out = append(out, r)
}
rows.Close()
if len(out) == 0 {
return out, sum, nil
}
// строки
if depoTableExists(ctx, db, "FTV_M2M_LINES") {
lr, err := db.QueryContext(ctx, fsql.Que04+strings.Replace(strings.TrimPrefix(timeCol, "m."), "NULL", "GUID", 1)+` DESC)`, sql.Named("n", limit))
if err == nil {
for lr.Next() {
var g string
var l depoQueueLine
if lr.Scan(&g, &l.ISIN, &l.SecCode, &l.Amount, &l.State, &l.Comment) == nil {
if i, ok := idx[strings.ToUpper(g)]; ok {
l.Amount = strings.TrimRight(strings.TrimRight(l.Amount, "0"), ".")
out[i].Lines = append(out[i].Lines, l)
if strings.EqualFold(l.State, "ERROR") && l.Comment != "" {
out[i].Errors = append(out[i].Errors, l.Comment)
}
}
}
}
lr.Close()
}
}
// события
if depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
er, err := db.QueryContext(ctx, fsql.Que05+strings.Replace(strings.TrimPrefix(timeCol, "m."), "NULL", "GUID", 1)+` DESC) ORDER BY e.EVENT_DATE`, sql.Named("n", limit))
if err == nil {
for er.Next() {
var g string
var e depoQueueEvent
var at sql.NullTime
if er.Scan(&g, &e.Ref, &e.State, &e.Type, &at, &e.Codes, &e.Comment) == nil {
if at.Valid {
e.At = at.Time
}
if i, ok := idx[strings.ToUpper(g)]; ok {
out[i].Events = append(out[i].Events, e)
if strings.EqualFold(e.State, "TODO") {
out[i].Pending++
}
if e.Type == "DEPO_ERROR" && e.Comment != "" {
out[i].Errors = append(out[i].Errors, e.Comment)
}
if e.At.After(out[i].LastAt) {
out[i].LastAt, out[i].LastEvent = e.At, e.Type
}
if e.At.After(sum.LastEventAt) {
sum.LastEventAt = e.At
}
}
}
}
er.Close()
}
}
if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
sum.HasHandbook = true
_ = db.QueryRowContext(ctx, fsql.QQu01).Scan(&sum.Handbook)
if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") {
var t sql.NullTime
if db.QueryRowContext(ctx, fsql.QQu02).Scan(&t) == nil && t.Valid {
sum.HandbookAt = t.Time
}
}
}
for i := range out {
sum.Total++
if out[i].Pending > 0 {
sum.Pending++
}
if out[i].Doc != "" {
sum.WithDoc++
}
if len(out[i].Errors) > 0 {
sum.Errors++
}
}
sort.SliceStable(out, func(i, j int) bool {
ai, aj := out[i].CreatedAt, out[j].CreatedAt
if ai.IsZero() {
ai = out[i].LastAt
}
if aj.IsZero() {
aj = out[j].LastAt
}
return ai.After(aj)
})
return out, sum, nil
}
var errNoDepoTables = errNoDepo("в базе DEPO нет таблицы FTV_M2M — обмен ещё не настроен программистом Fancy")
type errNoDepo string
func (e errNoDepo) Error() string { return string(e) }
// fancyQueue — GET /admin/fancy-queue: обмен с DEPO через FTV_M2M*.
func (h *setupHandlers) fancyQueue(w http.ResponseWriter, r *http.Request) {
s := h.rc.Snapshot().Fancy
var rows []depoQueueRow
var sum depoQueueSummary
var loadErr string
if s.Server == "" {
loadErr = "Подключение к базе DEPO (Fancy) не настроено — Настройка → Обмен Fancy"
} else if rs, sm, err := depoQueueLoad(r.Context(), h.rc, 100); err != nil {
loadErr = "не удалось прочитать трансферные таблицы: " + err.Error()
} else {
rows, sum = rs, sm
}
// ошибки обработчика и пользователь для EXECUTE AS — видны сразу на странице
var fails []depoJobFailure
runAsOK, runAsMsg := true, ""
if s.Server != "" && loadErr == "" {
if db, err := depoEventsDB(h.rc); err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
fails, _ = depoJobFailures(ctx, db, 7, 50)
runAsOK, runAsMsg = depoRunAsCheck(ctx, db, s)
cancel()
db.Close()
}
}
data := struct {
page
Rows []depoQueueRow
Sum depoQueueSummary
Error string
Flash string
DB string
Fancy FancyMSSQLSettings
JobMode string
Failures []depoJobFailure
RunAs string
RunAsOK bool
RunAsMsg string
}{nowPage("Обмен с DEPO", "fancy"), rows, sum, loadErr, r.URL.Query().Get("flash"), s.DepoDB(), s, depoJobModeRu(s.DepoJobMode), fails, depoJobRunAs(s), runAsOK, runAsMsg}
render(w, h.tpl.a.fancyQueue, data)
}
// fancyQueueRequeue — POST /admin/fancy-queue/requeue: вернуть записи с ошибкой обработчика в очередь.
func (h *setupHandlers) fancyQueueRequeue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
msg := ""
db, err := depoEventsDB(h.rc)
if err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
var o, e int64
o, e, err = depoRequeueFailures(ctx, db, 7)
cancel()
db.Close()
if err == nil {
msg = fmt.Sprintf("Возвращено в очередь обработчика: поручений %d, событий %d. Обновите страницу через 10–20 секунд.", o, e)
log.Printf("depo-events: записи с ошибкой возвращены в очередь: поручений %d, событий %d", o, e)
}
}
if err != nil {
msg = "Не удалось вернуть записи в очередь: " + quikErrorDetail(err)
}
http.Redirect(w, r, "/admin/fancy-queue?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
func depoJobModeRu(m string) string {
switch m {
case "bj":
return "процедуру вызывает bj"
case "sql-auto":
return "SQL Agent (создан из bj)"
case "sql-manual":
return "SQL Agent (настроен вручную)"
}
return "не выбран"
}