Files
Bridge-and-Join-s/internal/lkgateway/lkdocs.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:47:10 +03:00

279 lines
11 KiB
Go

package lkgateway
// lkdocs.go — корпоративные действия по бумагам клиента и документы депозитария:
// готовые выписки из учётной системы (U_OP_INF_EXTRACT) плюс заявки клиента на
// документ, которые кабинет кладёт в схему lk и показывает сотрудникам.
//
// Кабинет не создаёт документы в учётной системе: заявка — это поручение сотруднику,
// он готовит документ у себя и выкладывает клиенту.
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// LKCorpAction — корпоративное действие по бумаге клиента.
type LKCorpAction struct {
FixDate string `json:"fix_date,omitempty"` // дата фиксации
OperDate string `json:"oper_date,omitempty"` // дата операции
Kind string `json:"kind"` // расшифровка вида
Code string `json:"code,omitempty"` // код вида в учёте
ISIN string `json:"isin,omitempty"`
Security string `json:"security,omitempty"`
Note string `json:"note,omitempty"`
}
// caKindRu — вид корпоративного действия по коду учёта (коды ISO 20022).
func caKindRu(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "DVCA":
return "дивиденды"
case "INTR":
return "выплата купона"
case "REDM":
return "погашение"
case "MCAL":
return "досрочное погашение"
case "PCAL":
return "частичное погашение"
case "BPUT":
return "оферта: предъявление к выкупу"
case "TEND":
return "добровольное предложение о выкупе"
case "MEET", "XMET", "OMET":
return "собрание владельцев"
case "CONV":
return "конвертация"
case "SPLF", "SPLR":
return "дробление или консолидация"
case "MRGR":
return "реорганизация"
case "EXOF":
return "обмен бумаг"
case "DRCA":
return "выплата по депозитарной расписке"
}
return "корпоративное действие"
}
// lkCorpActions — ближайшие корпоративные действия по бумагам клиента.
func lkCorpActions(ctx context.Context, s FancyMSSQLSettings, isins []string) ([]LKCorpAction, error) {
if strings.TrimSpace(s.Server) == "" || len(isins) == 0 {
return nil, nil
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// Список ISIN подставляем параметрами: значения приходят из портфеля клиента.
names := make([]string, 0, len(isins))
args := make([]any, 0, len(isins))
for i, isin := range isins {
names = append(names, fmt.Sprintf("@p%d", i+1))
args = append(args, sql.Named(fmt.Sprintf("p%d", i+1), strings.ToUpper(strings.TrimSpace(isin))))
}
q := fmt.Sprintf(`SELECT TOP 40 ISNULL(a.KD_TYPE,''), ISNULL(v.ISIN,''), ISNULL(v.NAME,''), a.FIX_DATE, a.OPER_DATE, ISNULL(a.OFFER_NAME,'')
FROM U_CA_ACTS a JOIN OD_VALUES v ON v.ID = a.SHARE
WHERE UPPER(v.ISIN) IN (%s)
AND ISNULL(a.FIX_DATE, a.OPER_DATE) >= DATEADD(month, -1, GETDATE())
ORDER BY ISNULL(a.FIX_DATE, a.OPER_DATE)`, strings.Join(names, ","))
rows, err := conn.QueryContext(cctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKCorpAction
for rows.Next() {
var code, isin, name, offer string
var fix, oper sql.NullTime
if rows.Scan(&code, &isin, &name, &fix, &oper, &offer) != nil {
continue
}
a := LKCorpAction{Code: strings.TrimSpace(code), Kind: caKindRu(code), ISIN: isin, Security: name, Note: strings.TrimSpace(offer)}
if fix.Valid && fix.Time.Year() < 9000 {
a.FixDate = fix.Time.Format("02.01.2006")
}
if oper.Valid && oper.Time.Year() > 2000 && oper.Time.Year() < 9000 {
a.OperDate = oper.Time.Format("02.01.2006")
}
out = append(out, a)
}
return out, rows.Err()
}
// LKDocument — документ депозитария, готовый или заказанный клиентом.
type LKDocument struct {
ID int64 `json:"id,omitempty"`
Date string `json:"date"` // дата готовности или заказа
Title string `json:"title"` // что за документ
Period string `json:"period,omitempty"` // за какой период
Account string `json:"account,omitempty"`
State string `json:"state"` // готов | в работе | отклонён
Note string `json:"note,omitempty"` // комментарий депозитария
Ordered bool `json:"ordered"` // заявка клиента (а не готовый документ учёта)
Kind string `json:"kind,omitempty"` // вид заявки
}
// lkDocSchema — таблица заявок на документы (создаётся вместе со схемой lk).
const lkDocSchema = `
CREATE TABLE IF NOT EXISTS lk.doc_requests (
id bigserial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
contract_number text NOT NULL,
client_name text,
kind text NOT NULL,
period_from date,
period_to date,
account text,
comment text,
status text NOT NULL DEFAULT 'new',
staff_note text,
done_at timestamptz
);
CREATE INDEX IF NOT EXISTS ix_lk_doc_requests_contract ON lk.doc_requests (contract_number, created_at DESC);
`
// lkDocKinds — виды документов, которые клиент может заказать в кабинете.
var lkDocKinds = []string{
"Выписка по счёту депо",
"Справка об остатках на дату",
"Отчёт об операциях за период",
"Справка о доходах",
}
// lkDocKindAllowed — вид заявки из списка (чужие значения не принимаем).
func lkDocKindAllowed(kind string) bool {
for _, k := range lkDocKinds {
if k == kind {
return true
}
}
return false
}
// lkDocRequestAdd — заявка клиента на документ.
func lkDocRequestAdd(ctx context.Context, rc *RuntimeConfig, contract, clientName, kind, from, to, account, comment string) error {
if !lkDocKindAllowed(kind) {
return fmt.Errorf("неизвестный вид документа")
}
db, err := lkDB(rc)
if err != nil {
return err
}
var fromP, toP any
if t, err := time.Parse("2006-01-02", strings.TrimSpace(from)); err == nil {
fromP = t
}
if t, err := time.Parse("2006-01-02", strings.TrimSpace(to)); err == nil {
toP = t
}
_, err = db.ExecContext(ctx, `INSERT INTO lk.doc_requests (contract_number, client_name, kind, period_from, period_to, account, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, contract, clientName, kind, fromP, toP, strings.TrimSpace(account), strings.TrimSpace(comment))
return err
}
// lkDocRequests — заявки клиента на документы.
func lkDocRequests(ctx context.Context, rc *RuntimeConfig, contract string) ([]LKDocument, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, `SELECT id, created_at, kind, period_from, period_to, COALESCE(account,''), status, COALESCE(staff_note,'')
FROM lk.doc_requests WHERE contract_number=$1 ORDER BY created_at DESC LIMIT 50`, contract)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKDocument
for rows.Next() {
var d LKDocument
var created time.Time
var from, to *time.Time
var status string
if err := rows.Scan(&d.ID, &created, &d.Kind, &from, &to, &d.Account, &status, &d.Note); err != nil {
continue
}
d.Ordered, d.Title, d.Date = true, d.Kind, created.Format("02.01.2006")
switch status {
case "done":
d.State = "готов"
case "rejected":
d.State = "отклонён"
default:
d.State = "в работе"
}
if from != nil && to != nil {
d.Period = from.Format("02.01.2006") + " — " + to.Format("02.01.2006")
}
out = append(out, d)
}
return out, rows.Err()
}
// lkReadyDocuments — готовые выписки по счетам клиента из учётной системы.
func lkReadyDocuments(ctx context.Context, s FancyMSSQLSettings, contract string) ([]LKDocument, error) {
if strings.TrimSpace(s.Server) == "" {
return nil, nil
}
ids, _, accounts, err := depoClientIDs(ctx, s, contract)
if err != nil || len(ids) == 0 {
return nil, err
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
q := fmt.Sprintf(`SELECT TOP 30 d.R_DATE, ISNULL(d.NUM,''), e.B_DATE, e.E_DATE, e.DEPO, d.STATE, ISNULL(e.IS_TURNS,0)
FROM U_OP_INF_EXTRACT e JOIN OD_DOCS d ON d.ID = e.DOC
WHERE e.DEPO IN (%s) ORDER BY d.R_DATE DESC`, inClause(ids))
rows, err := conn.QueryContext(cctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKDocument
for rows.Next() {
var rd, bd, ed sql.NullTime
var num string
var acc int64
var state, turns sql.NullInt64
if rows.Scan(&rd, &num, &bd, &ed, &acc, &state, &turns) != nil {
continue
}
d := LKDocument{Title: "Выписка по счёту депо", Account: accounts[acc], State: docStateRu(state, sql.NullTime{}), Note: num}
if turns.Valid && turns.Int64 == 1 {
d.Title = "Выписка по счёту депо с оборотами"
}
if rd.Valid {
d.Date = rd.Time.Format("02.01.2006")
}
if bd.Valid && ed.Valid {
d.Period = bd.Time.Format("02.01.2006") + " — " + ed.Time.Format("02.01.2006")
} else if ed.Valid {
d.Period = "на " + ed.Time.Format("02.01.2006")
}
out = append(out, d)
}
return out, rows.Err()
}
// notifyDocRequest — сообщить сотрудникам депозитария, что клиент заказал документ.
func notifyDocRequest(rc *RuntimeConfig, u LKUser, kind, from, to string) {
period := ""
if from != "" || to != "" {
period = fmt.Sprintf(" за период %s — %s", pick(from, "…"), pick(to, "…"))
}
go raiseSystemAlert(rc, "Кабинет: заказан документ",
fmt.Sprintf("Клиент %s (договор %s) заказал документ «%s»%s. Заявка видна в кабинете клиента и в журнале lk.doc_requests.",
pick(u.FullName, u.Login), u.Contract, kind, period))
}