Справочник: выгрузка XML в подлинном формате НРД; фикс ошибочного FIFO-сопоставления ответа
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,80 +1,82 @@
|
|||||||
package lkgateway
|
package lkgateway
|
||||||
|
|
||||||
// handbook_export.go — выгрузка справочника участников M2M со страницы
|
// handbook_export.go — выгрузка справочника участников M2M со страницы
|
||||||
// /admin/handbook в файлах JSON и XML. Данные — самый свежий справочник
|
// /admin/handbook в файлах JSON и XML.
|
||||||
// (readHandbook): полученный от НРД #M2MTH, копия с сервера лицензий или
|
//
|
||||||
// встроенный снимок. Только чтение, доступ — под админ-авторизацией /admin/*.
|
// 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hbEntryXML — запись справочника в XML (атрибутами, как плоский JSON).
|
// handbookXMLFile — сырой XML справочника, как его прислал НРД (#M2MTH). Пишется
|
||||||
type hbEntryXML struct {
|
// поллером при получении (writeHandbookRawXML).
|
||||||
INN string `xml:"inn,attr,omitempty"`
|
const handbookXMLFile = "/var/lib/bj/.bj/m2m-handbook.xml"
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// hbExportXML — корень XML-выгрузки справочника.
|
// writeHandbookRawXML сохраняет подлинный XML справочника от НРД (для выгрузки в
|
||||||
type hbExportXML struct {
|
// исходном формате). Перезаписывает только при изменении содержимого.
|
||||||
XMLName xml.Name `xml:"M2MHandbook"`
|
func writeHandbookRawXML(raw []byte) {
|
||||||
At string `xml:"at,attr,omitempty"`
|
if len(raw) == 0 {
|
||||||
Participants []hbEntryXML `xml:"Participants>Participant"`
|
return
|
||||||
Places []hbEntryXML `xml:"SettlementPlaces>Place"`
|
}
|
||||||
}
|
if old, err := os.ReadFile(handbookXMLFile); err == nil && bytes.Equal(old, raw) {
|
||||||
|
return
|
||||||
func hbEntryToXML(e HandbookEntry) hbEntryXML {
|
}
|
||||||
return hbEntryXML{INN: e.INN, Code: e.Code, Kind: e.Kind, Name: e.Name,
|
_ = os.WriteFile(handbookXMLFile, raw, 0o600)
|
||||||
FullName: e.FullName, DisplayName: e.DisplayName, EngName: e.EngName}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportHandbook отдаёт сохранённый справочник участников M2M файлом JSON или XML.
|
// exportHandbook отдаёт сохранённый справочник участников M2M файлом JSON или XML.
|
||||||
func (a *admin) exportHandbook(w http.ResponseWriter, format string) {
|
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")
|
day := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
if format == "xml" {
|
if format == "xml" {
|
||||||
doc := hbExportXML{At: hb.At}
|
// 1) Подлинный XML от НРД — отдаём как есть (windows-1251, формат НРД).
|
||||||
for _, p := range hb.Participants {
|
if raw, err := os.ReadFile(handbookXMLFile); err == nil && len(raw) > 0 {
|
||||||
doc.Participants = append(doc.Participants, hbEntryToXML(p))
|
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 {
|
// 2) Сырого нет — реконструируем формат НРД из плоского JSON.
|
||||||
doc.Places = append(doc.Places, hbEntryToXML(p))
|
raw, err := readHandbook()
|
||||||
|
if err != nil || len(raw) == 0 {
|
||||||
|
http.Error(w, "Справочник ещё не загружен — запросите его у НРД.", http.StatusNotFound)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
out, err := xml.MarshalIndent(doc, "", " ")
|
var hb flatHandbook
|
||||||
if err != nil {
|
if err := json.Unmarshal(raw, &hb); err != nil {
|
||||||
http.Error(w, "Не удалось сформировать XML: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Справочник повреждён: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="m2m-handbook-`+day+`.xml"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="m2m-handbook-`+day+`.xml"`)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write([]byte(xml.Header))
|
_, _ = w.Write(reconstructHandbookXML(hb))
|
||||||
_, _ = w.Write(out)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON — отдаём сохранённый справочник как есть, с отступами для читаемости.
|
// JSON — отдаём сохранённый справочник как есть, с отступами для читаемости.
|
||||||
|
raw, err := readHandbook()
|
||||||
|
if err != nil || len(raw) == 0 {
|
||||||
|
http.Error(w, "Справочник ещё не загружен — запросите его у НРД.", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if json.Indent(&buf, raw, "", " ") != nil {
|
if json.Indent(&buf, raw, "", " ") != nil {
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
@@ -85,3 +87,89 @@ func (a *admin) exportHandbook(w http.ResponseWriter, format string) {
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(buf.Bytes())
|
_, _ = 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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
|
||||||
|
b.WriteString(`<hk:M2MTransferHandbook xmlns:hk="http://nsd.ru/schemas/m2m/handbook" xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` + "\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(" <hk:CreationTimestamp>" + xmlEsc(ts) + "</hk:CreationTimestamp>\n")
|
||||||
|
|
||||||
|
b.WriteString(" <hk:SettlementPlaces>\n")
|
||||||
|
for _, p := range hb.Places {
|
||||||
|
b.WriteString(" <m2m:Place>\n")
|
||||||
|
b.WriteString(" <m2m:INN>" + xmlEsc(p.INN) + "</m2m:INN>\n")
|
||||||
|
if p.Name != "" {
|
||||||
|
b.WriteString(" <m2m:ShortName>" + xmlEsc(p.Name) + "</m2m:ShortName>\n")
|
||||||
|
}
|
||||||
|
if p.DisplayName != "" {
|
||||||
|
b.WriteString(" <m2m:DisplayName>" + xmlEsc(p.DisplayName) + "</m2m:DisplayName>\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" </m2m:Place>\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" </hk:SettlementPlaces>\n")
|
||||||
|
|
||||||
|
b.WriteString(" <hk:Participants>\n")
|
||||||
|
for _, p := range hb.Participants {
|
||||||
|
b.WriteString(" <m2m:Participant>\n")
|
||||||
|
b.WriteString(" <m2m:INN>" + xmlEsc(p.INN) + "</m2m:INN>\n")
|
||||||
|
b.WriteString(" <m2m:Names>\n")
|
||||||
|
b.WriteString(" <m2m:Rus>\n")
|
||||||
|
if p.FullName != "" {
|
||||||
|
b.WriteString(" <m2m:FullName>" + xmlEsc(p.FullName) + "</m2m:FullName>\n")
|
||||||
|
}
|
||||||
|
if p.Name != "" {
|
||||||
|
b.WriteString(" <m2m:ShortName>" + xmlEsc(p.Name) + "</m2m:ShortName>\n")
|
||||||
|
}
|
||||||
|
if p.DisplayName != "" {
|
||||||
|
b.WriteString(" <m2m:DisplayName>" + xmlEsc(p.DisplayName) + "</m2m:DisplayName>\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" </m2m:Rus>\n")
|
||||||
|
if p.EngName != "" {
|
||||||
|
b.WriteString(" <m2m:Eng>\n")
|
||||||
|
b.WriteString(" <m2m:DisplayName>" + xmlEsc(p.EngName) + "</m2m:DisplayName>\n")
|
||||||
|
b.WriteString(" </m2m:Eng>\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" </m2m:Names>\n")
|
||||||
|
switch p.Kind {
|
||||||
|
case "broker":
|
||||||
|
b.WriteString(" <m2m:BrokerPlace>\n")
|
||||||
|
b.WriteString(" <m2m:ParticipantCode>" + xmlEsc(p.Code) + "</m2m:ParticipantCode>\n")
|
||||||
|
b.WriteString(" </m2m:BrokerPlace>\n")
|
||||||
|
default: // depo и прочее
|
||||||
|
if p.Code != "" {
|
||||||
|
b.WriteString(" <m2m:DepositoryPlace>\n")
|
||||||
|
b.WriteString(" <m2m:ParticipantCode>" + xmlEsc(p.Code) + "</m2m:ParticipantCode>\n")
|
||||||
|
b.WriteString(" </m2m:DepositoryPlace>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(" </m2m:Participant>\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" </hk:Participants>\n")
|
||||||
|
b.WriteString("</hk:M2MTransferHandbook>\n")
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlEsc — XML-экранирование текстового значения.
|
||||||
|
func xmlEsc(s string) string {
|
||||||
|
var b bytes.Buffer
|
||||||
|
_ = xml.EscapeText(&b, []byte(s))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
nsdProbeReply(time.Now())
|
nsdProbeReply(time.Now())
|
||||||
|
writeHandbookRawXML(unpacked.DocXML) // подлинный XML НРД — для выгрузки со страницы справочника
|
||||||
if !saveHandbook(&hb) {
|
if !saveHandbook(&hb) {
|
||||||
log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants))
|
log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants))
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -341,8 +341,12 @@ func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransfer
|
|||||||
if !errors.Is(err, m2mcore.ErrNotFound) {
|
if !errors.Is(err, m2mcore.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// GUID конкретный, но среди ИСХОДЯЩИХ сделок его нет — это сервисный ответ по
|
||||||
|
// чужому/входящему GUID (например M2M31 на наше решение по ВХОДЯЩЕМУ переводу).
|
||||||
|
// FIFO здесь ошибочно пометил бы не ту исходящую заявку — поэтому не угадываем.
|
||||||
|
return nil, m2mcore.ErrNotFound
|
||||||
}
|
}
|
||||||
// Fallback: ответ без идентифицируемого GUID.
|
// Fallback — ТОЛЬКО для ответа без идентифицируемого GUID (нулевой/пустой: поведение НРД при M2M14).
|
||||||
st := m2mcore.StateAwaitingDecision
|
st := m2mcore.StateAwaitingDecision
|
||||||
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
|
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user