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:
@@ -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")}
|
||||
}
|
||||
Reference in New Issue
Block a user