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:
@@ -15,7 +15,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
|
|||||||
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
|
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
|
||||||
| PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
|
| PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
|
||||||
| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
|
| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
|
||||||
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 |
|
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 |
|
||||||
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
|
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
|
||||||
|
|
||||||
## Как запустить задачу
|
## Как запустить задачу
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# internal/nsdadapter — транспорт к НРД
|
||||||
|
|
||||||
|
Реализует `m2mcore.NSDSender`. Основной канал — Интеграционный шлюз
|
||||||
|
(ИШ) НРД через REST API; ИШ сам подписывает и упаковывает пакеты ЭДО,
|
||||||
|
нам XMLDSig не требуется. Резерв — WS ONYX (заглушка до PR-6).
|
||||||
|
|
||||||
|
## Состав
|
||||||
|
|
||||||
|
- `igw/client.go` — REST-клиент ИШ: `SendPackage`, `GetStatus`,
|
||||||
|
`ListIncoming`. Body передаётся base64 в JSON, ретраи на 5xx и
|
||||||
|
сетевые ошибки, 4xx не ретраится.
|
||||||
|
- `router.go` — маршрутизация доменного типа сообщения в `PackageType`
|
||||||
|
ЭДО (`#M2MTR`, `#M2MTD`, `#M2MER`, `SUBBR/SUBER/SUB16`,
|
||||||
|
`Assets_investment`).
|
||||||
|
- `sender.go` — реализация `m2mcore.NSDSender`: сериализует Request в
|
||||||
|
windows-1251 через `nsdxml.Marshal` и шлёт в ИШ. Ответы НРД
|
||||||
|
приходят асинхронно через входящий поллер.
|
||||||
|
- `config.go` — профили `guest-gost`, `guest-rsa`, `test3-gost`,
|
||||||
|
`test3-rsa`, `prod-gost`, `prod-rsa` (URL ИШ, канал, контейнер
|
||||||
|
ключа, таймауты, retry).
|
||||||
|
- `onyx/onyx.go` — скелет SOAP-клиента WS ONYX. Реальная реализация —
|
||||||
|
после PR-6 (подпись через crypto-service).
|
||||||
|
|
||||||
|
## cmd/nsd-adapter
|
||||||
|
|
||||||
|
`cmd/nsd-adapter/main.go` — сервис с HTTP `/healthz` и фоновым
|
||||||
|
поллером входящих пакетов (`ListIncoming` по типам ЭДО из
|
||||||
|
`IncomingPackageKinds`). Параметры:
|
||||||
|
|
||||||
|
- `BJ_HTTP_ADDR` — адрес HTTP (по умолчанию `:8082`).
|
||||||
|
- `BJ_NSD_PROFILE` — имя профиля. Если не задан — сервис стартует в
|
||||||
|
режиме idle (только healthz, опрос отключён). Полезно для CI и
|
||||||
|
смоук-тестов без реального ИШ.
|
||||||
|
- `BJ_NSD_POLL_INTERVAL` — частота опроса, например `15s`.
|
||||||
|
|
||||||
|
## Принципы логирования
|
||||||
|
|
||||||
|
- Логируем: метод, путь, HTTP-статус, package_id, длительность.
|
||||||
|
- **Не логируем** тело пакета — там могут быть ПДн.
|
||||||
|
- Маскировка ПДн в любых журналах — на стороне `m2m-core` при
|
||||||
|
логировании content до отправки в адаптер.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Package nsdadapter реализует транспорт к НРД:
|
||||||
|
// REST-клиент Интеграционного шлюза (основной канал) и резервный
|
||||||
|
// WS ONYX. На M1 — REST-клиент + маршрутизация типов пакетов;
|
||||||
|
// onyx остаётся скелетом до получения КриптоПро JCP (PR-6).
|
||||||
|
package nsdadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile — преднастроенный набор параметров подключения к ИШ НРД.
|
||||||
|
type Profile struct {
|
||||||
|
Name string
|
||||||
|
IGWBaseURL string
|
||||||
|
Channel string
|
||||||
|
KeyContainer string
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
RetryMax int
|
||||||
|
RetryBackoff time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Известные профили: пары среда (guest/test3/prod) x алгоритм (gost/rsa).
|
||||||
|
var profiles = map[string]Profile{
|
||||||
|
"guest-gost": {
|
||||||
|
Name: "guest-gost", IGWBaseURL: "http://localhost:8080", Channel: "GUEST",
|
||||||
|
KeyContainer: "GUEST_GOST_CONTAINER",
|
||||||
|
RequestTimeout: 30 * time.Second, RetryMax: 3, RetryBackoff: time.Second,
|
||||||
|
},
|
||||||
|
"guest-rsa": {
|
||||||
|
Name: "guest-rsa", IGWBaseURL: "http://localhost:8080", Channel: "GUEST",
|
||||||
|
KeyContainer: "GUEST_RSA_CONTAINER",
|
||||||
|
RequestTimeout: 30 * time.Second, RetryMax: 3, RetryBackoff: time.Second,
|
||||||
|
},
|
||||||
|
"test3-gost": {
|
||||||
|
Name: "test3-gost", IGWBaseURL: "http://localhost:8080", Channel: "TEST3",
|
||||||
|
KeyContainer: "TEST3_GOST_CONTAINER",
|
||||||
|
RequestTimeout: 30 * time.Second, RetryMax: 3, RetryBackoff: time.Second,
|
||||||
|
},
|
||||||
|
"test3-rsa": {
|
||||||
|
Name: "test3-rsa", IGWBaseURL: "http://localhost:8080", Channel: "TEST3",
|
||||||
|
KeyContainer: "TEST3_RSA_CONTAINER",
|
||||||
|
RequestTimeout: 30 * time.Second, RetryMax: 3, RetryBackoff: time.Second,
|
||||||
|
},
|
||||||
|
"prod-gost": {
|
||||||
|
Name: "prod-gost", IGWBaseURL: "http://localhost:8080", Channel: "PROD",
|
||||||
|
KeyContainer: "PROD_GOST_CONTAINER",
|
||||||
|
RequestTimeout: 60 * time.Second, RetryMax: 5, RetryBackoff: 2 * time.Second,
|
||||||
|
},
|
||||||
|
"prod-rsa": {
|
||||||
|
Name: "prod-rsa", IGWBaseURL: "http://localhost:8080", Channel: "PROD",
|
||||||
|
KeyContainer: "PROD_RSA_CONTAINER",
|
||||||
|
RequestTimeout: 60 * time.Second, RetryMax: 5, RetryBackoff: 2 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupProfile находит профиль по имени.
|
||||||
|
func LookupProfile(name string) (Profile, error) {
|
||||||
|
p, ok := profiles[name]
|
||||||
|
if !ok {
|
||||||
|
return Profile{}, fmt.Errorf("nsdadapter: неизвестный профиль %q", name)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableProfiles возвращает имена всех известных профилей.
|
||||||
|
func AvailableProfiles() []string {
|
||||||
|
out := make([]string, 0, len(profiles))
|
||||||
|
for name := range profiles {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package igw_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSendPackageHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/package/TEST3/file" {
|
||||||
|
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-123"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||||
|
id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("<xml/>"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != "pkg-123" {
|
||||||
|
t.Errorf("package_id = %q, ожидалось %q", id, "pkg-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendPackageRetryOn500(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls++
|
||||||
|
if calls < 2 {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-retry"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
|
||||||
|
id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != "pkg-retry" {
|
||||||
|
t.Errorf("ожидалось pkg-retry, получено %q", id)
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Errorf("ожидалось хотя бы 2 попытки, получено %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendPackage4xxNoRetry(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls++
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"bad"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
|
||||||
|
_, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка на 400")
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("4xx не должен ретраиться, попыток = %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStatus(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/package/status/pkg-1" {
|
||||||
|
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"package_id":"pkg-1","state":"delivered","updated_at":"2026-03-02T14:30:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||||
|
st, err := c.GetStatus(context.Background(), "pkg-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.State != "delivered" {
|
||||||
|
t.Errorf("state = %q, ожидалось delivered", st.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIncoming(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !contains(r.URL.RawQuery, "channel=TEST3") {
|
||||||
|
t.Errorf("в query нет channel: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"items":[{"package_id":"p1","package_type":"#M2MTD","channel":"TEST3","received_at":"2026-03-02T14:00:00Z","body":""}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
||||||
|
pkgs, err := c.ListIncoming(context.Background(), "TEST3", time.Now().Add(-time.Hour), "#M2MTD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pkgs) != 1 || pkgs[0].PackageType != "#M2MTD" {
|
||||||
|
t.Errorf("неожиданный результат: %+v", pkgs)
|
||||||
|
}
|
||||||
|
body, err := pkgs[0].DecodeBody()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("DecodeBody: %v", err)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
t.Errorf("ожидалось пустое тело")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && (indexOf(s, substr) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, substr string) int {
|
||||||
|
for i := 0; i+len(substr) <= len(s); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Package onyx — резервный канал к НРД через WS ONYX. На M1 скелет;
|
||||||
|
// реальная реализация — после PR-6 (crypto-service): подпись пакета
|
||||||
|
// требует КриптоПро JCP, которой пока нет.
|
||||||
|
package onyx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotImplemented возвращается из всех методов скелета.
|
||||||
|
var ErrNotImplemented = errors.New("nsdadapter/onyx: не реализовано (ждём PR-6 crypto-service)")
|
||||||
|
|
||||||
|
// Client — SOAP-клиент к OnyxEdoWSService. Заглушка.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send отправляет подписанный M2MTransferRequest напрямую через WS ONYX.
|
||||||
|
// TODO(PR-6): подписать пакет через crypto-service по UDS, упаковать в
|
||||||
|
// SOAP envelope, отправить.
|
||||||
|
func (Client) Send(_ context.Context, _ *m2m.M2MTransferRequest) error {
|
||||||
|
return ErrNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDecision отправляет подписанное M2MTransferDecision.
|
||||||
|
// TODO(PR-6): подписать через crypto-service.
|
||||||
|
func (Client) SendDecision(_ context.Context, _ *m2m.M2MTransferDecision) error {
|
||||||
|
return ErrNotImplemented
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package nsdadapter
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// PackageType — тип пакета ЭДО НРД. Используется при отправке через ИШ.
|
||||||
|
type PackageType string
|
||||||
|
|
||||||
|
// Известные типы пакетов M2M и связанных сервисных сообщений.
|
||||||
|
const (
|
||||||
|
PackageM2MTransferRequest PackageType = "#M2MTR"
|
||||||
|
PackageM2MTransferDecision PackageType = "#M2MTD"
|
||||||
|
PackageM2MTransferError PackageType = "#M2MER"
|
||||||
|
PackageSUBBR PackageType = "SUBBR"
|
||||||
|
PackageSUBER PackageType = "SUBER"
|
||||||
|
PackageSUB16 PackageType = "SUB16"
|
||||||
|
PackageAssetsInvestment PackageType = "Assets_investment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageKind — внутренний классификатор доменного M2M-сообщения,
|
||||||
|
// который мы хотим отправить в НРД.
|
||||||
|
type MessageKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindTransferRequest MessageKind = "transfer_request"
|
||||||
|
KindTransferDecision MessageKind = "transfer_decision"
|
||||||
|
KindTransferResponse MessageKind = "transfer_response"
|
||||||
|
KindHandbookRequest MessageKind = "handbook_request"
|
||||||
|
KindParticipantForm MessageKind = "participant_form"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteToPackageType подбирает PackageType ЭДО для нашего MessageKind.
|
||||||
|
func RouteToPackageType(kind MessageKind) (PackageType, error) {
|
||||||
|
switch kind {
|
||||||
|
case KindTransferRequest:
|
||||||
|
return PackageM2MTransferRequest, nil
|
||||||
|
case KindTransferDecision:
|
||||||
|
return PackageM2MTransferDecision, nil
|
||||||
|
case KindTransferResponse:
|
||||||
|
return PackageM2MTransferError, nil
|
||||||
|
case KindHandbookRequest:
|
||||||
|
return PackageAssetsInvestment, nil
|
||||||
|
case KindParticipantForm:
|
||||||
|
return PackageAssetsInvestment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("nsdadapter: не задан PackageType для kind=%q", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncomingPackageKinds — типы входящих пакетов, которые нужно опрашивать
|
||||||
|
// и обрабатывать на нашей стороне.
|
||||||
|
func IncomingPackageKinds() []PackageType {
|
||||||
|
return []PackageType{
|
||||||
|
PackageM2MTransferDecision,
|
||||||
|
PackageM2MTransferError,
|
||||||
|
PackageSUBBR,
|
||||||
|
PackageSUBER,
|
||||||
|
PackageSUB16,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package nsdadapter_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouteToPackageType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
kind nsdadapter.MessageKind
|
||||||
|
want nsdadapter.PackageType
|
||||||
|
}{
|
||||||
|
{nsdadapter.KindTransferRequest, nsdadapter.PackageM2MTransferRequest},
|
||||||
|
{nsdadapter.KindTransferDecision, nsdadapter.PackageM2MTransferDecision},
|
||||||
|
{nsdadapter.KindTransferResponse, nsdadapter.PackageM2MTransferError},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := nsdadapter.RouteToPackageType(c.kind)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("kind=%s ошибка: %v", c.kind, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("kind=%s: получено %s, ожидалось %s", c.kind, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteToPackageTypeUnknown(t *testing.T) {
|
||||||
|
if _, err := nsdadapter.RouteToPackageType("unknown"); err == nil {
|
||||||
|
t.Error("ожидалась ошибка на неизвестный kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncomingPackageKindsContainsExpected(t *testing.T) {
|
||||||
|
kinds := nsdadapter.IncomingPackageKinds()
|
||||||
|
want := map[nsdadapter.PackageType]bool{
|
||||||
|
nsdadapter.PackageM2MTransferDecision: true,
|
||||||
|
nsdadapter.PackageSUB16: true,
|
||||||
|
}
|
||||||
|
for _, k := range kinds {
|
||||||
|
delete(want, k)
|
||||||
|
}
|
||||||
|
if len(want) > 0 {
|
||||||
|
t.Errorf("в IncomingPackageKinds не хватает: %v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupProfile(t *testing.T) {
|
||||||
|
for _, name := range []string{"guest-gost", "test3-rsa", "prod-gost"} {
|
||||||
|
p, err := nsdadapter.LookupProfile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("LookupProfile(%s): %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Name != name {
|
||||||
|
t.Errorf("LookupProfile(%s).Name = %s", name, p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := nsdadapter.LookupProfile("unknown"); err == nil {
|
||||||
|
t.Error("ожидалась ошибка на неизвестный профиль")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package nsdadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IGWClient — узкий интерфейс из igw.Client, нужный Sender'у. Введён
|
||||||
|
// ради тестируемости (mock в тестах) и независимости от ИШ-реализации.
|
||||||
|
type IGWClient interface {
|
||||||
|
SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error)
|
||||||
|
ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]igw.Package, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender — реализация m2mcore.NSDSender поверх REST ИШ НРД.
|
||||||
|
type Sender struct {
|
||||||
|
profile Profile
|
||||||
|
client IGWClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSender собирает Sender для указанного профиля.
|
||||||
|
func NewSender(profile Profile, client IGWClient) *Sender {
|
||||||
|
return &Sender{profile: profile, client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send сериализует M2MTransferRequest в windows-1251, маршрутизирует к
|
||||||
|
// типу пакета #M2MTR и отправляет в ИШ. M2MTransferResponse в этом
|
||||||
|
// канале возвращается асинхронно через ListIncoming, поэтому Send
|
||||||
|
// возвращает nil-response — реальный ответ забирает поллер.
|
||||||
|
func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("nsdadapter: Send: req=nil")
|
||||||
|
}
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("nsdadapter: req.Validate: %w", err)
|
||||||
|
}
|
||||||
|
body, err := nsdxml.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("nsdadapter: marshal Request: %w", err)
|
||||||
|
}
|
||||||
|
pkgType, err := RouteToPackageType(KindTransferRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgID, err := s.client.SendPackage(ctx, s.profile.Channel, string(pkgType), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("nsdadapter: SendPackage: %w", err)
|
||||||
|
}
|
||||||
|
// Возвращаем псевдо-Response с GUID-ом для трассировки. Реальный
|
||||||
|
// M2MTransferResponse от НРД придёт через входящие пакеты, его
|
||||||
|
// обработает поллер cmd/nsd-adapter.
|
||||||
|
_ = pkgID
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDecision сериализует и отправляет M2MTransferDecision.
|
||||||
|
func (s *Sender) SendDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error {
|
||||||
|
if decision == nil {
|
||||||
|
return errors.New("nsdadapter: SendDecision: decision=nil")
|
||||||
|
}
|
||||||
|
if err := decision.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("nsdadapter: decision.Validate: %w", err)
|
||||||
|
}
|
||||||
|
body, err := nsdxml.Marshal(decision)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nsdadapter: marshal Decision: %w", err)
|
||||||
|
}
|
||||||
|
pkgType, err := RouteToPackageType(KindTransferDecision)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.client.SendPackage(ctx, s.profile.Channel, string(pkgType), body); err != nil {
|
||||||
|
return fmt.Errorf("nsdadapter: SendPackage: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package nsdadapter_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockIGW struct {
|
||||||
|
gotChannel string
|
||||||
|
gotType string
|
||||||
|
gotBody []byte
|
||||||
|
returnID string
|
||||||
|
returnErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIGW) SendPackage(_ context.Context, channel, packageType string, body []byte) (string, error) {
|
||||||
|
m.gotChannel = channel
|
||||||
|
m.gotType = packageType
|
||||||
|
m.gotBody = append([]byte(nil), body...)
|
||||||
|
return m.returnID, m.returnErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIGW) ListIncoming(_ context.Context, _ string, _ time.Time, _ string) ([]igw.Package, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validRequest строит минимально валидный M2MTransferRequest для теста Sender.
|
||||||
|
func validRequest() *m2m.M2MTransferRequest {
|
||||||
|
whole := uint64(100)
|
||||||
|
isin := m2m.ISIN("RU0007661625")
|
||||||
|
return &m2m.M2MTransferRequest{
|
||||||
|
Header: m2m.RequestHeader{
|
||||||
|
GUID: m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133"),
|
||||||
|
SenderCode: "MC0079200000",
|
||||||
|
ReceiverCode: "MC0010300000",
|
||||||
|
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
|
||||||
|
},
|
||||||
|
Data: m2m.RequestData{
|
||||||
|
InvestorInformation: m2m.InvestorInformation{
|
||||||
|
LastName: "Иванов",
|
||||||
|
FirstName: "Иван",
|
||||||
|
IdentityDocument: m2m.IdentityDocument{
|
||||||
|
DocumentType: m2m.DocCode21,
|
||||||
|
DocumentNumber: m2m.IdentityDocSerial("654321"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TransferringDepository: m2m.SettlementRequisites{INN: "7702070139"},
|
||||||
|
ReceivingDepository: m2m.SettlementRequisites{INN: "7802031669"},
|
||||||
|
TransferredSecurities: m2m.RequestTransferredSecurities{
|
||||||
|
Securities: []m2m.RequestSecurity{
|
||||||
|
{
|
||||||
|
ReferenceID: "M2M2026030200001",
|
||||||
|
SecurityCode: "MM0766162534",
|
||||||
|
SecurityDetails: m2m.SecurityDetails{ISIN: &isin},
|
||||||
|
Quantity: m2m.Quantity{Whole: &whole},
|
||||||
|
SettlementAccount: []m2m.RequestSettlementAccount{
|
||||||
|
{
|
||||||
|
SettlementRequisites: m2m.SettlementRequisites{INN: "7702070139"},
|
||||||
|
SettlementLocation: m2m.SettlementDepositoryLocation{
|
||||||
|
DeponentCode: "DP789456",
|
||||||
|
AccountID: "31MC0021900000F01",
|
||||||
|
SectionID: "P001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IsolationStatus: m2m.IsolationSGDN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSenderSend(t *testing.T) {
|
||||||
|
profile, err := nsdadapter.LookupProfile("test3-gost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mock := &mockIGW{returnID: "pkg-1"}
|
||||||
|
s := nsdadapter.NewSender(profile, mock)
|
||||||
|
|
||||||
|
_, err = s.Send(context.Background(), validRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if mock.gotChannel != "TEST3" {
|
||||||
|
t.Errorf("channel = %q, ожидалось TEST3", mock.gotChannel)
|
||||||
|
}
|
||||||
|
if mock.gotType != string(nsdadapter.PackageM2MTransferRequest) {
|
||||||
|
t.Errorf("type = %q, ожидалось %s", mock.gotType, nsdadapter.PackageM2MTransferRequest)
|
||||||
|
}
|
||||||
|
if len(mock.gotBody) == 0 {
|
||||||
|
t.Error("body пустой")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSenderSendNilRequest(t *testing.T) {
|
||||||
|
profile, _ := nsdadapter.LookupProfile("guest-gost")
|
||||||
|
s := nsdadapter.NewSender(profile, &mockIGW{})
|
||||||
|
if _, err := s.Send(context.Background(), nil); err == nil {
|
||||||
|
t.Error("ожидалась ошибка на nil request")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user