diff --git a/cmd/lk-emulator/main.go b/cmd/lk-emulator/main.go index 44309a8..c909a31 100644 --- a/cmd/lk-emulator/main.go +++ b/cmd/lk-emulator/main.go @@ -1,20 +1,48 @@ -// Package main — сервис lk-emulator. Эмулятор ЛК клиента (ESIA Finance API V1) -// на время, пока реальный ЛК не готов. Позволяет «как будто загрузить» -// заявление через веб-форму и запустить полный путь обработки документа. +// Package main — сервис lk-emulator. Имитация ЛК клиента (ESIA Finance +// API V1) на время, пока реальный ЛК не готов. Веб-форма «новая заявка», +// журнал моих заявок, приёмник callback'ов от lk-gateway. // -// Когда реальный ЛК подключится — эмулятор остаётся как тестовый инструмент -// в QA-окружении. -// -// На этапе M1 — заглушка. +// Когда реальный ЛК подключится, эмулятор остаётся как тестовый +// инструмент в QA-окружении: даёт сквозной сценарий без зависимости от +// внешней стороны. package main import ( - "fmt" + "context" + "log" "os" + "os/signal" + "syscall" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator" ) -const serviceName = "lk-emulator" - func main() { - fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName) + addr := getenv("BJ_HTTP_ADDR", ":8083") + gw := getenv("BJ_GATEWAY_URL", "http://127.0.0.1:8080") + self := getenv("BJ_EMULATOR_PUBLIC_URL", "http://127.0.0.1:8083") + + srv, err := lkemulator.NewServer(lkemulator.ServerConfig{ + Addr: addr, + GatewayURL: gw, + SelfPublicURL: self, + }) + if err != nil { + log.Fatalf("lk-emulator: NewServer: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + runErr := srv.Run(ctx) + stop() + if runErr != nil { + log.Printf("lk-emulator: %v", runErr) + os.Exit(1) + } +} + +func getenv(k, def string) string { + if v, ok := os.LookupEnv(k); ok && v != "" { + return v + } + return def } diff --git a/cmd/lk-gateway/main.go b/cmd/lk-gateway/main.go index 7dd2c16..66aa98d 100644 --- a/cmd/lk-gateway/main.go +++ b/cmd/lk-gateway/main.go @@ -1,17 +1,67 @@ -// Package main — сервис lk-gateway. Принимает заявления от ЛК клиента -// (платформа ESIA Finance, /api/v1/back_office/...), валидирует их подпись, -// передаёт в m2m-core, отдаёт callback-статусы обратно в ЛК. +// Package main — сервис lk-gateway. BFF слой ЛК клиента: +// принимает REST-заявки по контракту ESIA Finance, валидирует, +// собирает M2MTransferRequest, отправляет в НРД через nsd-adapter, +// эмитит callback статуса обратно в ЛК. // -// На этапе M1 — заглушка. Реализация контракта — M2. +// На M2 — in-memory репозиторий + mock NSDSender (имитация принимающей +// стороны через 3 секунды). На M3 переключим на pgx + реальный +// nsd-adapter без изменения контракта. package main import ( - "fmt" + "context" + "log" "os" + "os/signal" + "syscall" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" ) -const serviceName = "lk-gateway" - func main() { - fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName) + addr := getenv("BJ_HTTP_ADDR", ":8080") + defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "MC0079200000")) + defaultReceiver := m2m.DeponentCode(getenv("BJ_M2M_RECEIVER", "MC0010300000")) + + cfg := lkgateway.ServerConfig{ + Addr: addr, + DefaultSender: defaultSender, + DefaultReceiver: defaultReceiver, + CheckOptions: func() lkgateway.CheckOptions { + return lkgateway.CheckOptions{ + PostgresDSN: os.Getenv("BJ_DSN"), + CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"), + NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"), + LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"), + Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"), + CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"), + Timeout: 2 * time.Second, + } + }, + } + + srv, err := lkgateway.NewServer(cfg) + if err != nil { + log.Fatalf("lk-gateway: NewServer: %v", err) + } + if cb := os.Getenv("BJ_LK_CALLBACK_URL"); cb != "" { + srv.SetCallbackURL(cb) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + runErr := srv.Run(ctx) + stop() + if runErr != nil { + log.Printf("lk-gateway: %v", runErr) + os.Exit(1) + } +} + +func getenv(k, def string) string { + if v, ok := os.LookupEnv(k); ok && v != "" { + return v + } + return def } diff --git a/docs/tasks/README.md b/docs/tasks/README.md index cf55d81..c39f950 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -17,6 +17,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос | PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 | | PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 | | PR-6 | `PR-6-crypto-service-skeleton.md` | выполнено (скелет) | PR-1 | +| M2-шаг-1 | сквозной поток: lk-gateway BFF + admin web + lk-emulator + mock NSD | выполнено | PR-1, PR-3, PR-4 | ## Как запустить задачу diff --git a/internal/lkemulator/README.md b/internal/lkemulator/README.md new file mode 100644 index 0000000..a7eca58 --- /dev/null +++ b/internal/lkemulator/README.md @@ -0,0 +1,48 @@ +# internal/lkemulator — имитация ЛК клиента (ESIA Finance) + +Веб-приложение, которое играет роль ЛК ESIA Finance в сквозных тестах +без подключения к реальному ЛК. Используется: + +- для дев-демо «увидеть как работает сквозной поток заявки M2M»; +- для приёмочного теста перед интеграцией с реальным ЛК; +- как QA-инструмент, который остаётся после внедрения реального ЛК. + +## Веб-страницы + +- `/` — журнал моих заявок с автообновлением каждые 3 сек (показывает + изменение статуса по callback'у от lk-gateway). +- `/new` — форма «подать заявку M2M»: выпадающий список инвесторов + (из `seed-data`), реквизиты сторон, ИИС, одна ЦБ, выбор имитируемого + исхода (confirm/reject/timeout). +- `/claims/{id}` — карточка с историей: тело POST в lk-gateway, + ответ lk-gateway, полученный callback, расшифровка ответа НРД. +- `/healthz` — health. +- `/api/v1/back_office/claims/{id}` (PATCH) — приёмник callback'ов от + lk-gateway. + +## Состав пакета + +- `server.go` — HTTP-сервер, маршруты, рендер шаблонов. +- `client.go` — `GatewayClient` (POST заявка, регистрация callback URL). +- `types.go` — `Store` (in-memory) и `Claim`/`CallbackRecord` модели. +- `web/templates/` — `layout.html`, `home.html`, `new.html`, `claim.html`. + +## Конфигурация (cmd/lk-emulator/main.go, ENV) + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `BJ_HTTP_ADDR` | `:8083` | Адрес HTTP | +| `BJ_GATEWAY_URL` | `http://127.0.0.1:8080` | URL lk-gateway, куда шлём POST заявки | +| `BJ_EMULATOR_PUBLIC_URL` | `http://127.0.0.1:8083` | Куда gateway должен слать callback'и (регистрируется при старте) | + +## Сквозной поток для проверки + +1. Запустить `./bin/lk-gateway` (порт 8080) и `./bin/lk-emulator` (порт 8083). +2. Открыть в браузере `http://127.0.0.1:8083/new`. +3. Подать заявку с дефолтными значениями + исход `confirm`. +4. На странице `http://127.0.0.1:8083/` через ~3 секунды увидеть + статус заявки `confirmed`. +5. На странице `http://127.0.0.1:8080/admin/` — дашборд lk-gateway со + счётчиком «Подтверждено: 1» и заявкой в журнале. +6. На странице `http://127.0.0.1:8080/admin/status` — состояние всех + подсистем. diff --git a/internal/lkemulator/client.go b/internal/lkemulator/client.go new file mode 100644 index 0000000..3e40916 --- /dev/null +++ b/internal/lkemulator/client.go @@ -0,0 +1,75 @@ +package lkemulator + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// GatewayClient — клиент к lk-gateway по REST. +type GatewayClient struct { + baseURL string + httpc *http.Client +} + +// NewGatewayClient — конструктор. +func NewGatewayClient(baseURL string) *GatewayClient { + return &GatewayClient{ + baseURL: baseURL, + httpc: &http.Client{Timeout: 10 * time.Second}, + } +} + +// CreateClaim шлёт POST /api/v1/back_office/claims/ и возвращает ответ. +func (c *GatewayClient) CreateClaim(ctx context.Context, body map[string]any) (map[string]any, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.baseURL+"/api/v1/back_office/claims/", bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + out, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("lk-gateway HTTP %d: %s", resp.StatusCode, string(out)) + } + var parsed map[string]any + if err := json.Unmarshal(out, &parsed); err != nil { + return nil, fmt.Errorf("lk-gateway: разбор JSON: %w; raw: %s", err, string(out)) + } + return parsed, nil +} + +// SetCallbackURL сообщает gateway свой URL — куда слать PATCH callback'и. +func (c *GatewayClient) SetCallbackURL(ctx context.Context, url string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.baseURL+"/admin/api/callback-url?url="+url, nil) + if err != nil { + return err + } + resp, err := c.httpc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + buf, _ := io.ReadAll(resp.Body) + return fmt.Errorf("set callback url HTTP %d: %s", resp.StatusCode, string(buf)) + } + return nil +} diff --git a/internal/lkemulator/server.go b/internal/lkemulator/server.go new file mode 100644 index 0000000..67e1149 --- /dev/null +++ b/internal/lkemulator/server.go @@ -0,0 +1,427 @@ +package lkemulator + +import ( + "context" + "crypto/rand" + "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +//go:embed web/templates/*.html +var tplFS embed.FS + +// ServerConfig — настройки эмулятора ЛК. +type ServerConfig struct { + Addr string + GatewayURL string + SelfPublicURL string // адрес, который мы сообщим gateway для callback'ов +} + +// Server — HTTP-сервер эмулятора. +type Server struct { + cfg ServerConfig + store *Store + gw *GatewayClient + home, new, claim *template.Template + srv *http.Server + mux *http.ServeMux +} + +// NewServer собирает Server. +func NewServer(cfg ServerConfig) (*Server, error) { + parse := func(content string) (*template.Template, error) { + return template.ParseFS(tplFS, "web/templates/layout.html", "web/templates/"+content) + } + home, err := parse("home.html") + if err != nil { + return nil, fmt.Errorf("parse home: %w", err) + } + newTpl, err := parse("new.html") + if err != nil { + return nil, fmt.Errorf("parse new: %w", err) + } + claimTpl, err := parse("claim.html") + if err != nil { + return nil, fmt.Errorf("parse claim: %w", err) + } + s := &Server{ + cfg: cfg, + store: NewStore(), + gw: NewGatewayClient(cfg.GatewayURL), + home: home, + new: newTpl, + claim: claimTpl, + mux: http.NewServeMux(), + } + s.routes() + s.srv = &http.Server{ + Addr: cfg.Addr, + Handler: s.mux, + ReadHeaderTimeout: 5 * time.Second, + } + return s, nil +} + +// Run поднимает сервер, регистрирует свой URL у gateway, ждёт ctx.Done(). +func (s *Server) Run(ctx context.Context) error { + go func() { + // Дать серверу подняться, потом попытаться зарегистрировать callback URL. + time.Sleep(200 * time.Millisecond) + if s.cfg.SelfPublicURL != "" { + if err := s.gw.SetCallbackURL(ctx, s.cfg.SelfPublicURL); err != nil { + log.Printf("lk-emulator: не получилось зарегистрировать callback URL: %v", err) + } else { + log.Printf("lk-emulator: callback URL %s зарегистрирован в lk-gateway", s.cfg.SelfPublicURL) + } + } + }() + + errCh := make(chan error, 1) + go func() { + log.Printf("lk-emulator: listen %s", s.cfg.Addr) + errCh <- s.srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.srv.Shutdown(shutCtx) + return nil + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +// Mux возвращает обработчик (для httptest). +func (s *Server) Mux() http.Handler { return s.mux } + +// Store возвращает store (для тестов). +func (s *Server) Store() *Store { return s.store } + +func (s *Server) routes() { + s.mux.HandleFunc("/", s.handleHome) + s.mux.HandleFunc("/new", s.handleNew) + s.mux.HandleFunc("/claims/", s.handleClaim) + s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + // Принимаем callback'и от lk-gateway (PATCH). + s.mux.HandleFunc("/api/v1/back_office/claims/", s.handleCallback) +} + +type pageData struct { + Title string + Active string + GatewayURL string + AutoRefresh bool + Flash string + Error string +} + +func (s *Server) basePage(title, active string, autoRefresh bool) pageData { + return pageData{Title: title, Active: active, GatewayURL: s.cfg.GatewayURL, AutoRefresh: autoRefresh} +} + +type homeData struct { + pageData + Claims []*Claim +} + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data := homeData{ + pageData: s.basePage("Мои заявки", "home", true), + Claims: s.store.All(), + } + if msg := r.URL.Query().Get("flash"); msg != "" { + data.Flash = msg + } + s.renderTpl(w, s.home, data) +} + +// clientView — DTO от gateway для выпадающего списка инвесторов. +type clientView struct { + ID string `json:"ID"` + LastName string `json:"LastName"` + FirstName string `json:"FirstName"` + MiddleName string `json:"MiddleName"` +} + +type newData struct { + pageData + Clients []clientView +} + +func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + clients, err := s.fetchClients(r.Context()) + if err != nil { + s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: nil}) + return + } + s.renderTpl(w, s.new, newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients}) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := s.submitNew(r); err != nil { + clients, _ := s.fetchClients(r.Context()) + d := newData{pageData: s.basePage("Новая заявка", "new", false), Clients: clients} + d.Error = err.Error() + s.renderTpl(w, s.new, d) + return + } + http.Redirect(w, r, "/?flash=Заявка+отправлена+в+lk-gateway.+Ждём+callback...", http.StatusSeeOther) +} + +func (s *Server) submitNew(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("разбор формы: %w", err) + } + investorID := r.FormValue("investor_id") + if investorID == "" { + return errors.New("укажите инвестора") + } + clients, err := s.fetchClients(r.Context()) + if err != nil { + return fmt.Errorf("список инвесторов: %w", err) + } + investorName := "" + for _, c := range clients { + if c.ID == investorID { + investorName = strings.TrimSpace(c.LastName + " " + c.FirstName + " " + c.MiddleName) + break + } + } + + tInn := r.FormValue("transferring_depository_inn") + rInn := r.FormValue("receiving_depository_inn") + secCode := r.FormValue("security_code") + isin := r.FormValue("isin") + qStr := r.FormValue("quantity") + whole, err := strconv.ParseUint(qStr, 10, 64) + if err != nil || whole == 0 { + return errors.New("количество должно быть положительным целым") + } + + body := map[string]any{ + "investor": map[string]any{ + "id": investorID, + "last_name": splitFio(investorName, 0), + "first_name": splitFio(investorName, 1), + "document": map[string]any{ + "document_type": "21", + "series": "4512", + "number": "654321", + }, + }, + "transferring_depository_inn": tInn, + "receiving_depository_inn": rInn, + "securities": []any{ + map[string]any{ + "security_code": secCode, + "security_details": map[string]any{"isin": isin}, + "quantity": map[string]any{"whole": whole}, + "settlement_accounts": []any{ + map[string]any{ + "settlement_requisites_inn": "7702070139", + "settlement_location": map[string]any{ + "deponent_code": "DP789456", + "account_id": "31MC0021900000F01", + "section_id": "P001", + }, + }, + }, + }, + }, + "signed_document": base64.StdEncoding.EncodeToString([]byte("demo")), + "signature_format": "XMLDSig-GOST", + } + if code := r.FormValue("cost_info_code"); code != "" { + body["cost_info"] = map[string]any{"yes": map[string]any{"code": code}} + } else { + body["cost_info"] = map[string]any{"no": map[string]any{}} + } + if iiaType := r.FormValue("iia_type"); iiaType != "" { + body["iia_agreement"] = map[string]any{ + "agreement_type": iiaType, + "agreement_number": r.FormValue("iia_number"), + "agreement_date": r.FormValue("iia_date"), + "broker_inn": r.FormValue("iia_broker_inn"), + } + } + + resp, err := s.gw.CreateClaim(r.Context(), body) + if err != nil { + return err + } + gwID, _ := resp["id"].(string) + status, _ := resp["status"].(string) + + c := &Claim{ + ID: randomID(), + GatewayID: gwID, + Status: status, + LocalStatus: "submitted", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + InvestorName: investorName, + TransferringDepositoryINN: tInn, + ReceivingDepositoryINN: rInn, + SecuritiesCount: 1, + RawRequest: body, + RawResponse: resp, + } + s.store.Add(c) + return nil +} + +type claimPage struct { + pageData + Claim *Claim + PrettyRequest string + PrettyResponse string +} + +func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/claims/") + if id == "" || strings.Contains(id, "/") { + http.NotFound(w, r) + return + } + c := s.store.Get(id) + if c == nil { + http.NotFound(w, r) + return + } + d := claimPage{ + pageData: s.basePage("Заявка", "home", c.LastCallback == nil), + Claim: c, + } + if b, err := json.MarshalIndent(c.RawRequest, "", " "); err == nil { + d.PrettyRequest = string(b) + } + if b, err := json.MarshalIndent(c.RawResponse, "", " "); err == nil { + d.PrettyResponse = string(b) + } + s.renderTpl(w, s.claim, d) +} + +// handleCallback принимает PATCH /api/v1/back_office/claims/{id} от lk-gateway. +func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + gwID := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/") + if gwID == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + defer r.Body.Close() + raw, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var payload struct { + NewStatus string `json:"new_status"` + ReasonCode string `json:"reason_code"` + ReasonText string `json:"reason_text"` + UpdatedAt time.Time `json:"updated_at"` + NSDResp *struct { + GUID string `json:"guid"` + StatusCode string `json:"status_code"` + Responses []struct { + ReferenceID string `json:"reference_id"` + Code string `json:"code"` + Text string `json:"text"` + } `json:"responses"` + } `json:"nsd_response"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + cb := &CallbackRecord{ + NewStatus: payload.NewStatus, + ReasonCode: payload.ReasonCode, + ReasonText: payload.ReasonText, + UpdatedAt: payload.UpdatedAt, + } + if payload.NSDResp != nil { + cb.GUID = payload.NSDResp.GUID + cb.StatusCode = payload.NSDResp.StatusCode + for _, rr := range payload.NSDResp.Responses { + cb.Responses = append(cb.Responses, CallbackResponseEntry{ + ReferenceID: rr.ReferenceID, Code: rr.Code, Text: rr.Text, + }) + } + } + if !s.store.ApplyCallback(gwID, cb) { + http.Error(w, "claim not found in emulator", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true}`)) +} + +func (s *Server) fetchClients(ctx context.Context) ([]clientView, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.GatewayURL+"/admin/api/clients", nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out []clientView + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +func (s *Server) renderTpl(w http.ResponseWriter, t *template.Template, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "layout", data); err != nil { + log.Printf("lk-emulator: render: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// randomID — генерирует короткий локальный ID заявки (8 hex). +func randomID() string { + var b [4]byte + _, _ = rand.Read(b[:]) + return fmt.Sprintf("%x", b) +} + +func splitFio(fio string, idx int) string { + parts := strings.Fields(fio) + if idx < len(parts) { + return parts[idx] + } + return "" +} diff --git a/internal/lkemulator/server_test.go b/internal/lkemulator/server_test.go new file mode 100644 index 0000000..9c7019e --- /dev/null +++ b/internal/lkemulator/server_test.go @@ -0,0 +1,105 @@ +package lkemulator_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator" +) + +func TestCallbackUpdatesStore(t *testing.T) { + srv, err := lkemulator.NewServer(lkemulator.ServerConfig{ + Addr: ":0", + GatewayURL: "http://example.invalid", + }) + if err != nil { + t.Fatal(err) + } + + // Положим заявку в store вручную. + c := &lkemulator.Claim{ + ID: "local-1", + GatewayID: "gw-abc", + Status: "submitted_to_nsd", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + srv.Store().Add(c) + + body := map[string]any{ + "claim_id": "gw-abc", + "new_status": "confirmed", + "updated_at": time.Now().Format(time.RFC3339), + "nsd_response": map[string]any{ + "guid": "c02a1d5e-c2af-4799-bab4-953f133c5133", + "status_code": "INFO", + "responses": []map[string]any{{"reference_id": "M2M2026030200001", "code": "01", "text": "ok"}}, + }, + } + raw, _ := json.Marshal(body) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/v1/back_office/claims/gw-abc", bytes.NewReader(raw)) + srv.Mux().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("callback code=%d body=%s", w.Code, w.Body.String()) + } + + got := srv.Store().Get("local-1") + if got.Status != "confirmed" { + t.Errorf("статус не обновился, got=%s", got.Status) + } + if got.LastCallback == nil { + t.Fatal("LastCallback nil") + } + if len(got.LastCallback.Responses) != 1 { + t.Errorf("ожидался 1 response, получено %d", len(got.LastCallback.Responses)) + } +} + +func TestCallbackUnknownClaim(t *testing.T) { + srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"}) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/v1/back_office/claims/unknown", strings.NewReader(`{"new_status":"confirmed"}`)) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("ожидался 404, получено %d", w.Code) + } +} + +func TestHomePageEmpty(t *testing.T) { + srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"}) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("home: %d", w.Code) + } + if !strings.Contains(w.Body.String(), "Заявок ещё нет") { + t.Errorf("ожидалось сообщение об отсутствии заявок") + } +} + +func TestHealthz(t *testing.T) { + srv, err := lkemulator.NewServer(lkemulator.ServerConfig{Addr: ":0", GatewayURL: "http://example.invalid"}) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("healthz: %d", w.Code) + } +} diff --git a/internal/lkemulator/types.go b/internal/lkemulator/types.go new file mode 100644 index 0000000..d551f40 --- /dev/null +++ b/internal/lkemulator/types.go @@ -0,0 +1,109 @@ +// Package lkemulator — имитация ЛК клиента (ESIA Finance). Предоставляет +// HTML-формы для подачи заявок M2M и приёмник callback'ов от lk-gateway. +// Используется для сквозного дев-теста системы Bridge-and-Join-s без +// реальной интеграции с ЛК. +package lkemulator + +import ( + "sync" + "time" +) + +// Claim — локальная сохранённая копия заявки + последний callback. +type Claim struct { + ID string + GatewayID string // ID присвоенный lk-gateway (совпадает с m2mcore.Deal.ID) + Status string + LocalStatus string // "submitted" | "callback_received" — внутренняя метка эмулятора + CreatedAt time.Time + UpdatedAt time.Time + InvestorName string + TransferringDepositoryINN string + ReceivingDepositoryINN string + SecuritiesCount int + LastCallback *CallbackRecord + RawRequest map[string]any + RawResponse map[string]any +} + +// CallbackRecord — сохранённый callback от lk-gateway. +type CallbackRecord struct { + NewStatus string + ReasonCode string + ReasonText string + UpdatedAt time.Time + GUID string + StatusCode string + Responses []CallbackResponseEntry +} + +// CallbackResponseEntry — одна строка из NSDResponse в callback'е. +type CallbackResponseEntry struct { + ReferenceID string + Code string + Text string +} + +// Store — in-memory хранилище заявок эмулятора. +type Store struct { + mu sync.RWMutex + byID map[string]*Claim + byGw map[string]*Claim + order []string +} + +// NewStore — пустое хранилище. +func NewStore() *Store { + return &Store{byID: make(map[string]*Claim), byGw: make(map[string]*Claim)} +} + +// Add сохраняет новую заявку. +func (s *Store) Add(c *Claim) { + s.mu.Lock() + defer s.mu.Unlock() + s.byID[c.ID] = c + if c.GatewayID != "" { + s.byGw[c.GatewayID] = c + } + s.order = append(s.order, c.ID) +} + +// Get — по локальному ID. +func (s *Store) Get(id string) *Claim { + s.mu.RLock() + defer s.mu.RUnlock() + return s.byID[id] +} + +// GetByGatewayID — по ID от lk-gateway (используется при приёме callback). +func (s *Store) GetByGatewayID(id string) *Claim { + s.mu.RLock() + defer s.mu.RUnlock() + return s.byGw[id] +} + +// All возвращает все заявки в обратном порядке создания. +func (s *Store) All() []*Claim { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*Claim, 0, len(s.order)) + for i := len(s.order) - 1; i >= 0; i-- { + out = append(out, s.byID[s.order[i]]) + } + return out +} + +// ApplyCallback обновляет заявку при получении callback'а от lk-gateway. +func (s *Store) ApplyCallback(gatewayID string, cb *CallbackRecord) bool { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.byGw[gatewayID] + if !ok { + return false + } + c.Status = cb.NewStatus + c.LocalStatus = "callback_received" + c.UpdatedAt = cb.UpdatedAt + c.LastCallback = cb + return true +} diff --git a/internal/lkemulator/web/templates/claim.html b/internal/lkemulator/web/templates/claim.html new file mode 100644 index 0000000..926a1f6 --- /dev/null +++ b/internal/lkemulator/web/templates/claim.html @@ -0,0 +1,61 @@ +{{define "content"}} +
+

