Files
fontvielle c5695bf0b6 feat(m2m): сквозной поток с веб-интерфейсами — lk-gateway BFF + admin UI + lk-emulator + mock NSD
Реализован M2-шаг-1: первый рабочий сквозной поток M2M-заявки от ЛК
через нашу систему и обратно, с двумя видимыми веб-интерфейсами.

internal/nsdadapter/mock/:
- mock NSDSender с реалистичным синтетическим Response и асинхронной
  эмиссией Decision через настраиваемую задержку (Confirm/Reject/Timeout)
- использует собственный жизненный цикл, чтобы HTTP-контексты вызывающего
  не прерывали эмиссию Decision до истечения DecisionDelay

internal/lkgateway/:
- REST по контракту ESIA Finance V1 (POST/GET/PATCH/list claims)
- admin web UI (/admin/, /admin/claims, /admin/claims/{id}, /admin/status):
  - дашборд со статусом подсистем (postgres, crypto-service UDS,
    nsd-adapter, lk-emulator callback) и счётчиками сделок
  - журнал и карточка заявки с историей FSM, ответом НРД, решением
    принимающей стороны и последним callback'ом
- in-memory SeedStore с 5 тестовыми клиентами и счетами депо
- фоновый consumeDecisions: подписан на mock.Sender.Decisions(),
  применяет ApplyDecision и отправляет PATCH callback в ЛК

internal/lkemulator/:
- имитация ЛК клиента (порт 8083)
- веб-формы: журнал, форма «новая заявка», карточка заявки
- HTTP-клиент к lk-gateway (создание заявки + регистрация callback URL)
- приёмник PATCH callback'ов, локальное хранилище заявок,
  автообновление страницы каждые 3 сек

cmd/lk-gateway/main.go и cmd/lk-emulator/main.go — заглушки заменены
на полные сервисы с graceful shutdown.

Сквозной поток проверен smoke-test'ом: подача заявки через форму
эмулятора → создание сделки в lk-gateway → Send в mock NSD →
эмиссия Decision через 3 сек → ApplyDecision → PATCH callback в ЛК →
эмулятор показывает confirmed. Дашборд lk-gateway: Total=1, Подтверждено=1.

make ci зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:17:11 +03:00

103 lines
3.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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})
}