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>
This commit is contained in:
fontvielle
2026-05-14 11:17:11 +03:00
parent e2720c09f7
commit c5695bf0b6
30 changed files with 3496 additions and 19 deletions
+102
View File
@@ -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})
}