feat(nsd-adapter): REST-клиент ИШ НРД + маршрутизация типов пакетов

- internal/nsdadapter/igw/client.go: REST-клиент ИШ (SendPackage, GetStatus, ListIncoming) с base64-JSON, ретраями на 5xx, 4xx без ретраев
- internal/nsdadapter/router.go: маршрутизация MessageKind -> PackageType ЭДО (#M2MTR, #M2MTD, #M2MER, SUBBR/SUBER/SUB16, Assets_investment)
- internal/nsdadapter/sender.go: реализация m2mcore.NSDSender (Send/SendDecision) через REST ИШ, сериализация Request/Decision в windows-1251
- internal/nsdadapter/config.go: профили guest/test3/prod × gost/rsa (URL ИШ, канал, контейнер ключа, retry)
- internal/nsdadapter/onyx/onyx.go: скелет резервного канала WS ONYX (ждёт PR-6 crypto-service для подписи)
- cmd/nsd-adapter/main.go: HTTP /healthz + фоновый поллер входящих по типам ЭДО; idle-режим без BJ_NSD_PROFILE

make ci зелёный. Без внешних Go-зависимостей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
fontvielle
2026-05-14 00:55:20 +03:00
parent 9e6e95f431
commit a8cdeeb838
10 changed files with 803 additions and 1 deletions
+209
View File
@@ -0,0 +1,209 @@
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
// Тело пакета передаётся base64 в JSON; ИШ сам подписывает и
// упаковывает в ZIP-пакет ЭДО по правилам НРД.
package igw
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"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
}
// SendPackage отправляет пакет в указанный канал ЭДО. Возвращает
// идентификатор пакета, присвоенный ИШ.
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
if channel == "" {
return "", errors.New("igw: channel пустой")
}
if packageType == "" {
return "", errors.New("igw: packageType пустой")
}
payload := struct {
PackageType string `json:"package_type"`
Body string `json:"body"`
}{
PackageType: packageType,
Body: base64.StdEncoding.EncodeToString(body),
}
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("igw: marshal payload: %w", err)
}
path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel))
resp, err := c.doRetry(ctx, http.MethodPost, path, bytes.NewReader(raw), "application/json")
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
PackageID string `json:"package_id"`
}
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 в ответе ИШ")
}
return out.PackageID, nil
}
// Status — состояние пакета у ИШ.
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"`
}
// 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()
var s Status
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return Status{}, fmt.Errorf("igw: decode Status: %w", err)
}
return s, nil
}
// Package — описание входящего пакета.
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
}
// DecodeBody возвращает декодированное содержимое пакета.
func (p Package) DecodeBody() ([]byte, error) {
if p.Body == "" {
return nil, nil
}
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)
}
if !since.IsZero() {
q.Set("date", since.UTC().Format(time.RFC3339))
}
if packageType != "" {
q.Set("type", packageType)
}
path := "/api/package?" + q.Encode()
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out struct {
Items []Package `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming: %w", err)
}
return out.Items, nil
}
// 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)
}