feat(igw): REST-клиент ИШ НРД по DOC/instr-ish-rest-api.pdf + упаковщик ZIP

Полный клиент Интеграционного шлюза НРД в internal/nsdadapter/igw/:

client.go — REST endpoint'ы по свежей спецификации НРД:
- POST /api/package/{channel}/file — отправка ZIP (Type=archive, File=base64)
  возвращает id пакета (поддерживаются варианты id|package_id|ID)
- GET /api/package/status/{id} — статус NEW|SENT|ERROR (с error-полем)
- GET /api/package?channel=&type=M2MTD|M2MER&date=&id=&count=&excludeErrors=
  — список входящих от НРД, с files[] и signs[] (ИШ сам проверяет ЭП и
  выдаёт VALID|INVALID)
- GET /api/package/{id} — скачать ZIP (raw или base64-в-JSON, авто-детект
  по сигнатуре PK\x03\x04)
- Ретраи только на 5xx/сетевые ошибки (4xx — сразу ошибка)
- HTTP-клиент через options, кастомный таймаут, ретраи

pack.go — упаковщик/распаковщик ZIP по разделу 2.3 инструкции:
- PackRequest(req, docName) — M2MTransferRequest→ZIP с config.xml
- PackXML(xml, docName, packageType) — для эталонных сообщений
- UnpackPackage(zip) → {DocXML, WinfXML, Signature, Filenames}
- ParseDecision / ParseResponse через nsdxml.Unmarshal

Покрыто тестами (10/10 PASS):
- send happy path с проверкой формата JSON-body
- retry на 5xx, без ретраев на 4xx
- GetStatus с числовым id
- ListIncoming как массив (новый формат) и как {items:[]} (старый)
- GetPackage raw ZIP + GetPackage с base64-в-JSON
- упаковка/распаковка: 2 файла в ZIP, имена, содержимое config.xml
- распаковка с .sgn и winf.xml

cmd/bj-server/main.go — NSD-poller адаптирован под новый API
(client.ListIncoming(ctx, ListFilter{}) вместо позиционных параметров;
поля Package.ID/Name/Type/State вместо PackageID/PackageType).

Скачана и положена в DOC/ свежая спецификация (798 KB, 15 стр):
DOC/instr-ish-rest-api.pdf — это исходный документ для нашей реализации.

