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,98 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{.Claim.Status}}</span></h2>
|
||||
<table>
|
||||
<tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
|
||||
<tr><td class="muted">Обновлена</td><td>{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
|
||||
<tr><td class="muted">M2M GUID</td><td><code>{{.Claim.M2MGUID}}</code></td></tr>
|
||||
<tr><td class="muted">Инвестор</td><td>{{.Claim.Investor.LastName}} {{.Claim.Investor.FirstName}} {{.Claim.Investor.MiddleName}}</td></tr>
|
||||
<tr><td class="muted">Документ</td><td>тип {{.Claim.Investor.Document.DocumentType}}, серия {{.Claim.Investor.Document.Series}}, номер {{.Claim.Investor.Document.Number}}</td></tr>
|
||||
<tr><td class="muted">Передающий депозитарий</td><td><code>ИНН {{.Claim.TransferringDepositoryINN}}</code></td></tr>
|
||||
<tr><td class="muted">Принимающий депозитарий</td><td><code>ИНН {{.Claim.ReceivingDepositoryINN}}</code></td></tr>
|
||||
{{if .Claim.IIAAgreement}}
|
||||
<tr><td class="muted">ИИС</td><td>{{.Claim.IIAAgreement.AgreementType}} № {{.Claim.IIAAgreement.AgreementNumber}} от {{.Claim.IIAAgreement.AgreementDate}}, брокер ИНН {{.Claim.IIAAgreement.BrokerINN}}</td></tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Ценные бумаги ({{len .Claim.Securities}})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Код</th><th>ISIN / описание</th><th>Количество</th><th>Счетов депо</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.Securities}}
|
||||
<tr>
|
||||
<td><code>{{.SecurityCode}}</code></td>
|
||||
<td>{{if .SecurityDetails.ISIN}}<code>{{.SecurityDetails.ISIN}}</code>{{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}}</td>
|
||||
<td>{{if .Quantity.Whole}}{{.Quantity.Whole}}{{else}}{{.Quantity.Fractional}}{{end}}</td>
|
||||
<td>{{len .SettlementAccounts}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>История FSM</h2>
|
||||
<table>
|
||||
<thead><tr><th>Состояние</th><th>Вошли</th><th>Вышли</th><th>Причина</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.Stages}}
|
||||
<tr>
|
||||
<td><span class="badge {{.State}}">{{.State}}</span></td>
|
||||
<td>{{.EnteredAt.Format "15:04:05.000"}}</td>
|
||||
<td>{{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}<span class="muted">сейчас</span>{{end}}</td>
|
||||
<td class="muted">{{.Reason}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .Claim.M2MResponse}}
|
||||
<div class="card">
|
||||
<h2>Ответ НРД (M2MTransferResponse)</h2>
|
||||
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.M2MResponse.Responses}}
|
||||
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Claim.M2MDecision}}
|
||||
<div class="card">
|
||||
<h2>Решение принимающей стороны (M2MTransferDecision)</h2>
|
||||
<p class="muted">GUID <code>{{.Claim.M2MDecision.GUID}}</code></p>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceID</th><th>Решение</th><th>Коды отказа</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.M2MDecision.Securities}}
|
||||
<tr>
|
||||
<td><code>{{.ReferenceID}}</code></td>
|
||||
<td><span class="badge {{if eq .Outcome "confirmed"}}confirmed{{else}}rejected{{end}}">{{.Outcome}}</span></td>
|
||||
<td>{{range .RejectCodes}}<code>{{.}}</code> {{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Claim.LastCallback}}
|
||||
<div class="card">
|
||||
<h2>Последний callback в ЛК</h2>
|
||||
<table>
|
||||
<tr><td style="width:160px" class="muted">Статус</td><td><span class="badge {{.Claim.LastCallback.NewStatus}}">{{.Claim.LastCallback.NewStatus}}</span></td></tr>
|
||||
{{if .Claim.LastCallback.ReasonCode}}
|
||||
<tr><td class="muted">Код причины</td><td><code>{{.Claim.LastCallback.ReasonCode}}</code> {{.Claim.LastCallback.ReasonText}}</td></tr>
|
||||
{{end}}
|
||||
<tr><td class="muted">Время</td><td>{{.Claim.LastCallback.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Журнал заявок ({{len .Items}})</h2>
|
||||
{{if .Items}}
|
||||
<table>
|
||||
<thead><tr><th>Создана</th><th>ID</th><th>GUID M2M</th><th>Инвестор</th><th>ЦБ</th><th>Передающий</th><th>Принимающий</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td><code>{{slice .ID 0 8}}</code></td>
|
||||
<td><code>{{slice (printf "%s" .M2MGUID) 0 8}}</code></td>
|
||||
<td>{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.</td>
|
||||
<td>{{len .Securities}}</td>
|
||||
<td><code>{{.TransferringDepositoryINN}}</code></td>
|
||||
<td><code>{{.ReceivingDepositoryINN}}</code></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td><a href="/admin/claims/{{.ID}}">детали →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пусто.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,57 @@
|
||||
{{define "content"}}
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Всего сделок</div>
|
||||
<div class="stat-value">{{.Counts.Total}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Подтверждено</div>
|
||||
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">В ожидании</div>
|
||||
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Отказы / таймауты</div>
|
||||
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Состояние системы</h2>
|
||||
{{range .Status.Checks}}
|
||||
<div style="padding: 6px 0">
|
||||
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
|
||||
<strong>{{.Name}}</strong> — {{.Message}}
|
||||
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="muted" style="margin-top: 12px">
|
||||
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Последние заявки</h2>
|
||||
{{if .Recent}}
|
||||
<table>
|
||||
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Recent}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "15:04:05"}}</td>
|
||||
<td><code>{{slice .ID 0 8}}</code></td>
|
||||
<td>{{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}.</td>
|
||||
<td>{{len .Securities}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td><a href="/admin/claims/{{.ID}}">открыть →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Статус системы — детально</h2>
|
||||
<table>
|
||||
<thead><tr><th></th><th>Подсистема</th><th>Состояние</th><th>Сообщение</th><th>Детали</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Checks}}
|
||||
<tr>
|
||||
<td><span class="dot {{if .OK}}ok{{else}}err{{end}}"></span></td>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td>{{if .OK}}<span style="color: var(--ok)">OK</span>{{else}}<span style="color: var(--err)">FAIL</span>{{end}}</td>
|
||||
<td>{{.Message}}</td>
|
||||
<td><code>{{.Detail}}</code></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top: 16px">Проверка выполнена в {{.CheckedAt.Format "15:04:05 02.01.2006"}}.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Что подключается на следующих этапах</h2>
|
||||
<table>
|
||||
<tr><td class="muted" style="width:240px">PostgreSQL (схема m2m_core)</td><td>M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — <code>migrations/m2m-core/001__deals.sql</code>.</td></tr>
|
||||
<tr><td class="muted">crypto-service · КриптоПро JCP</td><td>M4: положить <code>jcp.jar</code> в <code>services/crypto-service/libs/</code>, выставить <code>BJ_CRYPTO_PROVIDER=cryptopro</code>, заполнить keystore профиля. Проверка — gRPC Health должна вернуть <code>provider=cryptopro, ok=true</code>.</td></tr>
|
||||
<tr><td class="muted">nsd-adapter · ИШ НРД</td><td>M3: установить ИШ, выставить <code>BJ_NSD_PROFILE=guest-gost</code> или иной, <code>BJ_NSD_IGW_URL=http://localhost:8080</code>. Без этого сейчас используется <code>nsdadapter/mock</code> с эмуляцией ответов через 3 сек.</td></tr>
|
||||
<tr><td class="muted">Реальный ЛК (ESIA Finance)</td><td>M4: согласовать <code>docs/lk-contract/v1/openapi.yaml</code> с командой ЛК, выставить <code>BJ_LK_CALLBACK_URL</code> на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{.Title}} · lk-gateway</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b94a3; --accent:#5b9dff; --ok:#3fbf6c; --warn:#e8b13a; --err:#e85a5a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); }
|
||||
header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 24px; }
|
||||
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
header nav a { color: var(--muted); text-decoration: none; margin-right: 16px; font-size: 14px; }
|
||||
header nav a:hover, header nav a.active { color: var(--text); }
|
||||
main { padding: 24px; max-width: 1280px; margin: 0 auto; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin-bottom: 16px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.stat { padding: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; margin-top: 4px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.err { background: var(--err); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
tr:hover td { background: rgba(91,157,255,0.05); }
|
||||
a { color: var(--accent); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background: rgba(91,157,255,0.15); color: #5b9dff; }
|
||||
.badge.awaiting_decision { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background: rgba(63,191,108,0.15); color: var(--ok); }
|
||||
.badge.rejected, .badge.timed_out { background: rgba(232,90,90,0.15); color: var(--err); }
|
||||
.badge.manual_approval { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
code { background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
pre { background: #0a0c10; border: 1px solid var(--border); border-radius: 4px; padding: 12px; font-size: 12px; overflow: auto; max-height: 400px; }
|
||||
button, .btn { background: var(--accent); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
button:hover, .btn:hover { opacity: .9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>lk-gateway</h1>
|
||||
<nav>
|
||||
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
|
||||
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
|
||||
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
|
||||
</nav>
|
||||
<span class="muted" style="margin-left:auto">{{.Now}}</span>
|
||||
</header>
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user