4cbefa41d5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
474 lines
17 KiB
Go
474 lines
17 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
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|