Files
Bridge-and-Join-s/internal/nsdadapter/igw/client.go
T
zuevav 9737c787f9 feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:03:21 +03:00

391 lines
14 KiB
Go
Raw 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.
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
// Документ-источник: 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 (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client — REST-клиент ИШ НРД.
type Client struct {
baseURL string
httpClient *http.Client
retryMax int
retryWait time.Duration
}
// Option настраивает Client.
type Option func(*Client)
// WithHTTPClient заменяет стандартный http.Client (для тестов и
// фиксации таймаутов).
func WithHTTPClient(c *http.Client) Option {
return func(cl *Client) { cl.httpClient = c }
}
// WithRetry задаёт количество ретраев и базовое ожидание (линейный backoff).
func WithRetry(max int, wait time.Duration) Option {
return func(cl *Client) { cl.retryMax = max; cl.retryWait = wait }
}
// NewClient собирает клиента к ИШ по URL.
func NewClient(baseURL string, opts ...Option) *Client {
c := &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 30 * time.Second},
retryMax: 3,
retryWait: time.Second,
}
for _, o := range opts {
o(c)
}
return c
}
// --------------- 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 пустой")
}
_ = packageType // тип пакета (#M2MTR) кладётся внутрь ZIP/config.xml через pack.go
if len(zipBody) == 0 {
return "", errors.New("igw: zipBody пустой")
}
// ИШ REST API ждёт multipart/form-data: поле File (binary ZIP) +
// Type (enum InFileType: FILE | ARCHIVE). Для ZIP-пакета — ARCHIVE.
// Ответ: {"id": <int64>}. См. Swagger /api/package/{channel}/file.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := mw.WriteField("Type", "ARCHIVE"); err != nil {
return "", fmt.Errorf("igw: multipart Type: %w", err)
}
fw, err := mw.CreateFormFile("File", "package.zip")
if err != nil {
return "", fmt.Errorf("igw: multipart File: %w", err)
}
if _, err := fw.Write(zipBody); err != nil {
return "", fmt.Errorf("igw: write zip в multipart: %w", err)
}
if err := mw.Close(); err != nil {
return "", fmt.Errorf("igw: multipart close: %w", err)
}
path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel))
resp, err := c.doRetry(ctx, http.MethodPost, path, &buf, mw.FormDataContentType())
if err != nil {
return "", err
}
defer resp.Body.Close()
var out sendResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
}
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
if s := string(v); s != "" {
return s, nil
}
}
return "", errors.New("igw: пустой id в ответе ИШ")
}
// --------------- GET /api/package/status/{id} ---------------
// Status — состояние отправленного пакета (раздел 2.5.2 инструкции).
// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка).
type Status struct {
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 == "" {
return Status{}, errors.New("igw: packageID пустой")
}
path := "/api/package/status/" + url.PathEscape(packageID)
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
if err != nil {
return Status{}, err
}
defer resp.Body.Close()
// Дублируем альтернативные имена полей (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
}
// --------------- 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 {
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"`
}
// 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 обязателен")
}
q := url.Values{}
q.Set("channel", filter.Channel)
if !filter.Date.IsZero() {
q.Set("date", filter.Date.UTC().Format("2006-01-02"))
}
if filter.SinceID > 0 {
q.Set("id", strconv.Itoa(filter.SinceID))
}
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, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
// ИШ может вернуть либо массив, либо {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.Unmarshal(body, &wrap); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err)
}
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
var bodyBytes []byte
if body != nil {
b, err := io.ReadAll(body)
if err != nil {
return nil, err
}
bodyBytes = b
}
for attempt := 0; attempt <= c.retryMax; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept", "application/json, */*")
resp, err := c.httpClient.Do(req)
switch {
case err != nil:
lastErr = err
case resp.StatusCode >= 500:
_ = resp.Body.Close()
lastErr = fmt.Errorf("igw: HTTP %d", resp.StatusCode)
case resp.StatusCode >= 400:
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("igw: HTTP %d: %s", resp.StatusCode, string(b))
default:
return resp, nil
}
if attempt < c.retryMax {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.retryWait * time.Duration(attempt+1)):
}
}
}
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
}
}
}
}