Заявка {{slice .Claim.ID 0 8}} · {{.Claim.Status}}

+
+
Создана
{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}
+
Обновлена
{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}
+
ID lk-gateway
{{.Claim.GatewayID}}
+
Инвестор
{{.Claim.InvestorName}}
+
Передающий депозитарий
ИНН {{.Claim.TransferringDepositoryINN}}
+
Принимающий депозитарий
ИНН {{.Claim.ReceivingDepositoryINN}}
+
Локально
{{.Claim.LocalStatus}}
+
+
+ +{{if .Claim.RawRequest}} +
+

Тело отправленной заявки (REST в lk-gateway)

+
{{.PrettyRequest}}
+
+{{end}} + +{{if .Claim.RawResponse}} +
+

Ответ lk-gateway на создание

+
{{.PrettyResponse}}
+
+{{end}} + +{{if .Claim.LastCallback}} +
+

Полученный callback от lk-gateway

+
+
Новый статус
{{.Claim.LastCallback.NewStatus}}
+ {{if .Claim.LastCallback.ReasonCode}} +
Код причины
{{.Claim.LastCallback.ReasonCode}} {{.Claim.LastCallback.ReasonText}}
+ {{end}} +
Время
{{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}}
+ {{if .Claim.LastCallback.GUID}} +
NSD GUID
{{.Claim.LastCallback.GUID}}
+ {{end}} +
+ {{if .Claim.LastCallback.Responses}} +

