Files
Bridge-and-Join-s/internal/lkgateway/incoming_handlers.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

269 lines
11 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
import (
"context"
"database/sql"
"encoding/json"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"net/http"
"net/url"
"path"
"strings"
"time"
)
// registerIncoming вешает маршруты «Входящие переводы» (сценарий 3333):
// список, карточка с выбором по бумагам, отправка решения роботу.
func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
mux.HandleFunc("/admin/incoming", func(w http.ResponseWriter, r *http.Request) {
data := struct {
page
Items []*IncomingTransfer
}{nowPage("Входящие переводы", "incoming"), deps.store.List()}
render(w, a.incoming, data)
})
// JSON-фид для браузерных уведомлений: список входящих, ждущих решения.
// Фронтенд (layout.html) опрашивает его и показывает Notification на новые.
mux.HandleFunc("/admin/incoming/feed", func(w http.ResponseWriter, r *http.Request) {
type feedItem struct {
ID string `json:"id"`
GUID string `json:"guid"`
Sender string `json:"sender"`
Count int `json:"count"`
Received string `json:"received"`
}
var pending []feedItem
for _, it := range deps.store.List() {
if it.Status != "pending" {
continue
}
pending = append(pending, feedItem{
ID: it.ID, GUID: it.GUID, Sender: it.SenderCode,
Count: len(it.Securities), Received: it.ReceivedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05"),
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"count": len(pending), "pending": pending})
})
// Тестовый прогон входящего в DEPO с реальным клиентом (без отправки в НРД).
mux.HandleFunc("/admin/incoming/depo-sim", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := r.FormValue("id")
it, ok := deps.store.Get(id)
if !ok {
http.NotFound(w, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
guid, err := depoSimulateIncoming(ctx, deps.rc, it, r.FormValue("ser"), r.FormValue("num"))
if err != nil {
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+urlQ("Прогон в DEPO: "+err.Error()), http.StatusSeeOther)
return
}
// ждём, создаст ли джоба документ
doc := ""
if db, e := depoEventsDB(deps.rc); e == nil {
for i := 0; i < 8; i++ {
time.Sleep(5 * time.Second)
var d sql.NullString
_ = db.QueryRowContext(ctx, fsql.QIn01, sql.Named("g", guid)).Scan(&d)
if d.Valid && d.String != "" && d.String != "0" {
doc = d.String
break
}
}
db.Close()
}
msg := "Прогон в DEPO записан: новый GUID " + guid + ", клиент по паспорту " + r.FormValue("ser") + " " + r.FormValue("num")
if doc != "" {
msg += ", джоба создала документ DEPO №" + doc
} else {
msg += "; документ DEPO пока не создан (джоба не ответила за 40 с)"
}
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+urlQ(msg), http.StatusSeeOther)
})
mux.HandleFunc("/admin/incoming/", func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/admin/incoming/")
// POST /admin/incoming/{id}/answer
if strings.HasSuffix(p, "/answer") {
id := path.Base(strings.TrimSuffix(p, "/answer"))
handleIncomingAnswer(w, r, deps, id)
return
}
// GET /admin/incoming/{id}/export.zip — выгрузка для регулятора
if strings.HasSuffix(p, "/export.zip") {
id := path.Base(strings.TrimSuffix(p, "/export.zip"))
exportIncoming(w, r, deps, id)
return
}
// GET /admin/incoming/{id}/draft.zip — черновик поручения 16/1 (MF010) для учётной системы
if strings.HasSuffix(p, "/draft.zip") {
id := path.Base(strings.TrimSuffix(p, "/draft.zip"))
downloadIncomingDraft(w, r, deps, id)
return
}
// POST /admin/incoming/{id}/submit-order — подать поручение 16/1 (ORDXM) в НРД
if strings.HasSuffix(p, "/submit-order") {
id := path.Base(strings.TrimSuffix(p, "/submit-order"))
submitDepoOrder(w, r, deps, id)
return
}
// GET /admin/incoming/{id}/order.xml — скачать отправленную форму 16/1
if strings.HasSuffix(p, "/order.xml") {
id := path.Base(strings.TrimSuffix(p, "/order.xml"))
downloadSentOrder(w, r, deps, id)
return
}
// GET /admin/incoming/{id}/request.xml — исходный запрос контрагента
if strings.HasSuffix(p, "/request.xml") {
id := path.Base(strings.TrimSuffix(p, "/request.xml"))
it, ok := deps.store.Get(id)
if !ok || it.RawXML == "" {
http.Error(w, "запрос не найден", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="request-`+shortID(id)+`.xml"`)
_, _ = w.Write(faithfulXMLBytes(it.RawXML))
return
}
// GET /admin/incoming/{id}/decision.xml — наш ответ-решение
if strings.HasSuffix(p, "/decision.xml") {
id := path.Base(strings.TrimSuffix(p, "/decision.xml"))
it, ok := deps.store.Get(id)
if !ok || it.DecisionXML == "" {
http.Error(w, "решение не найдено", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="decision-`+shortID(id)+`.xml"`)
_, _ = w.Write(faithfulXMLBytes(it.DecisionXML))
return
}
// GET /admin/incoming/{id}/ms010.zip — ответ НРД (отчёт MS010) по поручению
if strings.HasSuffix(p, "/ms010.zip") {
id := path.Base(strings.TrimSuffix(p, "/ms010.zip"))
it, ok := deps.store.Get(id)
if !ok {
http.Error(w, "не найден", http.StatusNotFound)
return
}
writeOrderResponseZip(w, it.OrderContrDID, "otvet-nrd-ms010-"+shortID(id))
return
}
// GET /admin/incoming/{id}
id := path.Base(p)
it, ok := deps.store.Get(id)
if !ok {
http.Error(w, "входящий перевод не найден", http.StatusNotFound)
return
}
var orderResps []orderResponse
var orderDone, orderRejected bool
var orderDoneAt string
if it.OrderSubmitted {
orderResps = findOrderResponses(it.OrderContrDID)
for _, rp := range orderResps {
if rp.Done { // отчёт MS010 — операция проведена или завершена отказом
orderDone = true
orderDoneAt = rp.Time
orderRejected = orderRejected || rp.Rejected
}
}
}
rejected, codes := incomingRejectedAll(it)
data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"),
OrderResponses: orderResps, OrderDone: orderDone, OrderRejected: orderRejected, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID),
Quik: globalQuikLinks.ForGUID(it.GUID), QuikOn: quikActive(deps.rc.Snapshot().Quik), Watch: globalOrderWatch.Get(it.GUID), DepoL: globalDepoLinks.Get(it.GUID),
Rejected: rejected, RejectCodes: codes, AutoMode: deps.rc.Snapshot().M2MAuto.Enabled}
// Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке.
data.Depo = runDepoCheck(it)
if data.Depo.Ran {
deps.store.SetDepo(it.ID, data.Depo.Recommend, data.Depo.Summary)
}
render(w, a.incomingDetail, data)
})
}
// handleIncomingAnswer обрабатывает форму решения: по каждой бумаге radio
// confirm/reject (+ код отказа), собирает решение и отправляет роботу.
func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
it, ok := deps.store.Get(id)
if !ok {
http.Error(w, "не найден", http.StatusNotFound)
return
}
var decisions []SecurityDecision
for _, sec := range it.Securities {
// поле decision_<ReferenceID> = "confirm" | "reject"
val := r.FormValue("decision_" + sec.ReferenceID)
d := SecurityDecision{ReferenceID: sec.ReferenceID, Confirm: val == "confirm"}
if !d.Confirm {
d.RejectCode = strings.TrimSpace(r.FormValue("reject_" + sec.ReferenceID))
if d.RejectCode == "" {
d.RejectCode = "M2M05"
}
}
decisions = append(decisions, d)
}
msg := "Решение отправлено контрагенту через НРД"
if devMode(deps.rc) {
msg = "Решение отправлено роботу"
}
if err := answerIncoming(r.Context(), deps, id, decisions); err != nil {
msg = "Ошибка отправки решения: " + err.Error()
}
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
// incomingDetailData — карточка входящего перевода.
type incomingDetailData struct {
page
It *IncomingTransfer
Flash string
OrderResponses []orderResponse
OrderDone bool
OrderRejected bool // MS010 с отказом
OrderDoneAt string
DraftReceived string
Depo depoResult // сверка с DEPO — рекомендация по ответу, ничего не отправляется
Quik []*QuikLink
QuikOn bool
Watch *OrderWatch
DepoL *DepoLink
Rejected bool // решение отправлено и ни одна бумага не принята — перевод завершён отказом
RejectCodes string // коды отказа через запятую
AutoMode bool // автоматический режим M2M (поручение 16/1 подаст bj)
}
// incomingRejectedAll — перевод отвечен и ни одна бумага не принята (поручение 16/1 не нужно).
func incomingRejectedAll(it *IncomingTransfer) (bool, string) {
if it.Status != "answered" || incomingHasConfirmed(it) {
return false, ""
}
var codes []string
seen := map[string]bool{}
for _, s := range it.Securities {
if s.RejectCode != "" && !seen[s.RejectCode] {
seen[s.RejectCode] = true
codes = append(codes, s.RejectCode)
}
}
return true, strings.Join(codes, ", ")
}