9737c787f9
Инфраструктура 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>
391 lines
14 KiB
Go
391 lines
14 KiB
Go
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|