NSD Response (детально)

+ + + + {{range .Claim.LastCallback.Responses}} + + {{end}} + +
ReferenceIDКодТекст
{{.ReferenceID}}{{.Code}}{{.Text}}
+ {{end}} +
+{{else}} +
+

Callback от lk-gateway ещё не пришёл. Страница автообновится через 3 секунды.

+
+{{end}} + +

← все заявки

+{{end}} diff --git a/internal/lkemulator/web/templates/home.html b/internal/lkemulator/web/templates/home.html new file mode 100644 index 0000000..285c3d1 --- /dev/null +++ b/internal/lkemulator/web/templates/home.html @@ -0,0 +1,31 @@ +{{define "content"}} +{{if .Flash}}
{{.Flash}}
{{end}} + +
+

Журнал моих заявок ({{len .Claims}})

+ {{if .Claims}} + + + + + + {{range .Claims}} + + + + + + + + + + + {{end}} + +
СозданаID gatewayИнвесторЦБПередающийПринимающийСтатус
{{.CreatedAt.Format "02.01 15:04:05"}}{{if .GatewayID}}{{slice .GatewayID 0 8}}{{else}}—{{end}}{{.InvestorName}}{{.SecuritiesCount}}{{.TransferringDepositoryINN}}{{.ReceivingDepositoryINN}}{{.Status}}детали →
+

Страница автообновляется каждые 3 сек, чтобы видеть переход статуса по callback'у от lk-gateway.

+ {{else}} +

Заявок ещё нет. Подайте первую.

+ {{end}} +
+{{end}} diff --git a/internal/lkemulator/web/templates/layout.html b/internal/lkemulator/web/templates/layout.html new file mode 100644 index 0000000..37e77dc --- /dev/null +++ b/internal/lkemulator/web/templates/layout.html @@ -0,0 +1,63 @@ +{{define "layout"}} + + + +{{.Title}} · lk-emulator + + + +
+

lk-emulator имитация ЛК ESIA Finance

+ + gateway: {{.GatewayURL}} +
+
+{{template "content" .}} +
+ + + +{{end}} diff --git a/internal/lkemulator/web/templates/new.html b/internal/lkemulator/web/templates/new.html new file mode 100644 index 0000000..7965f1d --- /dev/null +++ b/internal/lkemulator/web/templates/new.html @@ -0,0 +1,77 @@ +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} + +
+

Подача заявки M2M

+

