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
+223 -53
View File
@@ -1,6 +1,20 @@
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
// Тело пакета передаётся base64 в JSON; ИШ сам подписывает и
// упаковывает в ZIP-пакет ЭДО по правилам НРД.
// Документ-источник: DOC/instr-ish-rest-api.pdf (НРД, 2026).
//
// ИШ — серверное ПО НРД, которое:
// - принимает от нас сырой XML/ZIP M2M-документа;
// - сам подписывает его сертификатом УЦ МБ (ключ настроен в ИШ);
// - формирует пакет ЭДО по Правилам ЭДО НРД;
// - отправляет в НРД через Web-сервис ONYX;
// - принимает входящие пакеты M2MTD/M2MER от НРД;
// - проверяет подпись НРД (поле signs.status = VALID/INVALID);
// - выдаёт всё это клиенту через REST API.
//
// REST-эндпоинты (все по 200/JSON):
// POST /api/package/{channel}/file — отправить ZIP, вернёт id
// GET /api/package/status/{id} — статус: NEW | SENT | ERROR
// GET /api/package?channel=&type=... — список входящих
// GET /api/package/{id} — тело пакета (ZIP в base64)
package igw
import (
@@ -13,6 +27,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -53,21 +68,42 @@ func NewClient(baseURL string, opts ...Option) *Client {
return c
}
// SendPackage отправляет пакет в указанный канал ЭДО. Возвращает
// идентификатор пакета, присвоенный ИШ.
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
// --------------- POST /api/package/{channel}/file ---------------
// sendBody — тело запроса на отправку пакета. По спецификации НРД
// (instr-ish-rest-api.pdf, раздел 2.5.1) поля Type/File.
type sendBody struct {
Type string `json:"Type"`
File string `json:"File"`
}
// sendResponse — ответ ИШ на отправку. Спецификация: 200 + JSON с ID.
// В новом документе НРД сам формат JSON не зафиксирован детально, поэтому
// принимаем три популярные формы: {id:..}, {package_id:..}, {ID:..}.
type sendResponse struct {
ID json.Number `json:"id,omitempty"`
PackageID json.Number `json:"package_id,omitempty"`
IDAlt json.Number `json:"ID,omitempty"`
}
// SendPackage отправляет ZIP-архив (M2MTransferRequest.xml + config.xml)
// в указанный канал ЭДО ИШ. Сигнатура совместима с предыдущей версией —
// packageType остался параметром для backward-compat, но в новом API
// он внутри ZIP'а (в config.xml/<package>), не в HTTP-теле.
//
// Возвращает идентификатор пакета (как строку — может быть числом или
// UUID, зависит от версии ИШ).
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, zipBody []byte) (string, error) {
if channel == "" {
return "", errors.New("igw: channel пустой")
}
if packageType == "" {
return "", errors.New("igw: packageType пустой")
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
if len(zipBody) == 0 {
return "", errors.New("igw: zipBody пустой")
}
payload := struct {
PackageType string `json:"package_type"`
Body string `json:"body"`
}{
PackageType: packageType,
Body: base64.StdEncoding.EncodeToString(body),
payload := sendBody{
Type: "archive",
File: base64.StdEncoding.EncodeToString(zipBody),
}
raw, err := json.Marshal(payload)
if err != nil {
@@ -80,27 +116,36 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, b
}
defer resp.Body.Close()
var out struct {
PackageID string `json:"package_id"`
}
var out sendResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
}
if out.PackageID == "" {
return "", errors.New("igw: пустой package_id в ответе ИШ")
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
if s := string(v); s != "" {
return s, nil
}
}
return out.PackageID, nil
return "", errors.New("igw: пустой id в ответе ИШ")
}
// Status — состояние пакета у ИШ.
// --------------- GET /api/package/status/{id} ---------------
// Status — состояние отправленного пакета (раздел 2.5.2 инструкции).
// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка).
type Status struct {
PackageID string `json:"package_id"`
State string `json:"state"`
UpdatedAt time.Time `json:"updated_at"`
ErrorCode string `json:"error_code,omitempty"`
ErrorText string `json:"error_text,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// Status-константы для удобства.
const (
StatusNew = "NEW"
StatusSent = "SENT"
StatusError = "ERROR"
)
// GetStatus возвращает текущее состояние пакета по идентификатору.
func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) {
if packageID == "" {
@@ -112,41 +157,79 @@ func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error
return Status{}, err
}
defer resp.Body.Close()
var s Status
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
// Дублируем альтернативные имена полей (id|ID, status|state) — на
// случай различий между версиями ИШ.
var raw map[string]json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return Status{}, fmt.Errorf("igw: decode Status: %w", err)
}
s := Status{}
pickStr(raw, []string{"id", "ID", "package_id"}, &s.ID)
pickStr(raw, []string{"name"}, &s.Name)
pickStr(raw, []string{"status", "state"}, &s.Status)
pickStr(raw, []string{"error", "error_text", "error_code"}, &s.Error)
return s, nil
}
// Package — описание входящего пакета.
// --------------- GET /api/package?channel=&type=... ---------------
// ListFilter — параметры фильтрации входящих пакетов (раздел 2.6).
type ListFilter struct {
Channel string // обязательный — код канала ЭДО
Date time.Time // опц., YYYY-MM-DD
SinceID int // опц., скип до этого id
Count int // опц., лимит
Type string // опц., "M2MTD" | "M2MER" (без #)
ExcludeErrors bool // опц., исключать пакеты с ошибкой
}
// Sign — подпись пакета. ИШ сам проверяет и выдаёт результат: VALID/INVALID.
type Sign struct {
Serial string `json:"serial"`
Subject string `json:"subject"`
Description string `json:"description"`
Status string `json:"status"` // "VALID" | "INVALID"
}
// File — файл внутри пакета.
type File struct {
ID int `json:"id"`
Name string `json:"name"`
}
// Package — описание входящего пакета по списку (раздел 2.6).
type Package struct {
PackageID string `json:"package_id"`
PackageType string `json:"package_type"`
Channel string `json:"channel"`
ReceivedAt time.Time `json:"received_at"`
Body string `json:"body,omitempty"` // base64
Channel string `json:"channel"`
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "M2MTD" | "M2MER"
State string `json:"state"` // "RECEIVED" | "ERROR" | "DELETED"
Files []File `json:"files,omitempty"`
Signs []Sign `json:"signs,omitempty"`
}
// DecodeBody возвращает декодированное содержимое пакета.
func (p Package) DecodeBody() ([]byte, error) {
if p.Body == "" {
return nil, nil
// ListIncoming возвращает список входящих пакетов от НРД по фильтрам.
// Если filter.Type не задан — возвращает оба типа M2MTD + M2MER.
func (c *Client) ListIncoming(ctx context.Context, filter ListFilter) ([]Package, error) {
if filter.Channel == "" {
return nil, errors.New("igw: ListFilter.Channel обязателен")
}
return base64.StdEncoding.DecodeString(p.Body)
}
// ListIncoming возвращает список входящих пакетов по фильтрам.
func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]Package, error) {
q := url.Values{}
if channel != "" {
q.Set("channel", channel)
q.Set("channel", filter.Channel)
if !filter.Date.IsZero() {
q.Set("date", filter.Date.UTC().Format("2006-01-02"))
}
if !since.IsZero() {
q.Set("date", since.UTC().Format(time.RFC3339))
if filter.SinceID > 0 {
q.Set("id", strconv.Itoa(filter.SinceID))
}
if packageType != "" {
q.Set("type", packageType)
if filter.Count > 0 {
q.Set("count", strconv.Itoa(filter.Count))
}
if filter.Type != "" {
q.Set("type", filter.Type)
}
if filter.ExcludeErrors {
q.Set("excludeErrors", "true")
}
path := "/api/package?" + q.Encode()
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
@@ -154,15 +237,83 @@ func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Ti
return nil, err
}
defer resp.Body.Close()
var out struct {
// ИШ может вернуть либо массив, либо {items: [...]}. Поддержим оба.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("igw: read ListIncoming: %w", err)
}
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, nil
}
if body[0] == '[' {
var arr []Package
if err := json.Unmarshal(body, &arr); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming (array): %w", err)
}
return arr, nil
}
var wrap struct {
Items []Package `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming: %w", err)
if err := json.Unmarshal(body, &wrap); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err)
}
return out.Items, nil
return wrap.Items, nil
}
// --------------- GET /api/package/{id} ---------------
// GetPackage возвращает содержимое пакета по ID — ZIP-архив с файлами
// документа и отсоединёнными подписями. По спецификации (раздел 2.6.2)
// ИШ отвечает 200 + body = base64-encoded ZIP. Метод декодирует base64
// и возвращает сырые ZIP-байты.
func (c *Client) GetPackage(ctx context.Context, packageID int) ([]byte, error) {
if packageID <= 0 {
return nil, errors.New("igw: packageID должен быть > 0")
}
path := "/api/package/" + strconv.Itoa(packageID)
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("igw: read GetPackage: %w", err)
}
// ИШ возвращает либо чистый ZIP (Content-Type: application/zip),
// либо JSON с base64. Проверим по сигнатуре ZIP (PK\x03\x04).
if len(body) >= 4 && body[0] == 'P' && body[1] == 'K' &&
body[2] == 0x03 && body[3] == 0x04 {
return body, nil
}
// Иначе пробуем base64 (с JSON-обёрткой или без).
stripped := bytes.TrimSpace(body)
if len(stripped) > 1 && stripped[0] == '"' && stripped[len(stripped)-1] == '"' {
stripped = stripped[1 : len(stripped)-1]
}
// JSON-объект {"file":"..."} или {"body":"..."}
if len(stripped) > 0 && stripped[0] == '{' {
var obj map[string]string
if err := json.Unmarshal(stripped, &obj); err == nil {
for _, k := range []string{"file", "File", "body", "Body"} {
if v, ok := obj[k]; ok {
return base64.StdEncoding.DecodeString(v)
}
}
}
}
decoded, err := base64.StdEncoding.DecodeString(string(stripped))
if err == nil && len(decoded) >= 4 && decoded[0] == 'P' && decoded[1] == 'K' {
return decoded, nil
}
return body, nil
}
// --------------- общая HTTP-логика ---------------
// doRetry выполняет HTTP-запрос с ретраями на сетевые ошибки и 5xx.
func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
var lastErr error
@@ -182,7 +333,7 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept", "application/json, */*")
resp, err := c.httpClient.Do(req)
switch {
case err != nil:
@@ -207,3 +358,22 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
}
return nil, fmt.Errorf("igw: исчерпаны ретраи: %w", lastErr)
}
// pickStr заполняет dest первым непустым значением из raw по списку ключей.
func pickStr(raw map[string]json.RawMessage, keys []string, dest *string) {
for _, k := range keys {
if v, ok := raw[k]; ok {
var s string
if err := json.Unmarshal(v, &s); err == nil && s != "" {
*dest = s
return
}
// Может быть числом — превращаем в строку.
var n json.Number
if err := json.Unmarshal(v, &n); err == nil && string(n) != "" {
*dest = string(n)
return
}
}
}
}