From 567706e0a1c619c1fbac0c769112d192bd771c61 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Wed, 14 Oct 2026 19:10:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA:=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20XML=20=D0=B2=20=D0=BF=D0=BE=D0=B4=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=20=D0=9D=D0=A0=D0=94;=20=D1=84=D0=B8=D0=BA=D1=81=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?FIFO-=D1=81=D0=BE=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) XML-выгрузка справочника (/admin/handbook → XML) теперь в формате НРД (hk:M2MTransferHandbook: SettlementPlaces/Participants, Rus/Eng, Depository/BrokerPlace). Поллер сохраняет подлинный XML от НРД (writeHandbookRawXML → m2m-handbook.xml) и выгрузка отдаёт его как есть (windows-1251, байт в байт). Для установок без своего шлюза — реконструкция того же формата из плоского JSON (reconstructHandbookXML), проверено парсером НРД. 2) findDealForResponse: ответ сервиса МОСТ с конкретным GUID, которого нет среди ИСХОДЯЩИХ сделок (например M2M31 на наше решение по ВХОДЯЩЕМУ переводу), больше НЕ пришивается по эвристике FIFO к чужой исходящей заявке и не отклоняет её ошибочно. FIFO остаётся только для ответа с нулевым/пустым GUID (поведение НРД при M2M14). Co-Authored-By: Claude Opus 4.8 --- internal/lkgateway/handbook_export.go | 184 +++++++++++++++++++------- internal/lkgateway/nsdpoller.go | 1 + internal/lkgateway/service.go | 6 +- 3 files changed, 142 insertions(+), 49 deletions(-) diff --git a/internal/lkgateway/handbook_export.go b/internal/lkgateway/handbook_export.go index cd3b80f..42564fb 100644 --- a/internal/lkgateway/handbook_export.go +++ b/internal/lkgateway/handbook_export.go @@ -1,80 +1,82 @@ package lkgateway // handbook_export.go — выгрузка справочника участников M2M со страницы -// /admin/handbook в файлах JSON и XML. Данные — самый свежий справочник -// (readHandbook): полученный от НРД #M2MTH, копия с сервера лицензий или -// встроенный снимок. Только чтение, доступ — под админ-авторизацией /admin/*. +// /admin/handbook в файлах JSON и XML. +// +// JSON — наш плоский вид (readHandbook: от НРД #M2MTH, копия с сервера лицензий +// или встроенный снимок). +// XML — в ПОДЛИННОМ формате НРД (hk:M2MTransferHandbook). Источник по порядку: +// 1) сырой XML, полученный этим bj от НРД (handbookXMLFile) — отдаём как есть +// (он уже в кодировке windows-1251, с SettlementPlaces/Participants, +// Rus/Eng, Depository/BrokerPlace — байт в байт как прислал НРД); +// 2) если сырого нет (установка без своего шлюза) — реконструкция того же +// формата из плоского JSON (с потерями: Eng — только DisplayName, место — +// по типу записи), UTF-8. +// Доступ — под админ-авторизацией /admin/*. import ( "bytes" "encoding/json" "encoding/xml" "net/http" + "os" + "strings" "time" ) -// hbEntryXML — запись справочника в XML (атрибутами, как плоский JSON). -type hbEntryXML struct { - INN string `xml:"inn,attr,omitempty"` - Code string `xml:"code,attr,omitempty"` - Kind string `xml:"kind,attr,omitempty"` - Name string `xml:"name,attr,omitempty"` - FullName string `xml:"fullName,attr,omitempty"` - DisplayName string `xml:"displayName,attr,omitempty"` - EngName string `xml:"engName,attr,omitempty"` -} +// handbookXMLFile — сырой XML справочника, как его прислал НРД (#M2MTH). Пишется +// поллером при получении (writeHandbookRawXML). +const handbookXMLFile = "/var/lib/bj/.bj/m2m-handbook.xml" -// hbExportXML — корень XML-выгрузки справочника. -type hbExportXML struct { - XMLName xml.Name `xml:"M2MHandbook"` - At string `xml:"at,attr,omitempty"` - Participants []hbEntryXML `xml:"Participants>Participant"` - Places []hbEntryXML `xml:"SettlementPlaces>Place"` -} - -func hbEntryToXML(e HandbookEntry) hbEntryXML { - return hbEntryXML{INN: e.INN, Code: e.Code, Kind: e.Kind, Name: e.Name, - FullName: e.FullName, DisplayName: e.DisplayName, EngName: e.EngName} +// writeHandbookRawXML сохраняет подлинный XML справочника от НРД (для выгрузки в +// исходном формате). Перезаписывает только при изменении содержимого. +func writeHandbookRawXML(raw []byte) { + if len(raw) == 0 { + return + } + if old, err := os.ReadFile(handbookXMLFile); err == nil && bytes.Equal(old, raw) { + return + } + _ = os.WriteFile(handbookXMLFile, raw, 0o600) } // exportHandbook отдаёт сохранённый справочник участников M2M файлом JSON или XML. func (a *admin) exportHandbook(w http.ResponseWriter, format string) { - raw, err := readHandbook() - if err != nil || len(raw) == 0 { - http.Error(w, "Справочник ещё не загружен — запросите его у НРД.", http.StatusNotFound) - return - } - var hb struct { - At string `json:"at"` - Participants []HandbookEntry `json:"participants"` - Places []HandbookEntry `json:"settlement_places"` - } - if err := json.Unmarshal(raw, &hb); err != nil { - http.Error(w, "Справочник повреждён: "+err.Error(), http.StatusInternalServerError) - return - } day := time.Now().Format("2006-01-02") + if format == "xml" { - doc := hbExportXML{At: hb.At} - for _, p := range hb.Participants { - doc.Participants = append(doc.Participants, hbEntryToXML(p)) + // 1) Подлинный XML от НРД — отдаём как есть (windows-1251, формат НРД). + if raw, err := os.ReadFile(handbookXMLFile); err == nil && len(raw) > 0 { + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Disposition", `attachment; filename="m2m-handbook-`+day+`.xml"`) + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(raw) + return } - for _, p := range hb.Places { - doc.Places = append(doc.Places, hbEntryToXML(p)) + // 2) Сырого нет — реконструируем формат НРД из плоского JSON. + raw, err := readHandbook() + if err != nil || len(raw) == 0 { + http.Error(w, "Справочник ещё не загружен — запросите его у НРД.", http.StatusNotFound) + return } - out, err := xml.MarshalIndent(doc, "", " ") - if err != nil { - http.Error(w, "Не удалось сформировать XML: "+err.Error(), http.StatusInternalServerError) + var hb flatHandbook + if err := json.Unmarshal(raw, &hb); err != nil { + http.Error(w, "Справочник повреждён: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="m2m-handbook-`+day+`.xml"`) w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write([]byte(xml.Header)) - _, _ = w.Write(out) + _, _ = w.Write(reconstructHandbookXML(hb)) return } + // JSON — отдаём сохранённый справочник как есть, с отступами для читаемости. + raw, err := readHandbook() + if err != nil || len(raw) == 0 { + http.Error(w, "Справочник ещё не загружен — запросите его у НРД.", http.StatusNotFound) + return + } var buf bytes.Buffer if json.Indent(&buf, raw, "", " ") != nil { buf.Reset() @@ -85,3 +87,89 @@ func (a *admin) exportHandbook(w http.ResponseWriter, format string) { w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(buf.Bytes()) } + +// flatHandbook — наш плоский формат хранения (handbookFile). +type flatHandbook struct { + At string `json:"at"` + Participants []HandbookEntry `json:"participants"` + Places []HandbookEntry `json:"settlement_places"` +} + +// reconstructHandbookXML собирает XML в формате НРД (hk:M2MTransferHandbook) из +// плоского JSON — запасной путь, когда подлинного XML от НРД нет. С потерями: +// у участника восстанавливается одно место (Depository или Broker — по типу +// записи), из английских наименований — только DisplayName. +func reconstructHandbookXML(hb flatHandbook) []byte { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + msk := time.FixedZone("МСК", 3*3600) + ts := time.Now().In(msk).Format("2006-01-02T15:04:05") + "(МСК)" // формат метки времени НРД + if hb.At != "" { + if t, err := time.Parse(time.RFC3339, hb.At); err == nil { + ts = t.In(msk).Format("2006-01-02T15:04:05") + "(МСК)" + } + } + b.WriteString(" " + xmlEsc(ts) + "\n") + + b.WriteString(" \n") + for _, p := range hb.Places { + b.WriteString(" \n") + b.WriteString(" " + xmlEsc(p.INN) + "\n") + if p.Name != "" { + b.WriteString(" " + xmlEsc(p.Name) + "\n") + } + if p.DisplayName != "" { + b.WriteString(" " + xmlEsc(p.DisplayName) + "\n") + } + b.WriteString(" \n") + } + b.WriteString(" \n") + + b.WriteString(" \n") + for _, p := range hb.Participants { + b.WriteString(" \n") + b.WriteString(" " + xmlEsc(p.INN) + "\n") + b.WriteString(" \n") + b.WriteString(" \n") + if p.FullName != "" { + b.WriteString(" " + xmlEsc(p.FullName) + "\n") + } + if p.Name != "" { + b.WriteString(" " + xmlEsc(p.Name) + "\n") + } + if p.DisplayName != "" { + b.WriteString(" " + xmlEsc(p.DisplayName) + "\n") + } + b.WriteString(" \n") + if p.EngName != "" { + b.WriteString(" \n") + b.WriteString(" " + xmlEsc(p.EngName) + "\n") + b.WriteString(" \n") + } + b.WriteString(" \n") + switch p.Kind { + case "broker": + b.WriteString(" \n") + b.WriteString(" " + xmlEsc(p.Code) + "\n") + b.WriteString(" \n") + default: // depo и прочее + if p.Code != "" { + b.WriteString(" \n") + b.WriteString(" " + xmlEsc(p.Code) + "\n") + b.WriteString(" \n") + } + } + b.WriteString(" \n") + } + b.WriteString(" \n") + b.WriteString("\n") + return []byte(b.String()) +} + +// xmlEsc — XML-экранирование текстового значения. +func xmlEsc(s string) string { + var b bytes.Buffer + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go index 4c11bc1..b5960c5 100644 --- a/internal/lkgateway/nsdpoller.go +++ b/internal/lkgateway/nsdpoller.go @@ -250,6 +250,7 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error { return nil } nsdProbeReply(time.Now()) + writeHandbookRawXML(unpacked.DocXML) // подлинный XML НРД — для выгрузки со страницы справочника if !saveHandbook(&hb) { log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants)) return nil diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go index ea44354..13df098 100644 --- a/internal/lkgateway/service.go +++ b/internal/lkgateway/service.go @@ -341,8 +341,12 @@ func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransfer if !errors.Is(err, m2mcore.ErrNotFound) { return nil, err } + // GUID конкретный, но среди ИСХОДЯЩИХ сделок его нет — это сервисный ответ по + // чужому/входящему GUID (например M2M31 на наше решение по ВХОДЯЩЕМУ переводу). + // FIFO здесь ошибочно пометил бы не ту исходящую заявку — поэтому не угадываем. + return nil, m2mcore.ErrNotFound } - // Fallback: ответ без идентифицируемого GUID. + // Fallback — ТОЛЬКО для ответа без идентифицируемого GUID (нулевой/пустой: поведение НРД при M2M14). st := m2mcore.StateAwaitingDecision deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200}) if err != nil {