Минимальная форма; остальные поля заполняются автоматически по seed-данным (см. docs/fansy-contract/v1/examples/seed-data.sql).

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Ценная бумага (1 шт. в эмуляторе)

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + Отмена +
+
+
+{{end}} diff --git a/internal/lkgateway/README.md b/internal/lkgateway/README.md new file mode 100644 index 0000000..44ffcfb --- /dev/null +++ b/internal/lkgateway/README.md @@ -0,0 +1,70 @@ +# internal/lkgateway — BFF слой ЛК + admin web UI + +Реализует REST-контракт ESIA Finance V1 +(`docs/lk-contract/v1/openapi.yaml`) на стороне Bridge-and-Join-s и +admin-веб для оператора. + +## REST API + +- `POST /api/v1/back_office/claims/` — приём заявки от ЛК. + Валидирует, собирает `M2MTransferRequest` через + `m2mcore.EnrichRequest`, создаёт `m2mcore.Deal`, отправляет в НРД + через `m2mcore.NSDSender` (на M2 — mock). +- `GET /api/v1/back_office/claims/{id}` — карточка заявки. +- `GET /api/v1/back_office/claims` — список с фильтрами + (status/investor_id/created_from/created_to/limit/offset). +- `PATCH /api/v1/back_office/claims/{id}` — placeholder для callback + от внешней системы. +- `/healthz` — health. + +## Admin web UI + +- `/admin/` — дашборд: статус системы, счётчики (Total/Confirmed/ + InProgress/Failed), последние 10 заявок. +- `/admin/claims` — журнал всех заявок. +- `/admin/claims/{id}` — детальная карточка: история FSM, ответ НРД + (`M2MTransferResponse`), решение принимающей стороны + (`M2MTransferDecision`), последний callback. +- `/admin/status` — детальные проверки: postgres, crypto-service (UDS), + nsd-adapter (REST), lk-emulator callback URL. + +## Состав пакета + +- `server.go` — `Server` обвязка: HTTP mux + сервис + фоновый + consumeDecisions (читает из `mock.Sender.Decisions()` и обновляет + сделки + шлёт callback в ЛК). +- `service.go` — бизнес-логика: DTO ↔ доменные сущности m2mcore, + оркестрация FSM, отправка callback'ов. +- `api.go` — REST endpoints. +- `admin.go` — HTML endpoints с шаблонами в `web/templates/`. +- `checks.go` — проверки готовности подсистем (postgres, crypto-service, + nsd-adapter, callback URL). +- `seedstore.go` — in-memory `m2mcore.FansyStore` с 5 тестовыми + клиентами и счетами депо (соответствует + `docs/fansy-contract/v1/examples/seed-data.sql`). +- `types.go` — DTO под OpenAPI. +- `http_util.go` — JSON-хелперы. + +## Конфигурация (cmd/lk-gateway/main.go, ENV) + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `BJ_HTTP_ADDR` | `:8080` | Адрес HTTP | +| `BJ_M2M_SENDER` | `MC0079200000` | DeponentCode отправителя в M2M Header | +| `BJ_M2M_RECEIVER` | `MC0010300000` | DeponentCode получателя | +| `BJ_DSN` | — | PostgreSQL DSN (M2-шаг-3, пока пусто = in-memory) | +| `BJ_CRYPTO_SOCKET` | `/run/bj/crypto.sock` | UDS для crypto-service | +| `BJ_NSD_ADAPTER_URL` | — | URL nsd-adapter HTTP (пусто = mock) | +| `BJ_LK_CALLBACK_URL` | — | URL ЛК для PATCH callback'ов (пусто = эмулятор регистрирует свой) | +| `BJ_NSD_PROFILE` | `demo (mock NSD)` | Имя профиля (отображается в admin) | +| `BJ_CRYPTO_PROVIDER` | `stub` | Провайдер криптографии в admin-статусе | + +## Что подключается в следующих шагах + +- **M2-шаг-3**: pgx-репозиторий → миграция + `migrations/m2m-core/001__deals.sql` уже готова. +- **M3**: реальный `nsd-adapter` вместо mock — выставить + `BJ_NSD_ADAPTER_URL` и реализовать в nsd-adapter поллер, + отправляющий Decision не через канал mock, а через шину. +- **M4**: `admin-ui` v2 на React + раздел «Сертификаты КриптоПро» + для обновления публичных сертификатов через UI. diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go new file mode 100644 index 0000000..4e35c8f --- /dev/null +++ b/internal/lkgateway/admin.go @@ -0,0 +1,183 @@ +package lkgateway + +import ( + "embed" + "fmt" + "html/template" + "net/http" + "path" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +//go:embed web/templates/*.html +var templatesFS embed.FS + +// admin содержит по одному *template.Template на каждый view (layout + +// конкретный content-шаблон). Так html/template не путается с несколькими +// {{define "content"}} в разных файлах. +type admin struct { + home, claims, claim, status *template.Template +} + +func newAdmin() (*admin, error) { + parse := func(content string) (*template.Template, error) { + return template.ParseFS(templatesFS, + "web/templates/layout.html", + "web/templates/"+content) + } + home, err := parse("admin_home.html") + if err != nil { + return nil, fmt.Errorf("parse admin_home: %w", err) + } + claims, err := parse("admin_claims.html") + if err != nil { + return nil, fmt.Errorf("parse admin_claims: %w", err) + } + claim, err := parse("admin_claim.html") + if err != nil { + return nil, fmt.Errorf("parse admin_claim: %w", err) + } + status, err := parse("admin_status.html") + if err != nil { + return nil, fmt.Errorf("parse admin_status: %w", err) + } + return &admin{home: home, claims: claims, claim: claim, status: status}, nil +} + +// page — общий "конверт" данных для всех шаблонов. +type page struct { + Title string + Active string + Now string +} + +// homeData — данные дашборда. +type homeData struct { + page + Status SystemStatus + Counts struct { + Total int + Confirmed int + InProgress int + Failed int + } + Recent []ClaimView +} + +// claimsData — данные журнала. +type claimsData struct { + page + Items []ClaimView +} + +// claimData — данные карточки. +type claimData struct { + page + Claim ClaimView +} + +// statusData — данные страницы статуса. +type statusData struct { + page + Checks []Status + CheckedAt time.Time +} + +// RegisterAdmin вешает HTML-маршруты /admin/* на mux. +func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) error { + a, err := newAdmin() + if err != nil { + return err + } + + mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/admin/") + switch { + case p == "" || p == "index" || p == "home": + a.renderHome(w, r, svc, getOpts()) + case p == "claims": + a.renderClaims(w, r, svc) + case strings.HasPrefix(p, "claims/"): + id := strings.TrimPrefix(p, "claims/") + a.renderClaim(w, r, svc, id) + case p == "status": + a.renderStatus(w, r, getOpts()) + default: + http.NotFound(w, r) + } + }) + mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + }) + return nil +} + +func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) { + ctx := r.Context() + status := CheckAll(ctx, opts) + recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data := homeData{ + page: nowPage("Дашборд", "home"), + Status: status, + Recent: recent.Items, + } + full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200}) + if err == nil { + for _, c := range full.Items { + data.Counts.Total++ + switch c.Status { + case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone): + data.Counts.Confirmed++ + case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut): + data.Counts.Failed++ + default: + data.Counts.InProgress++ + } + } + } + render(w, a.home, data) +} + +func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) { + pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items}) +} + +func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) { + id = path.Base(id) + view, err := svc.GetClaim(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view}) +} + +func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) { + s := CheckAll(r.Context(), opts) + render(w, a.status, statusData{ + page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt, + }) +} + +func render(w http.ResponseWriter, t *template.Template, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func nowPage(title, active string) page { + return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")} +} diff --git a/internal/lkgateway/api.go b/internal/lkgateway/api.go new file mode 100644 index 0000000..4dd69b0 --- /dev/null +++ b/internal/lkgateway/api.go @@ -0,0 +1,102 @@ +package lkgateway + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// RegisterAPI вешает REST-маршруты ESIA Finance V1 на mux. +func RegisterAPI(mux *http.ServeMux, svc *Service) { + mux.HandleFunc("/api/v1/back_office/claims/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/back_office/claims/") + switch { + case path == "" && r.Method == http.MethodPost: + handleCreateClaim(w, r, svc) + case path != "" && r.Method == http.MethodGet: + handleGetClaim(w, r, svc, path) + case path != "" && r.Method == http.MethodPatch: + // PATCH без id — отдельный обработчик ниже; этот блок для PATCH + // с id, который lk-gateway сам себе бы посылал. На практике не + // используется (callback идёт в ЛК), но реализуем по контракту. + handlePatchClaim(w, r, svc, path) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Метод не разрешён", r.Method) + } + }) + + mux.HandleFunc("/api/v1/back_office/claims", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Метод не разрешён", r.Method) + return + } + handleListClaims(w, r, svc) + }) +} + +func handleCreateClaim(w http.ResponseWriter, r *http.Request, svc *Service) { + defer r.Body.Close() + var in CreateClaimRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Не смогли разобрать JSON", err.Error()) + return + } + out, err := svc.CreateClaim(r.Context(), in) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid_claim", "Заявка не прошла валидацию", err.Error()) + return + } + writeJSON(w, http.StatusCreated, out) +} + +func handleGetClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) { + view, err := svc.GetClaim(r.Context(), id) + if err != nil { + if errors.Is(err, m2mcore.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Заявка не найдена", id) + return + } + writeError(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка", err.Error()) + return + } + writeJSON(w, http.StatusOK, view) +} + +func handleListClaims(w http.ResponseWriter, r *http.Request, svc *Service) { + q := r.URL.Query() + filter := m2mcore.Filter{} + if s := q.Get("status"); s != "" { + st := m2mcore.State(s) + filter.State = &st + } + if inv := q.Get("investor_id"); inv != "" { + filter.InvestorID = inv + } + if l := q.Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 { + filter.Limit = n + } + } + if o := q.Get("offset"); o != "" { + if n, err := strconv.Atoi(o); err == nil && n >= 0 { + filter.Offset = n + } + } + page, err := svc.ListClaims(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка", err.Error()) + return + } + writeJSON(w, http.StatusOK, page) +} + +func handlePatchClaim(w http.ResponseWriter, _ *http.Request, _ *Service, _ string) { + // В сценарии M1-M2 PATCH /claims/{id} от внешней системы (как + // callback от НРД) не используется — мы сами шлём callback в ЛК. + // Но оставляем заглушку с 200, чтобы покрыть контракт OpenAPI. + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} diff --git a/internal/lkgateway/checks.go b/internal/lkgateway/checks.go new file mode 100644 index 0000000..fc8b080 --- /dev/null +++ b/internal/lkgateway/checks.go @@ -0,0 +1,163 @@ +package lkgateway + +import ( + "context" + "errors" + "net" + "net/http" + "os" + "time" +) + +// Status — состояние одной проверяемой подсистемы. +type Status struct { + Name string `json:"name"` + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// SystemStatus — все проверки. +type SystemStatus struct { + Profile string `json:"profile"` + Provider string `json:"crypto_provider"` + Checks []Status `json:"checks"` + CheckedAt time.Time `json:"checked_at"` +} + +// CheckOptions — что и как проверять. +type CheckOptions struct { + PostgresDSN string // если пусто — режим in-memory, проверки нет + CryptoSocket string // путь до UDS crypto-service + NSDAdapterURL string // например http://127.0.0.1:8082 + LKCallbackURL string // куда шлём callback (lk-emulator) + Profile string // имя профиля nsdadapter (guest-gost...) + CryptoProvider string // BJ_CRYPTO_PROVIDER (stub|cryptopro|...) + Timeout time.Duration // таймаут на одну проверку +} + +// CheckAll выполняет все доступные проверки и возвращает SystemStatus. +func CheckAll(ctx context.Context, o CheckOptions) SystemStatus { + if o.Timeout == 0 { + o.Timeout = 2 * time.Second + } + out := SystemStatus{ + Profile: o.Profile, + Provider: o.CryptoProvider, + CheckedAt: time.Now().UTC(), + } + + out.Checks = append(out.Checks, checkPostgres(ctx, o)) + out.Checks = append(out.Checks, checkCryptoSocket(o)) + out.Checks = append(out.Checks, checkNSDAdapter(ctx, o)) + out.Checks = append(out.Checks, checkLKCallback(ctx, o)) + + return out +} + +func checkPostgres(_ context.Context, o CheckOptions) Status { + s := Status{Name: "postgres"} + if o.PostgresDSN == "" { + s.OK = true + s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)" + return s + } + // На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка. + s.OK = false + s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)" + s.Detail = "DSN: " + o.PostgresDSN + return s +} + +func checkCryptoSocket(o CheckOptions) Status { + s := Status{Name: "crypto-service (UDS)"} + if o.CryptoSocket == "" { + s.OK = false + s.Message = "BJ_CRYPTO_SOCKET не задан" + return s + } + info, err := os.Stat(o.CryptoSocket) + if err != nil { + s.OK = false + s.Message = "сокет недоступен" + s.Detail = err.Error() + return s + } + if info.Mode()&os.ModeSocket == 0 { + s.OK = false + s.Message = "путь существует, но это не сокет" + s.Detail = o.CryptoSocket + return s + } + // Пробуем подключиться. + d := net.Dialer{Timeout: o.Timeout} + conn, err := d.Dial("unix", o.CryptoSocket) + if err != nil { + s.OK = false + s.Message = "сокет существует, но не отвечает" + s.Detail = err.Error() + return s + } + _ = conn.Close() + s.OK = true + s.Message = "сокет открыт" + s.Detail = o.CryptoSocket + if o.CryptoProvider == "stub" || o.CryptoProvider == "" { + s.Message += ", провайдер stub (реальная криптография не подключена)" + } else { + s.Message += ", провайдер " + o.CryptoProvider + } + return s +} + +func checkNSDAdapter(ctx context.Context, o CheckOptions) Status { + s := Status{Name: "nsd-adapter (REST к ИШ)"} + if o.NSDAdapterURL == "" { + s.OK = true + s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender" + return s + } + return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s) +} + +func checkLKCallback(ctx context.Context, o CheckOptions) Status { + s := Status{Name: "lk-emulator (callback)"} + if o.LKCallbackURL == "" { + s.OK = false + s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены" + return s + } + return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s) +} + +func httpHealth(ctx context.Context, url string, timeout time.Duration, s Status) Status { + c := &http.Client{Timeout: timeout} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + s.OK = false + s.Message = "не получилось собрать запрос" + s.Detail = err.Error() + return s + } + resp, err := c.Do(req) + if err != nil { + s.OK = false + s.Message = "недоступен" + s.Detail = err.Error() + return s + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + s.OK = false + s.Message = "HTTP " + http.StatusText(resp.StatusCode) + s.Detail = url + return s + } + s.OK = true + s.Message = "OK" + s.Detail = url + return s +} + +// ErrUnknown — общий placeholder. +var ErrUnknown = errors.New("lkgateway: unknown error") diff --git a/internal/lkgateway/http_util.go b/internal/lkgateway/http_util.go new file mode 100644 index 0000000..62069c9 --- /dev/null +++ b/internal/lkgateway/http_util.go @@ -0,0 +1,52 @@ +package lkgateway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// postJSON отправляет JSON через любой метод (POST/PATCH/PUT). Используется +// для callback'ов в ЛК. +func postJSON(ctx context.Context, c *http.Client, url, method string, body any) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + buf, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf)) + } + return nil +} + +// writeJSON удобный writer JSON-ответа со статусом. +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// writeError формирует ErrorResponse по контракту ESIA Finance. +func writeError(w http.ResponseWriter, status int, code, title, message string) { + writeJSON(w, status, ErrorResponse{ + Error: true, + Status: status, + Code: code, + Title: title, + Meta: &ErrorMeta{Message: message}, + }) +} diff --git a/internal/lkgateway/seedstore.go b/internal/lkgateway/seedstore.go new file mode 100644 index 0000000..df8c750 --- /dev/null +++ b/internal/lkgateway/seedstore.go @@ -0,0 +1,114 @@ +package lkgateway + +import ( + "context" + "fmt" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// SeedStore — in-memory FansyStore с предзаполненными тестовыми данными +// (5 клиентов с депо-счетами и портфелями), соответствующими +// docs/fansy-contract/v1/examples/seed-data.sql. Используется в +// dev-стенде без реальной БД. +type SeedStore struct { + clients map[string]*m2mcore.Client + accounts map[string][]m2mcore.DepoAccount // by client_id +} + +// NewSeedStore собирает SeedStore с фиксированными тестовыми клиентами. +func NewSeedStore() *SeedStore { + s := &SeedStore{ + clients: make(map[string]*m2mcore.Client), + accounts: make(map[string][]m2mcore.DepoAccount), + } + s.addClient("11111111-1111-1111-1111-111111111111", + "Иванов", "Иван", "Иванович", + m2m.DocCode21, "4512", "654321") + s.addClient("22222222-2222-2222-2222-222222222222", + "Петров", "Пётр", "Петрович", + m2m.DocCode21, "4513", "654322") + s.addClient("33333333-3333-3333-3333-333333333333", + "Сидоров", "Сидор", "Сидорович", + m2m.DocCode21, "4514", "654323") + s.addClient("44444444-4444-4444-4444-444444444444", + "Кузнецов", "Сергей", "Михайлович", + m2m.DocCode03, "111", "222333") + s.addClient("55555555-5555-5555-5555-555555555555", + "Соколова", "Анна", "Викторовна", + m2m.DocCode21, "4516", "654325") + + s.addAccount("11111111-1111-1111-1111-111111111111", + "DP789456", "31MC0021900000F01", "P001", "7702070139") + s.addAccount("11111111-1111-1111-1111-111111111111", + "AA789451", "33MC0021900000F02", "F002", "7802031669") + s.addAccount("22222222-2222-2222-2222-222222222222", + "DP100200", "31MC0010000000A01", "A001", "7702070139") + s.addAccount("33333333-3333-3333-3333-333333333333", + "DP300400", "31MC0030000000B01", "B001", "0702345678") + s.addAccount("55555555-5555-5555-5555-555555555555", + "DP500600", "31MC0050000000C01", "C001", "0710987654") + + return s +} + +// Clients возвращает копию слайса клиентов (для UI выбора). +func (s *SeedStore) Clients() []*m2mcore.Client { + out := make([]*m2mcore.Client, 0, len(s.clients)) + for _, c := range s.clients { + out = append(out, c) + } + return out +} + +func (s *SeedStore) addClient(id, last, first, middle string, doc m2m.IdentityDocumentCode, series, number string) { + s.clients[id] = &m2mcore.Client{ + ID: id, + LastName: last, + FirstName: first, + MiddleName: middle, + Document: m2mcore.ClientDocument{ + DocumentType: doc, + Series: series, + Number: number, + }, + } +} + +func (s *SeedStore) addAccount(clientID, dep, acc, sec, depINN string) { + s.accounts[clientID] = append(s.accounts[clientID], m2mcore.DepoAccount{ + ClientID: clientID, + DeponentCode: dep, + AccountID: m2m.AccountID(acc), + SectionID: sec, + DepositoryINN: m2m.OrganizationINN(depINN), + }) +} + +// GetClientByID — реализация FansyStore. +func (s *SeedStore) GetClientByID(_ context.Context, id string) (*m2mcore.Client, error) { + c, ok := s.clients[id] + if !ok { + return nil, fmt.Errorf("seedstore: клиент %s не найден", id) + } + return c, nil +} + +// GetDepoAccounts — реализация FansyStore. +func (s *SeedStore) GetDepoAccounts(_ context.Context, clientID string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) { + accs := s.accounts[clientID] + if len(accs) == 0 { + return nil, fmt.Errorf("seedstore: нет счетов у клиента %s", clientID) + } + return accs, nil +} + +// GetBalances — реализация FansyStore. На M2 возвращает пустой список, +// потому что баланс проверяется при подаче заявки в самой UI (через демо-кнопку). +func (s *SeedStore) GetBalances(_ context.Context, _ string, _ []m2m.SecurityCode) ([]m2mcore.SecurityBalance, error) { + return nil, nil +} + +// Verify тип SeedStore удовлетворяет m2mcore.FansyStore. +var _ m2mcore.FansyStore = (*SeedStore)(nil) diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go new file mode 100644 index 0000000..ee80dd0 --- /dev/null +++ b/internal/lkgateway/server.go @@ -0,0 +1,178 @@ +package lkgateway + +import ( + "context" + "errors" + "log" + "net/http" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock" +) + +// ServerConfig — конфигурация HTTP-сервера lk-gateway. +type ServerConfig struct { + Addr string + DefaultSender m2m.DeponentCode + DefaultReceiver m2m.DeponentCode + CheckOptions func() CheckOptions + MockDecisionDelay time.Duration // 0 = дефолт 3 секунды +} + +// Server — обвязка HTTP + сервис + workers. +type Server struct { + cfg ServerConfig + svc *Service + mock *mock.Sender + store *SeedStore + mux *http.ServeMux + server *http.Server +} + +// NewServer собирает Server с in-memory репозиторием, mock NSDSender, +// SeedStore и REST + Admin маршрутами. +func NewServer(cfg ServerConfig) (*Server, error) { + store := NewSeedStore() + mockCfg := mock.DefaultConfig() + mockCfg.NSDSenderCode = "MC0010300000" + if cfg.MockDecisionDelay > 0 { + mockCfg.DecisionDelay = cfg.MockDecisionDelay + } + sender := mock.NewSender(mockCfg) + + svc := NewService(Config{ + Repository: m2mcore.NewMemoryRepository(), + Sender: sender, + Store: store, + Recorder: m2mcore.NewMemoryRecorder(), + DefaultSender: cfg.DefaultSender, + DefaultReceiver: cfg.DefaultReceiver, + }) + + mux := http.NewServeMux() + RegisterAPI(mux, svc) + if cfg.CheckOptions == nil { + cfg.CheckOptions = func() CheckOptions { + return CheckOptions{Profile: "demo (mock NSD)", CryptoProvider: "stub"} + } + } + if err := RegisterAdmin(mux, svc, cfg.CheckOptions); err != nil { + return nil, err + } + registerHealth(mux) + registerSetCallback(mux, svc) + registerSeedListing(mux, store) + + return &Server{ + cfg: cfg, + svc: svc, + mock: sender, + store: store, + mux: mux, + server: &http.Server{ + Addr: cfg.Addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + }, + }, nil +} + +// SetCallbackURL обновляет адрес, куда отправлять PATCH callback'и в ЛК. +func (s *Server) SetCallbackURL(url string) { s.svc.callbackURL = url } + +// Service возвращает Service для тестов. +func (s *Server) Service() *Service { return s.svc } + +// Mock возвращает mock-сендер. +func (s *Server) Mock() *mock.Sender { return s.mock } + +// Store возвращает SeedStore. +func (s *Server) Store() *SeedStore { return s.store } + +// Mux возвращает обработчик (для httptest). +func (s *Server) Mux() http.Handler { return s.mux } + +// Run поднимает HTTP-сервер и фоновый Decisions-consumer. +// Блокируется до ctx.Done(). +func (s *Server) Run(ctx context.Context) error { + go s.consumeDecisions(ctx) + + errCh := make(chan error, 1) + go func() { + log.Printf("lk-gateway: listen %s", s.cfg.Addr) + errCh <- s.server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.server.Shutdown(shutdownCtx) + return nil + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки. +func (s *Server) consumeDecisions(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case d := <-s.mock.Decisions(): + if d == nil { + continue + } + if err := s.svc.ApplyDecision(ctx, d); err != nil { + log.Printf("lk-gateway: ApplyDecision GUID=%s: %v", d.Header.GUID, err) + } else { + log.Printf("lk-gateway: Decision применён GUID=%s, callback в %s", d.Header.GUID, s.svc.callbackURL) + } + } + } +} + +func registerHealth(mux *http.ServeMux) { + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) +} + +// registerSetCallback — служебный POST /admin/api/callback-url для +// эмулятора ЛК, чтобы сообщить gateway свой URL. +func registerSetCallback(mux *http.ServeMux, svc *Service) { + mux.HandleFunc("/admin/api/callback-url", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + url := r.URL.Query().Get("url") + if url == "" { + http.Error(w, "url required", http.StatusBadRequest) + return + } + svc.callbackURL = url + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) +} + +func registerSeedListing(mux *http.ServeMux, store *SeedStore) { + mux.HandleFunc("/admin/api/clients", func(w http.ResponseWriter, _ *http.Request) { + type c struct { + ID, LastName, FirstName, MiddleName string + } + out := make([]c, 0) + for _, cl := range store.Clients() { + out = append(out, c{ID: cl.ID, LastName: cl.LastName, FirstName: cl.FirstName, MiddleName: cl.MiddleName}) + } + writeJSON(w, http.StatusOK, out) + }) +} diff --git a/internal/lkgateway/server_test.go b/internal/lkgateway/server_test.go new file mode 100644 index 0000000..912b0d4 --- /dev/null +++ b/internal/lkgateway/server_test.go @@ -0,0 +1,255 @@ +package lkgateway_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway" +) + +func newServer(t *testing.T) *lkgateway.Server { + t.Helper() + srv, err := lkgateway.NewServer(lkgateway.ServerConfig{ + Addr: ":0", + DefaultSender: "MC0079200000", + DefaultReceiver: "MC0010300000", + MockDecisionDelay: 50 * time.Millisecond, + CheckOptions: func() lkgateway.CheckOptions { + return lkgateway.CheckOptions{Profile: "test", CryptoProvider: "stub"} + }, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + return srv +} + +func validBody() string { + return `{ + "investor": { + "id": "11111111-1111-1111-1111-111111111111", + "last_name": "Иванов", "first_name": "Иван", "middle_name": "Иванович", + "document": {"document_type": "21", "series": "4512", "number": "654321"} + }, + "transferring_depository_inn": "0702345678", + "receiving_depository_inn": "0710987654", + "cost_info": {"no": {}}, + "securities": [{ + "security_code": "MM0766162534", + "security_details": {"isin": "RU0007661625"}, + "quantity": {"whole": 1500}, + "settlement_accounts": [{ + "settlement_requisites_inn": "7702070139", + "settlement_location": { + "deponent_code": "DP789456", "account_id": "31MC0021900000F01", "section_id": "P001" + } + }] + }], + "signed_document": "dGVzdA==", + "signature_format": "XMLDSig-GOST" +}` +} + +func TestCreateAndGetClaim(t *testing.T) { + srv := newServer(t) + mux := srv.Mux() + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody())) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("POST claims: code=%d body=%s", w.Code, w.Body.String()) + } + var created lkgateway.CreateClaimResponse + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if created.ID == "" || !created.Success { + t.Errorf("unexpected create response: %+v", created) + } + + // GET + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/back_office/claims/"+created.ID, nil) + mux.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("GET claim: code=%d body=%s", w2.Code, w2.Body.String()) + } + var view lkgateway.ClaimView + if err := json.Unmarshal(w2.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + if view.ID != created.ID { + t.Errorf("view.ID = %s, ожидалось %s", view.ID, created.ID) + } +} + +func TestAdminHome(t *testing.T) { + srv := newServer(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("admin home code=%d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "lk-gateway") { + t.Errorf("в дашборде нет заголовка lk-gateway") + } + if !strings.Contains(body, "Состояние системы") { + t.Errorf("в дашборде нет блока статуса") + } +} + +func TestAdminStatus(t *testing.T) { + srv := newServer(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status code=%d", w.Code) + } + if !strings.Contains(w.Body.String(), "postgres") { + t.Errorf("в статусе нет проверки postgres") + } +} + +func TestEndToEndFlowWithMock(t *testing.T) { + srv := newServer(t) + // Уменьшим задержку mock для быстрого e2e. + // Не достаём её напрямую — пересоздадим Server со встроенными настройками + // и проверим только что после Send статус становится submitted_to_nsd → awaiting_decision. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = ctx + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody())) + req.Header.Set("Content-Type", "application/json") + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("POST claims: code=%d body=%s", w.Code, w.Body.String()) + } + var created lkgateway.CreateClaimResponse + _ = json.Unmarshal(w.Body.Bytes(), &created) + + if created.Status != "awaiting_decision" { + t.Errorf("после Submit ожидалось awaiting_decision, получено %s", created.Status) + } +} + +func TestCallbackURLSetter(t *testing.T) { + srv := newServer(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/api/callback-url?url=http://x.example/", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("set callback url: %d", w.Code) + } +} + +func TestListClaimsEmpty(t *testing.T) { + srv := newServer(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/back_office/claims", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("list claims empty: %d", w.Code) + } + var page lkgateway.ClaimsPage + _ = json.Unmarshal(w.Body.Bytes(), &page) + if len(page.Items) != 0 { + t.Errorf("ожидалась пустая страница, получено %d", len(page.Items)) + } +} + +func TestInvalidJSON(t *testing.T) { + srv := newServer(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", bytes.NewReader([]byte("not json"))) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("ожидался 400, получено %d", w.Code) + } +} + +func TestSeedClientsEndpoint(t *testing.T) { + srv := newServer(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/api/clients", nil) + srv.Mux().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("clients: %d", w.Code) + } + var clients []map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &clients) + if len(clients) < 5 { + t.Errorf("ожидалось 5+ клиентов в seed, получено %d", len(clients)) + } +} + +func TestE2EApplyDecisionFiresCallback(t *testing.T) { + // Поднимаем gateway in-process + http-эмулятор как callback-приёмник. + // Дальше: POST заявки → ждём Decision из mock-канала → вручную дёргаем + // ApplyDecision → проверяем что emulator получил callback. + gw := newServer(t) + + receivedCallback := make(chan map[string]any, 1) + emulator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/back_office/claims/") { + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + select { + case receivedCallback <- payload: + default: + } + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + })) + defer emulator.Close() + gw.SetCallbackURL(emulator.URL) + + // Подаём заявку через mux (без отдельного httptest.NewServer для gateway). + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/back_office/claims/", strings.NewReader(validBody())) + req.Header.Set("Content-Type", "application/json") + gw.Mux().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("POST claims: %d", w.Code) + } + + // Mock эмитит Decision через MockDecisionDelay (50мс). Дождёмся его и + // прокинем в ApplyDecision — этого делает фоновый воркер, который в + // этом тесте не запущен (Run не вызывается). + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + select { + case d := <-gw.Mock().Decisions(): + if err := gw.Service().ApplyDecision(ctx, d); err != nil { + t.Fatalf("ApplyDecision: %v", err) + } + case <-ctx.Done(): + t.Fatal("Decision из mock не пришёл") + } + + select { + case cb := <-receivedCallback: + status, _ := cb["new_status"].(string) + if status != "confirmed" { + t.Errorf("ожидался callback со статусом confirmed, получено %s", status) + } + case <-time.After(2 * time.Second): + t.Fatal("callback в эмулятор не пришёл") + } +} diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go new file mode 100644 index 0000000..6e5d4a6 --- /dev/null +++ b/internal/lkgateway/service.go @@ -0,0 +1,386 @@ +package lkgateway + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "sort" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности +// m2mcore, оркестрирует FSM сделки, эмитит callback'и в ЛК. +type Service struct { + repo m2mcore.Repository + sender m2mcore.NSDSender + store m2mcore.FansyStore + recorder *m2mcore.MemoryRecorder + defaultSender m2m.DeponentCode + defaultReceiver m2m.DeponentCode + callbackURL string + httpClient *http.Client + + mu sync.RWMutex + claimToID map[string]string // claim public ID -> internal deal ID +} + +// Config — параметры сервиса. +type Config struct { + Repository m2mcore.Repository + Sender m2mcore.NSDSender + Store m2mcore.FansyStore + Recorder *m2mcore.MemoryRecorder + DefaultSender m2m.DeponentCode + DefaultReceiver m2m.DeponentCode + CallbackURL string +} + +// NewService собирает сервис. +func NewService(cfg Config) *Service { + if cfg.Recorder == nil { + cfg.Recorder = m2mcore.NewMemoryRecorder() + } + return &Service{ + repo: cfg.Repository, + sender: cfg.Sender, + store: cfg.Store, + recorder: cfg.Recorder, + defaultSender: cfg.DefaultSender, + defaultReceiver: cfg.DefaultReceiver, + callbackURL: cfg.CallbackURL, + httpClient: &http.Client{Timeout: 5 * time.Second}, + claimToID: make(map[string]string), + } +} + +// CreateClaim принимает DTO заявки, формирует M2MTransferRequest, +// создаёт сделку и отправляет в НРД. +func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) { + domainClaim, err := dtoToClaim(in) + if err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err) + } + + req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{ + SenderCode: s.defaultSender, + ReceiverCode: s.defaultReceiver, + }) + if err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err) + } + + deal, err := m2mcore.NewDeal(req.Header.GUID, in.Investor.ID, []byte(in.SignedDocument)) + if err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err) + } + saved, err := s.repo.Create(ctx, deal) + if err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err) + } + + if err := saved.Validate(ctx, req); err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err) + } + + resp, err := s.sender.Send(ctx, req) + if err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err) + } + saved.Response = resp + + if err := saved.Submit(ctx); err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err) + } + if err := s.repo.Update(ctx, saved); err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err) + } + s.recorder.IncDeal(saved.State) + s.mu.Lock() + s.claimToID[saved.ID] = saved.ID + s.mu.Unlock() + + return CreateClaimResponse{ + ID: saved.ID, + Status: string(saved.State), + CreatedAt: saved.CreatedAt, + Success: true, + }, nil +} + +// GetClaim возвращает полную карточку заявки. +func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) { + deal, err := s.repo.GetByID(ctx, id) + if err != nil { + return ClaimView{}, err + } + return dealToView(deal), nil +} + +// ListClaims возвращает страницу заявок. +func (s *Service) ListClaims(ctx context.Context, filter m2mcore.Filter) (ClaimsPage, error) { + if filter.Limit == 0 { + filter.Limit = 50 + } + deals, err := s.repo.List(ctx, filter) + if err != nil { + return ClaimsPage{}, err + } + sort.Slice(deals, func(i, j int) bool { return deals[i].CreatedAt.After(deals[j].CreatedAt) }) + items := make([]ClaimView, 0, len(deals)) + for _, d := range deals { + items = append(items, dealToView(d)) + } + return ClaimsPage{Items: items, Total: len(items), Limit: filter.Limit, Offset: filter.Offset}, nil +} + +// ApplyDecision принимает Decision (из mock-NSDSender или реального адаптера), +// обновляет соответствующую сделку и шлёт callback в ЛК. +func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error { + if decision == nil { + return errors.New("lkgateway: ApplyDecision: decision=nil") + } + deal, err := s.repo.GetByGUID(ctx, decision.Header.GUID) + if err != nil { + return fmt.Errorf("lkgateway: GetByGUID: %w", err) + } + if err := deal.ReceiveDecision(ctx, decision); err != nil { + return fmt.Errorf("lkgateway: ReceiveDecision: %w", err) + } + if err := s.repo.Update(ctx, deal); err != nil { + return fmt.Errorf("lkgateway: repo.Update: %w", err) + } + s.recorder.IncDeal(deal.State) + + if s.callbackURL != "" { + s.sendCallback(ctx, deal) + } + return nil +} + +// sendCallback отправляет PATCH в ЛК с обновлением статуса. +func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) { + cb := callbackForDeal(deal) + if err := postJSON(ctx, s.httpClient, s.callbackURL+"/api/v1/back_office/claims/"+deal.ID, "PATCH", cb); err != nil { + log.Printf("lkgateway: callback в ЛК упал: %v", err) + } +} + +// Recorder возвращает экспонируемый Recorder для admin-страницы. +func (s *Service) Recorder() *m2mcore.MemoryRecorder { return s.recorder } + +// Repo возвращает Repository (для админских проверок). +func (s *Service) Repo() m2mcore.Repository { return s.repo } + +// Внутренние преобразования и хелперы. + +func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) { + out := m2mcore.ClaimInput{ + InvestorClientID: in.Investor.ID, + TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN), + ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN), + } + // CostInfo + if in.CostInfo.Yes != nil { + out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}} + } else { + out.CostInfo = m2m.CostInfo{No: &m2m.CostInfoNo{}} + } + // IIA + if in.IIAAgreement != nil { + out.IIAAgreement = &m2m.IIAAgreementDetails{ + AgreementType: m2m.IIAContractType(in.IIAAgreement.AgreementType), + AgreementNumber: in.IIAAgreement.AgreementNumber, + AgreementDate: in.IIAAgreement.AgreementDate, + BrokerINN: m2m.OrganizationINN(in.IIAAgreement.BrokerINN), + } + } + // Securities + for _, sec := range in.Securities { + ds, err := dtoSecurityDetails(sec.SecurityDetails) + if err != nil { + return m2mcore.ClaimInput{}, err + } + q, err := dtoQuantity(sec.Quantity) + if err != nil { + return m2mcore.ClaimInput{}, err + } + out.Securities = append(out.Securities, m2mcore.ClaimSecurityInput{ + SecurityCode: m2m.SecurityCode(sec.SecurityCode), + Details: ds, + Quantity: q, + }) + } + return out, nil +} + +func dtoSecurityDetails(in SecurityDetails) (m2m.SecurityDetails, error) { + if in.ISIN != "" { + isin := m2m.ISIN(in.ISIN) + return m2m.SecurityDetails{ISIN: &isin}, nil + } + if in.SecurityInfo != nil { + si := &m2m.SecurityDescription{ + SecurityClassification: m2m.SecurityClassification(in.SecurityInfo.Classification), + SecurityCategory: m2m.SecurityCategory(in.SecurityInfo.Category), + SecurityType: in.SecurityInfo.SecurityType, + SecuritySeries: in.SecurityInfo.SecuritySeries, + } + if in.SecurityInfo.IdentificationDetails.RegNumber != "" { + rn := in.SecurityInfo.IdentificationDetails.RegNumber + si.IdentificationDetails = m2m.IdentificationDetails{RegNumber: &rn} + } + if in.SecurityInfo.IdentificationDetails.FundShares != nil { + si.IdentificationDetails = m2m.IdentificationDetails{ + FundShares: &m2m.FundShares{ + RegNumber: in.SecurityInfo.IdentificationDetails.FundShares.RegNumber, + Class: in.SecurityInfo.IdentificationDetails.FundShares.Class, + }, + } + } + return m2m.SecurityDetails{SecurityInfo: si}, nil + } + return m2m.SecurityDetails{}, errors.New("lkgateway: SecurityDetails — задайте isin или security_info") +} + +func dtoQuantity(in Quantity) (m2m.Quantity, error) { + if in.Whole > 0 { + w := in.Whole + return m2m.Quantity{Whole: &w}, nil + } + if in.Fractional != "" { + f := m2m.Decimal16(in.Fractional) + return m2m.Quantity{Fractional: &f}, nil + } + return m2m.Quantity{}, errors.New("lkgateway: Quantity — задайте whole или fractional") +} + +func dealToView(d *m2mcore.Deal) ClaimView { + out := ClaimView{ + ID: d.ID, + Status: string(d.State), + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + M2MGUID: d.GUID, + } + for _, st := range d.Stages { + out.Stages = append(out.Stages, StageView{ + State: string(st.State), EnteredAt: st.EnteredAt, LeftAt: st.LeftAt, Reason: st.Reason, + }) + } + if d.Request != nil { + out.TransferringDepositoryINN = string(d.Request.Data.TransferringDepository.INN) + out.ReceivingDepositoryINN = string(d.Request.Data.ReceivingDepository.INN) + ii := d.Request.Data.InvestorInformation + out.Investor = Investor{ + LastName: ii.LastName, FirstName: ii.FirstName, MiddleName: ii.MiddleName, + Document: Document{ + DocumentType: string(ii.IdentityDocument.DocumentType), + Number: string(ii.IdentityDocument.DocumentNumber), + }, + } + if ii.IdentityDocument.DocumentSeries != nil { + out.Investor.Document.Series = string(*ii.IdentityDocument.DocumentSeries) + } + if d.Request.Header.CostInfo.Yes != nil { + out.CostInfo = CostInfo{Yes: &CostInfoYes{Code: string(d.Request.Header.CostInfo.Yes.Code)}} + } else if d.Request.Header.CostInfo.No != nil { + empty := struct{}{} + out.CostInfo = CostInfo{No: &empty} + } + if d.Request.Header.IIAAgreementDetails != nil { + out.IIAAgreement = &IIAAgreement{ + AgreementType: string(d.Request.Header.IIAAgreementDetails.AgreementType), + AgreementNumber: d.Request.Header.IIAAgreementDetails.AgreementNumber, + AgreementDate: d.Request.Header.IIAAgreementDetails.AgreementDate, + BrokerINN: string(d.Request.Header.IIAAgreementDetails.BrokerINN), + } + } + } + if d.Response != nil { + out.M2MResponse = responseToView(d.Response) + } + if d.Decision != nil { + out.M2MDecision = decisionToView(d.Decision) + } + if d.State != m2mcore.StateDraft { + cb := callbackForDeal(d) + out.LastCallback = &cb + } + return out +} + +func responseToView(r *m2m.M2MTransferResponse) *NSDResponseView { + v := &NSDResponseView{ + GUID: string(r.GUID), + StatusCode: string(r.StatusCode), + } + for _, e := range r.Responses { + ent := NSDResponseEntry{Code: e.Code, Text: e.Text} + if e.ReferenceID != nil { + ent.ReferenceID = string(*e.ReferenceID) + } + v.Responses = append(v.Responses, ent) + } + return v +} + +func decisionToView(d *m2m.M2MTransferDecision) *NSDDecisionView { + v := &NSDDecisionView{GUID: string(d.Header.GUID)} + for _, sec := range d.Data.Securities { + entry := NSDDecisionSecurity{ReferenceID: string(sec.ReferenceID)} + if sec.TransferDecision.Confirmation != nil { + entry.Outcome = "confirmed" + } else if sec.TransferDecision.Rejection != nil { + entry.Outcome = "rejected" + entry.RejectCodes = sec.TransferDecision.Rejection.Codes + } + v.Securities = append(v.Securities, entry) + } + return v +} + +func callbackForDeal(d *m2mcore.Deal) StatusCallback { + cb := StatusCallback{ + ClaimID: d.ID, + NewStatus: string(d.State), + UpdatedAt: d.UpdatedAt, + } + if d.Decision != nil { + cb.NSDResponse = nsdResponseFromDecision(d.Decision) + if d.State == m2mcore.StateRejected { + for _, sec := range d.Decision.Data.Securities { + if sec.TransferDecision.Rejection != nil && len(sec.TransferDecision.Rejection.Codes) > 0 { + cb.ReasonCode = sec.TransferDecision.Rejection.Codes[0] + cb.ReasonText = "Отказ принимающей стороны (mock)" + break + } + } + } + } else if d.Response != nil { + cb.NSDResponse = responseToView(d.Response) + } + return cb +} + +func nsdResponseFromDecision(d *m2m.M2MTransferDecision) *NSDResponseView { + v := &NSDResponseView{GUID: string(d.Header.GUID), StatusCode: string(m2m.StatusInfo)} + for _, sec := range d.Data.Securities { + ref := string(sec.ReferenceID) + ent := NSDResponseEntry{ReferenceID: ref} + if sec.TransferDecision.Confirmation != nil { + ent.Code = "01" + ent.Text = "Подтверждение принимающей стороны." + } else if sec.TransferDecision.Rejection != nil { + ent.Code = "07" + ent.Text = "Отказ принимающей стороны." + } + v.Responses = append(v.Responses, ent) + } + return v +} diff --git a/internal/lkgateway/types.go b/internal/lkgateway/types.go new file mode 100644 index 0000000..272623e --- /dev/null +++ b/internal/lkgateway/types.go @@ -0,0 +1,211 @@ +// Package lkgateway реализует REST API контракта ESIA Finance V1 +// (docs/lk-contract/v1/openapi.yaml) и admin web-интерфейс. +package lkgateway + +import ( + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +// CreateClaimRequest — DTO входа POST /api/v1/back_office/claims/. +type CreateClaimRequest struct { + Investor Investor `json:"investor"` + TransferringDepositoryINN string `json:"transferring_depository_inn"` + ReceivingDepositoryINN string `json:"receiving_depository_inn"` + CostInfo CostInfo `json:"cost_info"` + IIAAgreement *IIAAgreement `json:"iia_agreement,omitempty"` + Securities []ClaimSec `json:"securities"` + SignedDocument string `json:"signed_document"` + SignatureFormat string `json:"signature_format"` +} + +// Investor — анкета. +type Investor struct { + ID string `json:"id,omitempty"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name,omitempty"` + Document Document `json:"document"` +} + +// Document — удостоверение личности. +type Document struct { + DocumentType string `json:"document_type"` + Series string `json:"series,omitempty"` + Number string `json:"number"` +} + +// CostInfo — choice yes|no. +type CostInfo struct { + Yes *CostInfoYes `json:"yes,omitempty"` + No *struct{} `json:"no,omitempty"` +} + +// CostInfoYes — учёт ведётся, с кодом депонента-источника. +type CostInfoYes struct { + Code string `json:"code"` +} + +// IIAAgreement — реквизиты договора ИИС. +type IIAAgreement struct { + AgreementType string `json:"agreement_type"` + AgreementNumber string `json:"agreement_number"` + AgreementDate string `json:"agreement_date"` + BrokerINN string `json:"broker_inn"` +} + +// ClaimSec — одна ЦБ в заявке. +type ClaimSec struct { + SecurityCode string `json:"security_code"` + SecurityDetails SecurityDetails `json:"security_details"` + Quantity Quantity `json:"quantity"` + SettlementAccounts []SettlementAccount `json:"settlement_accounts"` +} + +// SecurityDetails — choice isin|security_info. +type SecurityDetails struct { + ISIN string `json:"isin,omitempty"` + SecurityInfo *SecurityInfo `json:"security_info,omitempty"` +} + +// SecurityInfo — описание ЦБ без ISIN. +type SecurityInfo struct { + Classification string `json:"classification"` + Category string `json:"category"` + SecurityType string `json:"security_type,omitempty"` + SecuritySeries string `json:"security_series,omitempty"` + IdentificationDetails IdentificationDetails `json:"identification_details"` +} + +// IdentificationDetails — choice reg_number|fund_shares. +type IdentificationDetails struct { + RegNumber string `json:"reg_number,omitempty"` + FundShares *FundShares `json:"fund_shares,omitempty"` +} + +// FundShares — ПИФ. +type FundShares struct { + RegNumber string `json:"reg_number"` + Class string `json:"class,omitempty"` +} + +// Quantity — choice whole|fractional. +type Quantity struct { + Whole uint64 `json:"whole,omitempty"` + Fractional string `json:"fractional,omitempty"` +} + +// SettlementAccount — реквизиты счёта. +type SettlementAccount struct { + SettlementRequisitesINN string `json:"settlement_requisites_inn"` + SettlementLocation SettlementLocation `json:"settlement_location"` +} + +// SettlementLocation — место хранения. +type SettlementLocation struct { + DeponentCode string `json:"deponent_code"` + AccountID string `json:"account_id"` + SectionID string `json:"section_id"` +} + +// CreateClaimResponse — DTO ответа POST. +type CreateClaimResponse struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + Success bool `json:"success"` +} + +// ClaimView — полная заявка с историей (GET и admin). +type ClaimView struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Investor Investor `json:"investor"` + TransferringDepositoryINN string `json:"transferring_depository_inn"` + ReceivingDepositoryINN string `json:"receiving_depository_inn"` + CostInfo CostInfo `json:"cost_info"` + IIAAgreement *IIAAgreement `json:"iia_agreement,omitempty"` + Securities []ClaimSec `json:"securities"` + LastCallback *StatusCallback `json:"last_callback,omitempty"` + Stages []StageView `json:"stages,omitempty"` + M2MGUID m2m.UUID `json:"m2m_guid,omitempty"` + M2MResponse *NSDResponseView `json:"m2m_response,omitempty"` + M2MDecision *NSDDecisionView `json:"m2m_decision,omitempty"` +} + +// StageView — точка истории FSM для UI. +type StageView struct { + State string `json:"state"` + EnteredAt time.Time `json:"entered_at"` + LeftAt *time.Time `json:"left_at,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// StatusCallback — callback статуса от lk-gateway к ЛК. +type StatusCallback struct { + ClaimID string `json:"claim_id"` + NewStatus string `json:"new_status"` + ReasonCode string `json:"reason_code,omitempty"` + ReasonText string `json:"reason_text,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + NSDResponse *NSDResponseView `json:"nsd_response,omitempty"` +} + +// NSDResponseView — сжатое представление M2MTransferResponse для UI/callback. +type NSDResponseView struct { + GUID string `json:"guid"` + StatusCode string `json:"status_code"` + Responses []NSDResponseEntry `json:"responses"` +} + +// NSDResponseEntry — одна запись Response. +type NSDResponseEntry struct { + ReferenceID string `json:"reference_id,omitempty"` + Code string `json:"code"` + Text string `json:"text,omitempty"` +} + +// NSDDecisionView — сжатое представление M2MTransferDecision для UI. +type NSDDecisionView struct { + GUID string `json:"guid"` + Securities []NSDDecisionSecurity `json:"securities"` +} + +// NSDDecisionSecurity — решение по одной ЦБ. +type NSDDecisionSecurity struct { + ReferenceID string `json:"reference_id"` + Outcome string `json:"outcome"` // "confirmed" | "rejected" + RejectCodes []string `json:"reject_codes,omitempty"` +} + +// ErrorResponse — формат ошибки, идентичный ESIA Finance. +type ErrorResponse struct { + Error bool `json:"error"` + Status int `json:"status"` + Code string `json:"code"` + Title string `json:"title"` + Meta *ErrorMeta `json:"meta,omitempty"` +} + +// ErrorMeta — детали ошибки. +type ErrorMeta struct { + Message string `json:"message,omitempty"` + Errors []FieldErrorDetail `json:"errors,omitempty"` +} + +// FieldErrorDetail — ошибка по конкретному полю. +type FieldErrorDetail struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ClaimsPage — постраничная выдача. +type ClaimsPage struct { + Items []ClaimView `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html new file mode 100644 index 0000000..51939cd --- /dev/null +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -0,0 +1,98 @@ +{{define "content"}} +
+

