diff --git a/internal/lkgateway/alerts.go b/internal/lkgateway/alerts.go index 39646c6..e7a6461 100644 --- a/internal/lkgateway/alerts.go +++ b/internal/lkgateway/alerts.go @@ -61,12 +61,18 @@ func sendSMTP(s SMTPSettings, subject, body string) error { if len(rcpts) == 0 { return fmt.Errorf("не заданы получатели (To)") } + return smtpDeliver(s, rcpts, buildMIME(s.From, rcpts, subject, body)) +} + +// smtpDeliver — транспорт SMTP: соединение, TLS, аутентификация и отправка +// готового MIME-сообщения. Выделено из sendSMTP для переиспользования в +// письмах с вложением (sendSMTPAttach, participant_form.go). +func smtpDeliver(s SMTPSettings, rcpts []string, msg []byte) error { port := s.Port if port == 0 { port = 587 } addr := net.JoinHostPort(s.Host, strconv.Itoa(port)) - msg := buildMIME(s.From, rcpts, subject, body) dialer := &net.Dialer{Timeout: 15 * time.Second} var client *smtp.Client diff --git a/internal/lkgateway/participant_form.go b/internal/lkgateway/participant_form.go new file mode 100644 index 0000000..b0a95bc --- /dev/null +++ b/internal/lkgateway/participant_form.go @@ -0,0 +1,355 @@ +package lkgateway + +// participant_form.go — анкета участника сервиса M2M (M2MTransferParticipantForm). +// Требование НРД при подключении: зарегистрировать нашу организацию в справочнике +// участников M2M, иначе сервис МОСТ отбивает запросы кодом M2M14 «Код ЭДО НРД +// отправителя отсутствует в справочнике участников M2M». Здесь bj формирует +// заполненную анкету (предзаполнение из автоопределения DEPO), даёт проверить +// (валидация + показ XML в windows-1251) и предлагает отправить её в НРД обычным +// каналом — письмом на M2MOST@nsd.ru с вложением (не через M2M-шлюз). + +import ( + "encoding/base64" + "fmt" + "html" + "net/http" + "regexp" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// participantFormRecipient — адрес НРД для регистрации участника M2M (обычный канал). +const participantFormRecipient = "M2MOST@nsd.ru" + +var pfINNRe = regexp.MustCompile(`^\d{10}$`) +var pfCodeRe = regexp.MustCompile(`^MC\d{10}$`) + +// participantFormData — поля анкеты (юридические реквизиты организации). +type participantFormData struct { + INN string + FullName string + ShortName string + DisplayName string + EngFull string + EngShort string + EngDisplay string + DepoCode string // DepositoryPlace/ParticipantCode (MC+10) + BrokerCode string // BrokerPlace/ParticipantCode (MC+10), опционально +} + +// participantFormPrefill — предзаполнение из автоопределения DEPO и настроек НРД. +func participantFormPrefill(rc *RuntimeConfig) participantFormData { + var d participantFormData + if r := depoDetectResult(); r != nil { + d.INN = strings.TrimSpace(r.OrgINN) + d.FullName = strings.TrimSpace(r.OrgName) + d.ShortName = strings.TrimSpace(r.OrgShort) + d.DisplayName = strings.TrimSpace(r.OrgShort) + d.EngFull = strings.TrimSpace(r.OrgEng) + if d.DepoCode == "" { + d.DepoCode = strings.TrimSpace(r.DeponentCode) + } + } + if d.DepoCode == "" && rc != nil { + d.DepoCode = strings.TrimSpace(rc.Snapshot().NSD.DeponentCode) + } + return d +} + +// participantFormValidate — проверка обязательных полей и форматов. Пустой срез — ок. +func participantFormValidate(d participantFormData) []string { + var errs []string + if !pfINNRe.MatchString(strings.TrimSpace(d.INN)) { + errs = append(errs, "ИНН: нужно 10 цифр (юридическое лицо)") + } + if strings.TrimSpace(d.FullName) == "" { + errs = append(errs, "Полное наименование: обязательно") + } + if strings.TrimSpace(d.DisplayName) == "" { + errs = append(errs, "Отображаемое наименование (DisplayName): обязательно") + } + if !pfCodeRe.MatchString(strings.TrimSpace(d.DepoCode)) { + errs = append(errs, "Код депозитария (DepositoryPlace): формат MC + 10 цифр") + } + if strings.TrimSpace(d.BrokerCode) != "" && !pfCodeRe.MatchString(strings.TrimSpace(d.BrokerCode)) { + errs = append(errs, "Код брокера (BrokerPlace): формат MC + 10 цифр (или оставьте пустым)") + } + // Структурная проверка типами НРД (ИНН/коды). + p := m2m.HandbookParticipant{ + INN: m2m.OrganizationINN(strings.TrimSpace(d.INN)), + Names: m2m.HandbookNames{Rus: m2m.HandbookRusName{FullName: d.FullName, ShortName: d.ShortName, DisplayName: d.DisplayName}}, + } + if c := strings.TrimSpace(d.DepoCode); c != "" { + p.DepositoryPlace = &m2m.DepositoryPlaces{ParticipantCode: m2m.DeponentCode(c)} + } + if c := strings.TrimSpace(d.BrokerCode); c != "" { + p.BrokerPlace = &m2m.BrokerPlaces{ParticipantCode: m2m.DeponentCode(c)} + } + if err := p.Validate(); err != nil && len(errs) == 0 { + errs = append(errs, "Проверка НРД: "+err.Error()) + } + return errs +} + +// pfx — экранирование текста XML-элемента (только & < >); кавычки в тексте +// оставляем литеральными, как в образце анкеты НРД. +var pfXMLEsc = strings.NewReplacer("&", "&", "<", "<", ">", ">") + +func pfx(s string) string { return pfXMLEsc.Replace(strings.TrimSpace(s)) } + +// participantFormXML — анкета в виде XML-строки (UTF-8) по образцу НРД +// (M2MTransferParticipantForm.xsd). CreationTimestamp — по МСК. +func participantFormXML(d participantFormData) string { + msk := time.FixedZone("МСК", 3*3600) + ts := time.Now().In(msk).Format("2006-01-02T15:04:05") + "(МСК)" + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + b.WriteString("\t" + ts + "\n") + b.WriteString("\t\n") + b.WriteString("\t\t" + pfx(d.INN) + "\n") + b.WriteString("\t\t\n") + b.WriteString("\t\t\t\n") + b.WriteString("\t\t\t\t" + pfx(d.FullName) + "\n") + if strings.TrimSpace(d.ShortName) != "" { + b.WriteString("\t\t\t\t" + pfx(d.ShortName) + "\n") + } + b.WriteString("\t\t\t\t" + pfx(d.DisplayName) + "\n") + b.WriteString("\t\t\t\n") + if strings.TrimSpace(d.EngFull) != "" || strings.TrimSpace(d.EngDisplay) != "" { + b.WriteString("\t\t\t\n") + b.WriteString("\t\t\t\t" + pfx(d.EngFull) + "\n") + if strings.TrimSpace(d.EngShort) != "" { + b.WriteString("\t\t\t\t" + pfx(d.EngShort) + "\n") + } + disp := d.EngDisplay + if strings.TrimSpace(disp) == "" { + disp = d.EngShort + } + b.WriteString("\t\t\t\t" + pfx(disp) + "\n") + b.WriteString("\t\t\t\n") + } + b.WriteString("\t\t\n") + b.WriteString("\t\t\n") + b.WriteString("\t\t\t" + pfx(d.DepoCode) + "\n") + b.WriteString("\t\t\n") + if strings.TrimSpace(d.BrokerCode) != "" { + b.WriteString("\t\t\n") + b.WriteString("\t\t\t" + pfx(d.BrokerCode) + "\n") + b.WriteString("\t\t\n") + } + b.WriteString("\t\n") + b.WriteString("\n") + return b.String() +} + +// participantFormBytes — анкета в windows-1251 (как требует НРД: кодировка из пролога). +func participantFormBytes(d participantFormData) ([]byte, error) { + return nsdxml.EncodeWindows1251([]byte(participantFormXML(d))) +} + +// participantEmailBody — текст письма на M2MOST@nsd.ru (обычный канал регистрации). +func participantEmailBody(d participantFormData) string { + var b strings.Builder + b.WriteString("Добрый день!\r\n\r\n") + b.WriteString("Просим зарегистрировать нашу организацию в справочнике участников сервиса M2M (МОСТ).\r\n") + b.WriteString("Заполненная анкета участника (M2MTransferParticipantForm) во вложении.\r\n\r\n") + b.WriteString("Реквизиты:\r\n") + b.WriteString(" - Депозитарный код участника: " + strings.TrimSpace(d.DepoCode) + "\r\n") + if strings.TrimSpace(d.BrokerCode) != "" { + b.WriteString(" - Брокерский код участника: " + strings.TrimSpace(d.BrokerCode) + "\r\n") + } + b.WriteString(" - Полное наименование: " + strings.TrimSpace(d.FullName) + "\r\n") + b.WriteString(" - ИНН: " + strings.TrimSpace(d.INN) + "\r\n\r\n") + b.WriteString("После регистрации просим подтвердить.\r\n\r\n") + b.WriteString("С уважением,\r\n" + strings.TrimSpace(d.ShortName) + "\r\n") + return b.String() +} + +// buildMIMEAttach — MIME multipart/mixed: текст письма (UTF-8) + вложение (base64). +func buildMIMEAttach(from string, to []string, subject, body, filename string, attach []byte, ctype string) []byte { + boundary := fmt.Sprintf("bjpart_%d", time.Now().UnixNano()) + var b strings.Builder + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + strings.Join(to, ", ") + "\r\n") + b.WriteString("Subject: " + encodeHeaderWord(subject) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n\r\n") + b.WriteString("--" + boundary + "\r\n") + b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") + b.WriteString(body + "\r\n") + b.WriteString("--" + boundary + "\r\n") + b.WriteString("Content-Type: " + ctype + "; name=\"" + filename + "\"\r\n") + b.WriteString("Content-Transfer-Encoding: base64\r\n") + b.WriteString("Content-Disposition: attachment; filename=\"" + filename + "\"\r\n\r\n") + enc := base64.StdEncoding.EncodeToString(attach) + for i := 0; i < len(enc); i += 76 { + j := i + 76 + if j > len(enc) { + j = len(enc) + } + b.WriteString(enc[i:j] + "\r\n") + } + b.WriteString("--" + boundary + "--\r\n") + return []byte(b.String()) +} + +// sendSMTPAttach — письмо с вложением (переиспользует транспорт smtpDeliver). +func sendSMTPAttach(s SMTPSettings, subject, body, filename string, attach []byte, ctype string) error { + if strings.TrimSpace(s.Host) == "" { + return fmt.Errorf("не задан хост SMTP") + } + if strings.TrimSpace(s.From) == "" { + return fmt.Errorf("не задан отправитель (From)") + } + rcpts := splitRecipients(s.To) + if len(rcpts) == 0 { + return fmt.Errorf("не заданы получатели (To)") + } + return smtpDeliver(s, rcpts, buildMIMEAttach(s.From, rcpts, subject, body, filename, attach, ctype)) +} + +// registerParticipantForm — страница «Регистрация участника M2M»: форма, проверка, +// скачивание анкеты, отправка письмом на M2MOST@nsd.ru. +func registerParticipantForm(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/admin/setup/participant-form", func(w http.ResponseWriter, r *http.Request) { + d := participantFormPrefill(rc) + action := "" + if r.Method == http.MethodPost { + _ = r.ParseForm() + action = r.FormValue("action") + d = participantFormData{ + INN: r.FormValue("inn"), FullName: r.FormValue("full"), ShortName: r.FormValue("short"), + DisplayName: r.FormValue("display"), EngFull: r.FormValue("eng_full"), EngShort: r.FormValue("eng_short"), + EngDisplay: r.FormValue("eng_display"), DepoCode: r.FormValue("depo"), BrokerCode: r.FormValue("broker"), + } + } + errs := participantFormValidate(d) + + if action == "download" { + if len(errs) > 0 { + http.Error(w, "анкета не прошла проверку: "+strings.Join(errs, "; "), http.StatusBadRequest) + return + } + body, err := participantFormBytes(d) + if err != nil { + http.Error(w, "кодировка windows-1251: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml; charset=windows-1251") + w.Header().Set("Content-Disposition", `attachment; filename="M2MTransferParticipantForm.xml"`) + _, _ = w.Write(body) + return + } + + var flash, flashKind string + if action == "send" { + to := strings.TrimSpace(r.FormValue("to")) + if to == "" { + to = participantFormRecipient + } + switch { + case len(errs) > 0: + flash, flashKind = "Анкета не прошла проверку — исправьте поля ниже.", "err" + default: + cfg := rc.Snapshot() + s := cfg.Alerts.SMTP + if !s.Enabled || strings.TrimSpace(s.Host) == "" { + flash, flashKind = "SMTP не настроен (раздел «Почта») — отправить письмо нельзя. Пока скачайте анкету и отправьте вручную.", "err" + break + } + s.To = to + body, encErr := participantFormBytes(d) + if encErr != nil { + flash, flashKind = "кодировка windows-1251: "+encErr.Error(), "err" + break + } + subject := "Регистрация участника сервиса M2M (МОСТ), код " + strings.TrimSpace(d.DepoCode) + if err := sendSMTPAttach(s, subject, participantEmailBody(d), "M2MTransferParticipantForm.xml", body, "application/xml; charset=windows-1251"); err != nil { + flash, flashKind = "письмо не отправлено: "+err.Error(), "err" + break + } + flash, flashKind = "Анкета отправлена письмом на "+to+" (с вложением). Ждём подтверждения регистрации от НРД.", "ok" + } + } + + xmlPreview := participantFormXML(d) + participantFormRender(w, d, errs, action, flash, flashKind, xmlPreview) + }) +} + +func participantFormRender(w http.ResponseWriter, d participantFormData, errs []string, action, flash, flashKind, xmlPreview string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + inp := func(name, label, val, ph string) string { + return `` + } + var vErr string + if (action == "preview" || action == "send") && len(errs) > 0 { + vErr = `
Проверка не пройдена:
` + } else if action == "preview" { + vErr = `
Проверка пройдена — анкета корректна.
` + } + var flashHTML string + if flash != "" { + cls := "ok" + if flashKind == "err" { + cls = "bad" + } + flashHTML = `
` + html.EscapeString(flash) + `
` + } + page := `Регистрация участника M2M + +

