Files
Bridge-and-Join-s/internal/nsdadapter/igw/client.go
T

474 lines
17 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
// confirmWait — сколько ждать от ИШ итога передачи пакета в НРД (SENT/ERROR). 0 — не ждать.
confirmWait time.Duration
// onDelivery — итог передачи каждого пакета в НРД (SENT | ERROR | NEW — не дождались), для мониторинга.
onDelivery func(packageID, status, code string)
}
// 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 }
}
// WithConfirmWait задаёт ожидание подтверждения передачи пакета в НРД (0 — не ждать).
func WithConfirmWait(d time.Duration) Option {
return func(cl *Client) { cl.confirmWait = d }
}
// WithDeliveryHook — сообщать итог передачи каждого пакета в НРД (для мониторинга связи).
func WithDeliveryHook(f func(packageID, status, code string)) Option {
return func(cl *Client) { cl.onDelivery = f }
}
// NotDeliveredError — ИШ принял пакет, но передать его в НРД не смог (статус ERROR): например,
// контур НРД недоступен (ERRORXML_UPLOAD_FAILED). Пакет в НРД не ушёл — отправку нужно повторить.
type NotDeliveredError struct {
PackageID string
Code string
}
func (e *NotDeliveredError) Error() string {
code := e.Code
if code == "" {
code = "без кода"
}
return fmt.Sprintf("НРД не принял пакет (ИШ, пакет %s: %s) — пакет не доставлен, отправку нужно повторить", e.PackageID, code)
}
// 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,
confirmWait: 20 * 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)
}
id := ""
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
if s := string(v); s != "" {
id = s
break
}
}
if id == "" {
return "", errors.New("igw: пустой id в ответе ИШ")
}
if err := c.waitDelivered(ctx, id); err != nil {
return "", err
}
return id, nil
}
func (c *Client) delivered(id, status, code string) {
if c.onDelivery != nil {
c.onDelivery(id, status, code)
}
}
// waitDelivered — ИШ отвечает id сразу, а в НРД передаёт пакет следом (обычно за доли секунды).
// Ждём итог: SENT — доставлен; ERROR — не доставлен (*NotDeliveredError). Если статус узнать
// не удалось или пакет всё ещё в очереди ИШ — считаем отправленным, как раньше.
func (c *Client) waitDelivered(ctx context.Context, id string) error {
if c.confirmWait <= 0 {
return nil
}
deadline := time.Now().Add(c.confirmWait)
for wait := 300 * time.Millisecond; ; {
st, err := c.GetStatus(ctx, id)
if err != nil {
return nil
}
switch strings.ToUpper(st.Status) {
case StatusSent:
c.delivered(id, StatusSent, "")
return nil
case StatusError:
c.delivered(id, StatusError, st.Error)
return &NotDeliveredError{PackageID: id, Code: st.Error}
case "": // ИШ не сообщает статус — ждать нечего
return nil
}
if time.Now().Add(wait).After(deadline) {
c.delivered(id, StatusNew, "")
return nil
}
select {
case <-ctx.Done():
return nil
case <-time.After(wait):
}
if wait < 2*time.Second {
wait *= 2
}
}
}
// --------------- 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
}
}
}
}