REPORT.md обновлён:
- общая готовность 65% → 70%
- готовность к роботу 80% → 85%
- добавлен раздел про REST-клиент ИШ
- блокер #6 — отсутствие «Руководства по установке ИШ»
This commit is contained in:
fontvielle
2026-05-14 17:10:17 +03:00
parent 5fa6ea6ab1
commit de41aea00c
7 changed files with 649 additions and 97 deletions
+173
View File
@@ -0,0 +1,173 @@
// pack.go — упаковщик/распаковщик ZIP-пакетов для ИШ НРД.
//
// Формат отправляемого пакета (раздел 2.3 инструкции):
// ZIP-архив содержит:
// - <doc>.xml — сам документ (M2MTransferRequest.xml)
// - config.xml — настроечный файл с указанием name и package
//
// Пример config.xml:
// <config>
// <name>doc.xml</name>
// <package>#M2MTR</package>
// </config>
//
// Формат входящего пакета (раздел 2.4 + раздел 2.6):
// ZIP-архив содержит:
// - <doc>.xml — сам документ (M2MTransferDecision.xml или M2MTransferResponse.xml)
// - winf.xml — транзитный конверт ЭДО НРД
// - <doc>.xml.sgn — отсоединённая подпись НРД (опц.)
//
// ИШ сам формирует пакет ЭДО (подписывает, добавляет winf.xml и т.д.).
// Наша задача — собрать ZIP с XML+config.xml и отправить.
package igw
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// config — содержимое config.xml внутри пакета.
type config struct {
XMLName xml.Name `xml:"config"`
Name string `xml:"name"`
Package string `xml:"package"`
}
// PackRequest упаковывает M2MTransferRequest в ZIP-архив для ИШ.
// docFileName — имя XML внутри архива (например "M2MTransferRequest.xml").
// Возвращает байты ZIP, готовые к отправке через POST /api/package/{channel}/file.
func PackRequest(req *m2m.M2MTransferRequest, docFileName string) ([]byte, error) {
if req == nil {
return nil, errors.New("igw: PackRequest: req=nil")
}
if docFileName == "" {
docFileName = "M2MTransferRequest.xml"
}
xmlBytes, err := nsdxml.Marshal(req)
if err != nil {
return nil, fmt.Errorf("igw: marshal M2MTransferRequest: %w", err)
}
return packZIP(xmlBytes, docFileName, "#M2MTR")
}
// PackXML упаковывает произвольный XML в ZIP с config.xml. Тип пакета
// (например "#M2MTR" / "#M2MTD") задаётся явно. Полезно когда XML уже
// собран снаружи (тесты, эталонные сообщения из инструкции).
func PackXML(xmlBytes []byte, docFileName, packageType string) ([]byte, error) {
if len(xmlBytes) == 0 {
return nil, errors.New("igw: PackXML: xmlBytes пустой")
}
if !strings.HasPrefix(packageType, "#") {
return nil, fmt.Errorf("igw: packageType должен начинаться с #, получено %q", packageType)
}
if docFileName == "" {
docFileName = "doc.xml"
}
return packZIP(xmlBytes, docFileName, packageType)
}
func packZIP(xmlBytes []byte, docFileName, packageType string) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// 1. сам документ
fw, err := w.Create(docFileName)
if err != nil {
return nil, fmt.Errorf("igw: create %s in zip: %w", docFileName, err)
}
if _, err := fw.Write(xmlBytes); err != nil {
return nil, err
}
// 2. config.xml
cfg := config{Name: docFileName, Package: packageType}
cfgBytes, err := xml.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, fmt.Errorf("igw: marshal config.xml: %w", err)
}
cfgWriter, err := w.Create("config.xml")
if err != nil {
return nil, fmt.Errorf("igw: create config.xml in zip: %w", err)
}
if _, err := cfgWriter.Write(cfgBytes); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("igw: close zip: %w", err)
}
return buf.Bytes(), nil
}
// UnpackedPackage — содержимое распакованного ZIP'а с входящим пакетом.
type UnpackedPackage struct {
DocXML []byte // первый XML, который не winf.xml и не config.xml
WinfXML []byte // транзитный конверт ЭДО (опц., присутствует у входящих от НРД)
Signature []byte // .sgn файл (отсоединённая подпись), опц.
Filenames []string
}
// UnpackPackage распаковывает ZIP-архив от ИШ и возвращает структурированно
// тело документа + winf.xml + отсоединённую подпись.
func UnpackPackage(zipBytes []byte) (*UnpackedPackage, error) {
r, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return nil, fmt.Errorf("igw: zip reader: %w", err)
}
out := &UnpackedPackage{}
for _, f := range r.File {
out.Filenames = append(out.Filenames, f.Name)
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("igw: open %s in zip: %w", f.Name, err)
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("igw: read %s from zip: %w", f.Name, err)
}
low := strings.ToLower(filepath.Base(f.Name))
switch {
case low == "winf.xml":
out.WinfXML = data
case low == "config.xml":
// config.xml в исходящих, во входящих обычно отсутствует — игнорируем
case strings.HasSuffix(low, ".sgn"):
out.Signature = data
case strings.HasSuffix(low, ".xml") && out.DocXML == nil:
out.DocXML = data
}
}
if out.DocXML == nil {
return nil, fmt.Errorf("igw: в ZIP нет основного .xml документа (файлы: %v)", out.Filenames)
}
return out, nil
}
// ParseDecision разбирает DocXML входящего пакета M2MTD в m2m.M2MTransferDecision.
func ParseDecision(docXML []byte) (*m2m.M2MTransferDecision, error) {
var d m2m.M2MTransferDecision
if err := nsdxml.Unmarshal(docXML, &d); err != nil {
return nil, fmt.Errorf("igw: parse M2MTransferDecision: %w", err)
}
return &d, nil
}
// ParseResponse разбирает DocXML входящего пакета M2MER в m2m.M2MTransferResponse.
func ParseResponse(docXML []byte) (*m2m.M2MTransferResponse, error) {
var r m2m.M2MTransferResponse
if err := nsdxml.Unmarshal(docXML, &r); err != nil {
return nil, fmt.Errorf("igw: parse M2MTransferResponse: %w", err)
}
return &r, nil
}