Регистрация участника сервиса M2M (МОСТ)

+

Требование НРД при подключении: зарегистрировать организацию в справочнике участников M2M. +Без регистрации сервис отбивает запросы кодом M2M14. Отправляется обычным каналом — письмом +на ` + participantFormRecipient + ` с вложенной анкетой (не через M2M-шлюз). Поля предзаполнены +из автоопределения DEPO — проверьте юридические реквизиты.

+` + flashHTML + vErr + ` +
+
Организация +` + inp("inn", "ИНН (10 цифр)", d.INN, "7700000000") + ` +` + inp("full", "Полное наименование (по уставу)", d.FullName, "") + ` +
` + inp("short", "Сокращённое", d.ShortName, "") + inp("display", "Отображаемое (DisplayName)", d.DisplayName, "") + `
+
+
Английские наименования (опционально) +` + inp("eng_full", "Full name", d.EngFull, "") + ` +
` + inp("eng_short", "Short name", d.EngShort, "") + inp("eng_display", "Display name", d.EngDisplay, "") + `
+
+
Коды участника (MC + 10 цифр) +
` + inp("depo", "Код депозитария (DepositoryPlace)", d.DepoCode, "MC0000000000") + inp("broker", "Код брокера (BrokerPlace, если брокер)", d.BrokerCode, "оставьте пустым, если не брокер") + `
+
+
Отправка +` + inp("to", "Кому (НРД)", participantFormRecipient, participantFormRecipient) + ` +
+
+ + + +
+
+