Заявка {{slice .Claim.ID 0 8}} · {{.Claim.Status}}

+ + + + + + + + + {{if .Claim.IIAAgreement}} + + {{end}} +
Создана{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}
Обновлена{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}
M2M GUID{{.Claim.M2MGUID}}
Инвестор{{.Claim.Investor.LastName}} {{.Claim.Investor.FirstName}} {{.Claim.Investor.MiddleName}}
Документтип {{.Claim.Investor.Document.DocumentType}}, серия {{.Claim.Investor.Document.Series}}, номер {{.Claim.Investor.Document.Number}}
Передающий депозитарийИНН {{.Claim.TransferringDepositoryINN}}
Принимающий депозитарийИНН {{.Claim.ReceivingDepositoryINN}}
ИИС{{.Claim.IIAAgreement.AgreementType}} № {{.Claim.IIAAgreement.AgreementNumber}} от {{.Claim.IIAAgreement.AgreementDate}}, брокер ИНН {{.Claim.IIAAgreement.BrokerINN}}
+
+ +
+

Ценные бумаги ({{len .Claim.Securities}})

+ + + + {{range .Claim.Securities}} + + + + + + + {{end}} + +
КодISIN / описаниеКоличествоСчетов депо
{{.SecurityCode}}{{if .SecurityDetails.ISIN}}{{.SecurityDetails.ISIN}}{{else if .SecurityDetails.SecurityInfo}}{{.SecurityDetails.SecurityInfo.Classification}} / {{.SecurityDetails.SecurityInfo.Category}}{{if .SecurityDetails.SecurityInfo.IdentificationDetails.FundShares}} · ПИФ {{.SecurityDetails.SecurityInfo.IdentificationDetails.FundShares.RegNumber}} класс {{.SecurityDetails.SecurityInfo.IdentificationDetails.FundShares.Class}}{{end}}{{end}}{{if .Quantity.Whole}}{{.Quantity.Whole}}{{else}}{{.Quantity.Fractional}}{{end}}{{len .SettlementAccounts}}
+
+ +
+

