Files
fontvielle de41aea00c 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 — отсутствие «Руководства по установке ИШ»
2026-05-14 17:10:17 +03:00

174 lines
6.3 KiB
Go
Raw Permalink 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.
// 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
}