Предпросмотр анкеты (windows-1251)

+
` + html.EscapeString(xmlPreview) + `
+` + _, _ = w.Write([]byte(page)) +} + +func escapeAll(ss []string) []string { + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = html.EscapeString(s) + } + return out +} diff --git a/internal/lkgateway/participant_form_test.go b/internal/lkgateway/participant_form_test.go new file mode 100644 index 0000000..241309b --- /dev/null +++ b/internal/lkgateway/participant_form_test.go @@ -0,0 +1,100 @@ +package lkgateway + +import ( + "strings" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +func validPF() participantFormData { + return participantFormData{ + INN: "7703807489", FullName: `ООО "Фонтвьель"`, ShortName: "Фонтвьель", + DisplayName: "Фонтвьель", DepoCode: "MC0413600000", + } +} + +func TestParticipantFormValidate(t *testing.T) { + if errs := participantFormValidate(validPF()); len(errs) != 0 { + t.Fatalf("корректная анкета должна проходить, получили: %v", errs) + } + bad := validPF() + bad.INN = "770380748" // 9 цифр + if errs := participantFormValidate(bad); len(errs) == 0 { + t.Fatal("ИНН из 9 цифр должен отклоняться") + } + bad = validPF() + bad.DepoCode = "XX0413600000" + if errs := participantFormValidate(bad); len(errs) == 0 { + t.Fatal("код не в формате MC+10 должен отклоняться") + } + bad = validPF() + bad.DisplayName = "" + if errs := participantFormValidate(bad); len(errs) == 0 { + t.Fatal("пустой DisplayName должен отклоняться") + } + bad = validPF() + bad.BrokerCode = "MC12" // неверный формат + if errs := participantFormValidate(bad); len(errs) == 0 { + t.Fatal("неверный код брокера должен отклоняться") + } +} + +func TestParticipantFormXML(t *testing.T) { + x := participantFormXML(validPF()) + for _, want := range []string{ + `encoding="windows-1251"`, + `7703807489`, + ``, + `MC0413600000`, + `(МСК)`, + } { + if !strings.Contains(x, want) { + t.Errorf("XML должен содержать %q\n---\n%s", want, x) + } + } + if strings.Contains(x, "") { + t.Error("без англ. полей блок Eng не должен появляться") + } + if strings.Contains(x, "") { + t.Error("без кода брокера блок BrokerPlace не должен появляться") + } + // С англ. и брокером — блоки появляются. + d := validPF() + d.EngFull, d.BrokerCode = "Fontvielle LLC", "MC0413600001" + x2 := participantFormXML(d) + if !strings.Contains(x2, "") || !strings.Contains(x2, "Fontvielle LLC") { + t.Error("с англ. наименованием блок Eng обязателен") + } + if !strings.Contains(x2, "") || !strings.Contains(x2, "MC0413600001") { + t.Error("с кодом брокера блок BrokerPlace обязателен") + } +} + +func TestParticipantFormBytesCP1251(t *testing.T) { + b, err := participantFormBytes(validPF()) + if err != nil { + t.Fatal(err) + } + // Декод обратно из 1251 — кириллица восстанавливается. + got := string(nsdxml.DecodeWindows1251(b)) + if !strings.Contains(got, `ООО "Фонтвьель"`) { + t.Errorf("после cp1251 round-trip потеряна кириллица:\n%s", got) + } +} + +func TestBuildMIMEAttach(t *testing.T) { + msg := string(buildMIMEAttach("a@b.ru", []string{"M2MOST@nsd.ru"}, "Тема", "тело", "form.xml", []byte(""), "application/xml")) + for _, want := range []string{ + "multipart/mixed; boundary=", + "Content-Disposition: attachment; filename=\"form.xml\"", + "Content-Transfer-Encoding: base64", + "To: M2MOST@nsd.ru", + } { + if !strings.Contains(msg, want) { + t.Errorf("MIME должен содержать %q", want) + } + } +} diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 2d2af37..3ac5188 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -233,6 +233,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerSeedListing(mux, store) registerHandbookRequest(mux, igwClient, igwChannel) registerChainTest(mux, rc, igwClient != nil) // «Проверить всю цепочку» (мастер, глава «Запуск») + registerParticipantForm(mux, rc) // анкета участника M2M (регистрация в НРД, participant_form.go) return &Server{ cfg: cfg,