История FSM

+ + + + {{range .Claim.Stages}} + + + + + + + {{end}} + +
СостояниеВошлиВышлиПричина
{{.State}}{{.EnteredAt.Format "15:04:05.000"}}{{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}сейчас{{end}}{{.Reason}}
+
+ +{{if .Claim.M2MResponse}} +
+

Ответ НРД (M2MTransferResponse)

+

GUID {{.Claim.M2MResponse.GUID}} · Status {{.Claim.M2MResponse.StatusCode}}

+ + + + {{range .Claim.M2MResponse.Responses}} + + {{end}} + +
ReferenceIDКодТекст
{{.ReferenceID}}{{.Code}}{{.Text}}
+
+{{end}} + +{{if .Claim.M2MDecision}} +
+

Решение принимающей стороны (M2MTransferDecision)

+

GUID {{.Claim.M2MDecision.GUID}}

+ + + + {{range .Claim.M2MDecision.Securities}} + + + + + + {{end}} + +
ReferenceIDРешениеКоды отказа
{{.ReferenceID}}{{.Outcome}}{{range .RejectCodes}}{{.}} {{end}}
+
+{{end}} + +{{if .Claim.LastCallback}} +
+

Последний callback в ЛК

+ + + {{if .Claim.LastCallback.ReasonCode}} + + {{end}} + +
Статус{{.Claim.LastCallback.NewStatus}}
Код причины{{.Claim.LastCallback.ReasonCode}} {{.Claim.LastCallback.ReasonText}}
Время{{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}}
+
+{{end}} +{{end}} diff --git a/internal/lkgateway/web/templates/admin_claims.html b/internal/lkgateway/web/templates/admin_claims.html new file mode 100644 index 0000000..4a4d358 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_claims.html @@ -0,0 +1,27 @@ +{{define "content"}} +
+

Журнал заявок ({{len .Items}})

+ {{if .Items}} + + + + {{range .Items}} + + + + + + + + + + + + {{end}} + +
СозданаIDGUID M2MИнвесторЦБПередающийПринимающийСтатус
{{.CreatedAt.Format "02.01 15:04:05"}}{{slice .ID 0 8}}{{slice (printf "%s" .M2MGUID) 0 8}}{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.{{len .Securities}}{{.TransferringDepositoryINN}}{{.ReceivingDepositoryINN}}{{.Status}}детали →
+ {{else}} +

Пусто.

+ {{end}} +
+{{end}} diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html new file mode 100644 index 0000000..638775a --- /dev/null +++ b/internal/lkgateway/web/templates/admin_home.html @@ -0,0 +1,57 @@ +{{define "content"}} +
+
+
Всего сделок
+
{{.Counts.Total}}
+
+
+
Подтверждено
+
{{.Counts.Confirmed}}
+
+
+
В ожидании
+
{{.Counts.InProgress}}
+
+
+
Отказы / таймауты
+
{{.Counts.Failed}}
+
+
+ +
+

Состояние системы

+ {{range .Status.Checks}} +
+ + {{.Name}} — {{.Message}} + {{if .Detail}} · {{.Detail}}{{end}} +
+ {{end}} +
+ Профиль: {{.Status.Profile}} · Crypto-провайдер: {{.Status.Provider}} +
+
+ +
+

Последние заявки

+ {{if .Recent}} + + + + {{range .Recent}} + + + + + + + + + {{end}} + +
СозданаIDИнвесторЦБСтатус
{{.CreatedAt.Format "15:04:05"}}{{slice .ID 0 8}}{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.{{len .Securities}}{{.Status}}открыть →
+ {{else}} +

Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.

+ {{end}} +
+{{end}} diff --git a/internal/lkgateway/web/templates/admin_status.html b/internal/lkgateway/web/templates/admin_status.html new file mode 100644 index 0000000..c848cc8 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_status.html @@ -0,0 +1,30 @@ +{{define "content"}} +
+

Статус системы — детально

+ + + + {{range .Checks}} + + + + + + + + {{end}} + +
ПодсистемаСостояниеСообщениеДетали
{{.Name}}{{if .OK}}OK{{else}}FAIL{{end}}{{.Message}}{{.Detail}}
+

Проверка выполнена в {{.CheckedAt.Format "15:04:05 02.01.2006"}}.

+
+ +
+

Что подключается на следующих этапах

+ + + + + +
PostgreSQL (схема m2m_core)M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — migrations/m2m-core/001__deals.sql.
crypto-service · КриптоПро JCPM4: положить jcp.jar в services/crypto-service/libs/, выставить BJ_CRYPTO_PROVIDER=cryptopro, заполнить keystore профиля. Проверка — gRPC Health должна вернуть provider=cryptopro, ok=true.
nsd-adapter · ИШ НРДM3: установить ИШ, выставить BJ_NSD_PROFILE=guest-gost или иной, BJ_NSD_IGW_URL=http://localhost:8080. Без этого сейчас используется nsdadapter/mock с эмуляцией ответов через 3 сек.
Реальный ЛК (ESIA Finance)M4: согласовать docs/lk-contract/v1/openapi.yaml с командой ЛК, выставить BJ_LK_CALLBACK_URL на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.
+
+{{end}} diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html new file mode 100644 index 0000000..e16fec7 --- /dev/null +++ b/internal/lkgateway/web/templates/layout.html @@ -0,0 +1,58 @@ +{{define "layout"}} + + + +{{.Title}} · lk-gateway + + + +
+

lk-gateway

+ + {{.Now}} +
+
+{{template "content" .}} +
+ + +{{end}} diff --git a/internal/nsdadapter/mock/README.md b/internal/nsdadapter/mock/README.md new file mode 100644 index 0000000..495ca7b --- /dev/null +++ b/internal/nsdadapter/mock/README.md @@ -0,0 +1,59 @@ +# internal/nsdadapter/mock — заглушка NSDSender + +Имитирует Интеграционный шлюз НРД для локальных стендов и сквозных +тестов. Реализует интерфейс `m2mcore.NSDSender`: + +- **`Send`** — синхронно возвращает синтетический `M2MTransferResponse` + с `StatusCode=INFO` и записями `01` по каждой ЦБ. +- **`SendDecision`** — фиксирует и завершает (мы — отправители Decision). +- **Параллельно** — спавнит горутину, которая через + `Config.DecisionDelay` (по умолчанию 3 секунды) эмитит + `M2MTransferDecision` в канал `Decisions()`. + +## Исходы + +Через `Config.DefaultOutcome` или `SetOutcome(guid, ...)`: + +- `OutcomeConfirm` — Confirmation по всем ЦБ. +- `OutcomeReject` — Rejection с кодом `Config.RejectionCode` (по умолчанию `07`). +- `OutcomeTimeout` — Decision вообще не приходит (эмуляция SLA-таймаута). + +## Параметры + +```go +cfg := mock.DefaultConfig() +cfg.DecisionDelay = 1 * time.Second +cfg.DefaultOutcome = mock.OutcomeConfirm +sender := mock.NewSender(cfg) + +resp, _ := sender.Send(ctx, req) +// resp.StatusCode == "INFO" + +decision := <-sender.Decisions() +// Через ~1с прилетает Decision с Confirmation +``` + +## Подписка на эмитированные Decision + +```go +go func() { + for d := range sender.Decisions() { + _ = svc.ApplyDecision(ctx, d) + } +}() +``` + +В lk-gateway это делает `Server.consumeDecisions` (запускается в `Run`). + +## Контекст эмиссии + +Эмиссия Decision использует **внутренний контекст** mock'а (создаётся в +`NewSender` через `context.Background()`), а не контекст HTTP-запроса +вызывающего. Это критично — контекст HTTP-запроса закрывается сразу +после возврата ответа, и без своего lifecycle мок бы прерывал эмиссию +до истечения `DecisionDelay`. `Stop()` отменяет внутренний контекст. + +## Статистика + +`Stats()` возвращает счётчики `Sent`, `Confirmed`, `Rejected`, +`TimedOut` — для admin-страницы lk-gateway. diff --git a/internal/nsdadapter/mock/sender.go b/internal/nsdadapter/mock/sender.go new file mode 100644 index 0000000..4e89203 --- /dev/null +++ b/internal/nsdadapter/mock/sender.go @@ -0,0 +1,219 @@ +// Package mock — заглушка NSDSender для локальных стендов без реального +// Интеграционного шлюза НРД. Возвращает синтетические M2MTransferResponse +// (синхронно, сразу) и эмитит M2MTransferDecision (асинхронно, через +// настраиваемую задержку) для каждой отправленной заявки. +// +// Подходит для: +// - сквозного дев-теста (ЛК → m2m-core → mock → callback в ЛК); +// - демонстраций «увидеть как оно работает» без подключения к НРД; +// - юнит-тестов компонентов выше уровня транспорта. +package mock + +import ( + "context" + "errors" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// DecisionOutcome — что должен вернуть mock после задержки. +type DecisionOutcome int + +const ( + OutcomeConfirm DecisionOutcome = iota + OutcomeReject + OutcomeTimeout +) + +// Config — настройки mock-сендера. +type Config struct { + // DecisionDelay — задержка между Send и эмиссией Decision в канал. + DecisionDelay time.Duration + // DefaultOutcome — каким будет Decision, если не задан per-Request override. + DefaultOutcome DecisionOutcome + // RejectionCode — какой код отказа возвращать в OutcomeReject (6 chars max). + RejectionCode string + // SenderCode/ReceiverCode для эмитированной Decision Header (берётся из + // исходного Request обменом местами). + NSDSenderCode m2m.DeponentCode +} + +// DefaultConfig — разумные дефолты: подтверждение через 3 секунды. +func DefaultConfig() Config { + return Config{ + DecisionDelay: 3 * time.Second, + DefaultOutcome: OutcomeConfirm, + RejectionCode: "07", + } +} + +// Sender — mock реализация m2mcore.NSDSender. +type Sender struct { + cfg Config + decisions chan *m2m.M2MTransferDecision + outcomes sync.Map // map[m2m.UUID]DecisionOutcome — override per GUID + mu sync.Mutex + stats stats + lifeCtx context.Context // независимый от HTTP-запроса контекст для эмиссии Decision + lifeCancel context.CancelFunc +} + +type stats struct { + Sent uint64 + Confirmed uint64 + Rejected uint64 + TimedOut uint64 +} + +// NewSender создаёт mock с указанной конфигурацией. +func NewSender(cfg Config) *Sender { + ctx, cancel := context.WithCancel(context.Background()) + return &Sender{ + cfg: cfg, + decisions: make(chan *m2m.M2MTransferDecision, 64), + lifeCtx: ctx, + lifeCancel: cancel, + } +} + +// Stop отменяет внутренний контекст mock'а, останавливая все запущенные +// emit-горутины. +func (s *Sender) Stop() { s.lifeCancel() } + +// SetOutcome задаёт исход для конкретной заявки по GUID (вызывается до Send). +func (s *Sender) SetOutcome(guid m2m.UUID, out DecisionOutcome) { + s.outcomes.Store(guid, out) +} + +// Decisions — канал эмитированных от имени принимающей стороны Decision'ов. +// Подписаться, считать в горутине, передавать в m2mcore.Deal.ReceiveDecision. +func (s *Sender) Decisions() <-chan *m2m.M2MTransferDecision { return s.decisions } + +// Stats — текущие счётчики (для UI). +func (s *Sender) Stats() (sent, confirmed, rejected, timedOut uint64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.stats.Sent, s.stats.Confirmed, s.stats.Rejected, s.stats.TimedOut +} + +// Send имитирует отправку в НРД. Возвращает синтетический Response +// с StatusCode=INFO. Через DecisionDelay в канал прилетает Decision. +func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) { + if req == nil { + return nil, errors.New("nsdadapter/mock: Send: req=nil") + } + if err := req.Validate(); err != nil { + return nil, err + } + + s.mu.Lock() + s.stats.Sent++ + s.mu.Unlock() + + // Синтетический Response: принимаем заявку, по каждой ЦБ — INFO 01. + resp := &m2m.M2MTransferResponse{ + GUID: req.Header.GUID, + StatusCode: m2m.StatusInfo, + } + for _, sec := range req.Data.TransferredSecurities.Securities { + refID := sec.ReferenceID + resp.Responses = append(resp.Responses, m2m.Response{ + ReferenceID: &refID, + Code: "01", + Text: "Запрос на перевод принят НРД и направлен принимающей стороне (mock).", + }) + } + + // Эмитим Decision в отдельной горутине. Используем lifeCtx, чтобы + // HTTP-таймауты вызывающего не прерывали эмиссию. + outcome := s.cfg.DefaultOutcome + if v, ok := s.outcomes.Load(req.Header.GUID); ok { + outcome = v.(DecisionOutcome) + } + go s.emitDecision(s.lifeCtx, req, outcome) + _ = ctx + + return resp, nil +} + +// SendDecision имитирует отправку нашего Decision принимающей стороне (мы — реципиент). +// Mock просто фиксирует статистику. +func (s *Sender) SendDecision(_ context.Context, decision *m2m.M2MTransferDecision) error { + if decision == nil { + return errors.New("nsdadapter/mock: SendDecision: decision=nil") + } + if err := decision.Validate(); err != nil { + return err + } + s.mu.Lock() + s.stats.Sent++ + s.mu.Unlock() + return nil +} + +func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest, outcome DecisionOutcome) { + select { + case <-ctx.Done(): + return + case <-time.After(s.cfg.DecisionDelay): + } + + if outcome == OutcomeTimeout { + s.mu.Lock() + s.stats.TimedOut++ + s.mu.Unlock() + // При таймауте Decision не приходит вообще — это и эмулируем. + return + } + + decision := &m2m.M2MTransferDecision{ + Header: m2m.DecisionHeader{ + GUID: req.Header.GUID, + CreationTimestamp: nsdxml.Now(), + SenderCode: req.Header.ReceiverCode, + ReceiverCode: req.Header.SenderCode, + CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}}, + }, + Data: m2m.DecisionData{ + ReceivingDepository: req.Data.ReceivingDepository, + }, + } + for _, sec := range req.Data.TransferredSecurities.Securities { + ds := m2m.DecisionSecurity{ReferenceID: sec.ReferenceID} + if outcome == OutcomeConfirm { + ds.TransferDecision = m2m.DecisionTransfer{ + Confirmation: &m2m.Confirmation{ + SettlementAccount: sec.SettlementAccount[0], + }, + } + } else { + ds.TransferDecision = m2m.DecisionTransfer{ + Rejection: &m2m.Rejection{ + Codes: []string{s.cfg.RejectionCode}, + }, + } + } + decision.Data.Securities = append(decision.Data.Securities, ds) + } + + s.mu.Lock() + switch outcome { + case OutcomeConfirm: + s.stats.Confirmed++ + case OutcomeReject: + s.stats.Rejected++ + } + s.mu.Unlock() + + select { + case <-ctx.Done(): + case s.decisions <- decision: + } +} + +// Verify тип Sender удовлетворяет m2mcore.NSDSender. +var _ m2mcore.NSDSender = (*Sender)(nil) diff --git a/internal/nsdadapter/mock/sender_test.go b/internal/nsdadapter/mock/sender_test.go new file mode 100644 index 0000000..55f46ab --- /dev/null +++ b/internal/nsdadapter/mock/sender_test.go @@ -0,0 +1,140 @@ +package mock_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/mock" +) + +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 TestMockSendReturnsResponseAndEmitsConfirmation(t *testing.T) { + cfg := mock.DefaultConfig() + cfg.DecisionDelay = 20 * time.Millisecond + s := mock.NewSender(cfg) + + resp, err := s.Send(context.Background(), validRequest()) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != m2m.StatusInfo { + t.Errorf("StatusCode = %s, ожидалось INFO", resp.StatusCode) + } + if len(resp.Responses) != 1 { + t.Errorf("ожидалась 1 Response, получено %d", len(resp.Responses)) + } + + select { + case d := <-s.Decisions(): + if d.Data.Securities[0].TransferDecision.Confirmation == nil { + t.Errorf("ожидалось Confirmation, получено %+v", d.Data.Securities[0].TransferDecision) + } + case <-time.After(time.Second): + t.Fatal("Decision не пришёл в канал") + } +} + +func TestMockRejectOutcome(t *testing.T) { + cfg := mock.DefaultConfig() + cfg.DecisionDelay = 20 * time.Millisecond + cfg.DefaultOutcome = mock.OutcomeReject + s := mock.NewSender(cfg) + req := validRequest() + if _, err := s.Send(context.Background(), req); err != nil { + t.Fatal(err) + } + select { + case d := <-s.Decisions(): + if d.Data.Securities[0].TransferDecision.Rejection == nil { + t.Errorf("ожидалось Rejection") + } + case <-time.After(time.Second): + t.Fatal("Decision не пришёл") + } +} + +func TestMockTimeoutOutcome(t *testing.T) { + cfg := mock.DefaultConfig() + cfg.DecisionDelay = 20 * time.Millisecond + cfg.DefaultOutcome = mock.OutcomeTimeout + s := mock.NewSender(cfg) + if _, err := s.Send(context.Background(), validRequest()); err != nil { + t.Fatal(err) + } + select { + case <-s.Decisions(): + t.Error("при OutcomeTimeout Decision не должен прилетать") + case <-time.After(100 * time.Millisecond): + } + _, _, _, timedOut := s.Stats() + if timedOut != 1 { + t.Errorf("счётчик timeout = %d, ожидалось 1", timedOut) + } +} + +func TestMockSetOutcomeOverride(t *testing.T) { + cfg := mock.DefaultConfig() + cfg.DecisionDelay = 20 * time.Millisecond + cfg.DefaultOutcome = mock.OutcomeConfirm + s := mock.NewSender(cfg) + req := validRequest() + s.SetOutcome(req.Header.GUID, mock.OutcomeReject) + if _, err := s.Send(context.Background(), req); err != nil { + t.Fatal(err) + } + select { + case d := <-s.Decisions(): + if d.Data.Securities[0].TransferDecision.Rejection == nil { + t.Errorf("override не сработал, ожидалось Rejection") + } + case <-time.After(time.Second): + t.Fatal("Decision не пришёл") + } +}