9737c787f9
Инфраструктура M2M (живой обмен с НРД через ИШ): - обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение ответа; INFO → ждём Decision; идемпотентность поллера - fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO - сырой XML ответа НРД в карточке заявки (для пересылки в ТП) - тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes, 4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта - редирект из теста сразу в карточку заявки Мастер установки ключа Валидаты на флешку (admin/setup/keywizard): - пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник сертификатов (CRL) → перезапуск+проверка ИШ → готово - привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен, bj-server остаётся в песочнице - сохранение структуры профиля архива (spr<N>), перечисление съёмных USB Прочее: - пакет-доказательство для ТП НРД + форма регистрации участника M2M - эталонные образцы робота (DOC/m2m_robot_samples) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
10 KiB
Go
299 lines
10 KiB
Go
package m2mcore
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
|
)
|
|
|
|
// StageRecord — запись о входе в состояние FSM, для аудита и метрик.
|
|
type StageRecord struct {
|
|
State State
|
|
EnteredAt time.Time
|
|
LeftAt *time.Time
|
|
Reason string
|
|
}
|
|
|
|
// Event — доменное событие сделки (event sourcing для аудита).
|
|
type Event struct {
|
|
Type string
|
|
Payload any
|
|
CreatedAt time.Time
|
|
Actor string
|
|
}
|
|
|
|
// Deal — корневая агрегатная сущность M2M-сделки.
|
|
type Deal struct {
|
|
ID string
|
|
GUID m2m.UUID
|
|
State State
|
|
InvestorID string
|
|
SignedClaim []byte
|
|
Request *m2m.M2MTransferRequest
|
|
Response *m2m.M2MTransferResponse
|
|
RawResponse []byte // точные байты ответа МОСТ от НРД (для пересылки в ТП)
|
|
Decision *m2m.M2MTransferDecision
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Stages []StageRecord
|
|
|
|
mu sync.Mutex
|
|
events []Event
|
|
}
|
|
|
|
// NewDeal создаёт новую сделку в состоянии Draft.
|
|
func NewDeal(guid m2m.UUID, investorID string, signedClaim []byte) (*Deal, error) {
|
|
if err := guid.Validate(); err != nil {
|
|
return nil, fmt.Errorf("m2mcore: невалидный GUID при создании Deal: %w", err)
|
|
}
|
|
id, err := NewUUIDv4()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
return &Deal{
|
|
ID: id,
|
|
GUID: guid,
|
|
State: StateDraft,
|
|
InvestorID: investorID,
|
|
SignedClaim: signedClaim,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Stages: []StageRecord{{State: StateDraft, EnteredAt: now}},
|
|
}, nil
|
|
}
|
|
|
|
// Events возвращает накопленные события (копия слайса).
|
|
func (d *Deal) Events() []Event {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
out := make([]Event, len(d.events))
|
|
copy(out, d.events)
|
|
return out
|
|
}
|
|
|
|
// recordEvent добавляет событие в журнал сделки.
|
|
func (d *Deal) recordEvent(eventType string, payload any, actor string) {
|
|
d.events = append(d.events, Event{
|
|
Type: eventType, Payload: payload,
|
|
CreatedAt: time.Now().UTC(), Actor: actor,
|
|
})
|
|
}
|
|
|
|
// shiftTo переводит FSM в новое состояние, фиксируя историю.
|
|
// Должен вызываться под d.mu.Lock.
|
|
func (d *Deal) shiftTo(next State, reason string) error {
|
|
if err := transition(d.State, next); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
if i := len(d.Stages) - 1; i >= 0 {
|
|
d.Stages[i].LeftAt = &now
|
|
}
|
|
d.Stages = append(d.Stages, StageRecord{State: next, EnteredAt: now, Reason: reason})
|
|
d.State = next
|
|
d.UpdatedAt = now
|
|
return nil
|
|
}
|
|
|
|
// Validate переводит Draft -> Validated и фиксирует событие.
|
|
// Сама валидация Request делается m2m.M2MTransferRequest.Validate(),
|
|
// которое следует вызвать перед этим методом.
|
|
func (d *Deal) Validate(_ context.Context, request *m2m.M2MTransferRequest) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if request == nil {
|
|
return fmt.Errorf("m2mcore: Deal.Validate: request=nil")
|
|
}
|
|
if err := request.Validate(); err != nil {
|
|
return fmt.Errorf("m2mcore: M2MTransferRequest.Validate: %w", err)
|
|
}
|
|
d.Request = request
|
|
if err := d.shiftTo(StateValidated, ""); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("validated", request.Header.GUID, "system")
|
|
return nil
|
|
}
|
|
|
|
// Submit переводит Validated -> SubmittedToNSD после успешной отправки.
|
|
func (d *Deal) Submit(_ context.Context) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.shiftTo(StateSubmittedToNSD, ""); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("submitted_to_nsd", nil, "system")
|
|
if err := d.shiftTo(StateAwaitingDecision, ""); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("awaiting_decision", nil, "system")
|
|
return nil
|
|
}
|
|
|
|
// ReceiveDecision принимает M2MTransferDecision от принимающей стороны и
|
|
// меняет состояние на Confirmed или Rejected в зависимости от содержимого.
|
|
func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecision) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if decision == nil {
|
|
return fmt.Errorf("m2mcore: ReceiveDecision: decision=nil")
|
|
}
|
|
if err := decision.Validate(); err != nil {
|
|
return fmt.Errorf("m2mcore: M2MTransferDecision.Validate: %w", err)
|
|
}
|
|
d.Decision = decision
|
|
|
|
// Если все Security содержат Confirmation — Confirmed; если хотя бы
|
|
// одна Rejection — Rejected. Смешанные сценарии XSD НРД не запрещает,
|
|
// но на нашей стороне их трактуем как Rejected (требует ручного разбора).
|
|
rejected := false
|
|
for _, s := range decision.Data.Securities {
|
|
if s.TransferDecision.Rejection != nil {
|
|
rejected = true
|
|
break
|
|
}
|
|
}
|
|
target := StateConfirmed
|
|
reason := ""
|
|
if rejected {
|
|
target = StateRejected
|
|
reason = "decision_contains_rejection"
|
|
}
|
|
if err := d.shiftTo(target, reason); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("decision_received", decision.Header.GUID, "nsd")
|
|
return nil
|
|
}
|
|
|
|
// ReceiveServiceResponse принимает M2MTransferResponse — ответ сервиса МОСТ
|
|
// (НРД), а не контрагента. Это сервисный уровень: подтверждение приёма в
|
|
// обработку (StatusCode=INFO) либо отказ сервиса (StatusCode=ERROR, код
|
|
// M2Mxx — напр. M2M14 «код отправителя отсутствует в справочнике M2M»).
|
|
//
|
|
// - INFO — заявка принята МОСТ в обработку; состояние не меняем, ждём
|
|
// M2MTransferDecision контрагента. Сохраняем ответ для отображения.
|
|
// - ERROR — сервис отклонил заявку до контрагента; Decision уже не придёт,
|
|
// переводим сделку в Rejected. Сохраняем ответ, чтобы в карточке был
|
|
// виден код и текст отказа НРД.
|
|
// raw — точные байты ответа (unpacked doc.xml от НРД); сохраняются как есть
|
|
// для дословной пересылки в техподдержку НРД. Может быть nil (mock-режим).
|
|
func (d *Deal) ReceiveServiceResponse(_ context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if resp == nil {
|
|
return fmt.Errorf("m2mcore: ReceiveServiceResponse: resp=nil")
|
|
}
|
|
d.Response = resp
|
|
if len(raw) > 0 {
|
|
d.RawResponse = raw
|
|
}
|
|
d.UpdatedAt = time.Now().UTC()
|
|
|
|
if resp.StatusCode != m2m.StatusError {
|
|
// INFO/приём в обработку — фиксируем, но не двигаем FSM.
|
|
d.recordEvent("service_ack", responsePayload(resp), "nsd")
|
|
return nil
|
|
}
|
|
|
|
// ERROR — отказ сервиса МОСТ. Если сделка уже в терминальном состоянии,
|
|
// повторный ответ игнорируем (идемпотентность поллера).
|
|
if IsTerminal(d.State) {
|
|
d.recordEvent("service_error_ignored_terminal", responsePayload(resp), "nsd")
|
|
return nil
|
|
}
|
|
if err := d.shiftTo(StateRejected, "service_error:"+firstCode(resp)); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("service_error", responsePayload(resp), "nsd")
|
|
return nil
|
|
}
|
|
|
|
// responsePayload сворачивает M2MTransferResponse в компактную запись для
|
|
// журнала событий (коды + тексты ответов сервиса).
|
|
func responsePayload(resp *m2m.M2MTransferResponse) map[string]any {
|
|
codes := make([]map[string]string, 0, len(resp.Responses))
|
|
for _, r := range resp.Responses {
|
|
codes = append(codes, map[string]string{"code": r.Code, "text": r.Text})
|
|
}
|
|
return map[string]any{
|
|
"status": string(resp.StatusCode),
|
|
"guid": string(resp.GUID),
|
|
"codes": codes,
|
|
}
|
|
}
|
|
|
|
// firstCode возвращает первый код ответа (для reason перехода).
|
|
func firstCode(resp *m2m.M2MTransferResponse) string {
|
|
if len(resp.Responses) > 0 {
|
|
return resp.Responses[0].Code
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// Timeout переводит сделку в TimedOut (когда не дождались Decision).
|
|
func (d *Deal) Timeout(_ context.Context) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.shiftTo(StateTimedOut, "no_decision_within_sla"); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("timed_out", nil, "system")
|
|
return nil
|
|
}
|
|
|
|
// SendToManualApproval переводит на ручной разбор оператора.
|
|
func (d *Deal) SendToManualApproval(_ context.Context, reason string) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.shiftTo(StateManualApproval, reason); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("manual_approval_requested", reason, "system")
|
|
return nil
|
|
}
|
|
|
|
// ApproveManually вручную подтверждает сделку (с операторской подписью).
|
|
func (d *Deal) ApproveManually(_ context.Context, operator string) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.shiftTo(StateConfirmed, "manual_approve"); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("manual_approve", nil, operator)
|
|
return nil
|
|
}
|
|
|
|
// RejectManually вручную отказывает в сделке.
|
|
func (d *Deal) RejectManually(_ context.Context, operator, code, comment string) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.shiftTo(StateRejected, "manual_reject:"+code); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("manual_reject", map[string]string{"code": code, "comment": comment}, operator)
|
|
return nil
|
|
}
|
|
|
|
// CompleteSUB16 фиксирует получение SUB16 от НРД и переводит Confirmed
|
|
// -> AwaitingSUB16 -> Done. Может вызываться один раз.
|
|
func (d *Deal) CompleteSUB16(_ context.Context) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.State == StateConfirmed {
|
|
if err := d.shiftTo(StateAwaitingSUB16, ""); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("awaiting_sub16", nil, "nsd")
|
|
}
|
|
if err := d.shiftTo(StateDone, ""); err != nil {
|
|
return err
|
|
}
|
|
d.recordEvent("done", nil, "nsd")
|
|
return nil
|
|
}
|