7ce4c2c79f
Реализация варианта B из аудита защиты знаний. Прямой garble всего bj-server ломает html/template (он обращается к полям структур по имени, garble их переименовывает). Решение — селективная обфускация: GOGARBLE обфусцирует ТОЛЬКО пакет с секретами, шаблоны и обработчики не затрагиваются. 95 SQL-запросов к DEPO/SPECTRE/QUIK и трансферным таблицам FTV_M2M вынесены из lkgateway в отдельный пакет internal/lkgateway/fsql как переменные (именно var, не const: const компилятор вставил бы обратно в необфусцируемый пакет, и шифрование бы не сработало — проверено). Сами обработчики, типы и параметры sql.Named остались на месте; пакет держит только строки запросов. build-release.sh собирает bj-server с `GOGARBLE=.../fsql garble -literals` (нужен отдельный SDK go1.26 — модуль-кэшный тулчейн garble не подходит); если garble/go1.26 не найдены — обычная сборка с предупреждением. Проверено на реальном garble-бинаре (запускается, /admin и страницы на структурах работают): рецепт чтения чужих систем из бинаря исчез — OD_RESTS 13→0, OD_WIRING 5→0, OD_TURNS 1→0, OD_BALANCES 3→0, D_B_CONTRACTS 1→0, OD_CLIENTS_PORTS 10→0, DepoLimits/MoneyLimits →0. Остаток FTV_M2M — упоминания имён таблиц в операторских embed-шаблонах и логах, не сам рецепт. Стенд собран обычной сборкой (без garble) — трассировки остаются читаемыми. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
272 lines
9.2 KiB
Go
272 lines
9.2 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Чтение РЕАЛЬНЫХ портфелей клиентов из депозитарной базы Fancy (BJ_DEPO_DATA).
|
|
// Только SELECT — боевая база учёта, ничего не меняем.
|
|
//
|
|
// Найденная цепочка связи:
|
|
//
|
|
// OD_CLIENTS_PORTS.PORT_NUM договор клиента («БР-1001 Гравс Геннадий»)
|
|
// ↓ CLIENT
|
|
// OD_CLIENTS.ID счёт депо клиента (В00000957 / ВТНКЦ00000957)
|
|
// ↓ = OD_RESTS.REG_1 ← ключ связи остатков со счётом
|
|
// OD_RESTS K = количество, BAL_ACC (836 рос. / 318 иностр.),
|
|
// BLOCK_DATE/BLOCK_REASON = блокировка
|
|
// ↓ VALUE_ID
|
|
// OD_VALUES бумага: NAME + ISIN (V_TYPE=2 — ценные бумаги)
|
|
//
|
|
// Важно: numeric-колонки читаем через CAST(... AS float) — иначе драйвер отдаёт 0.
|
|
|
|
// DepoPosition — позиция клиента по данным депозитарного учёта.
|
|
type DepoPosition struct {
|
|
Contract string // номер договора (PORT_NUM)
|
|
ClientName string // ФИО из договора
|
|
AccountNum string // счёт депо (OD_CLIENTS.NUMBER)
|
|
ISIN string //
|
|
SecurityName string //
|
|
Quantity float64 // остаток (OD_RESTS.K)
|
|
Blocked float64 // из них заблокировано (по BLOCK_REASON)
|
|
BlockReason string //
|
|
BalAcc int // балансовый счёт (836 — рос., 318 — иностр.)
|
|
}
|
|
|
|
// depoContractExists проверяет, есть ли такой договор в депозитарии.
|
|
func depoContractExists(ctx context.Context, s FancyMSSQLSettings, contract string) (string, bool) {
|
|
conn, err := fancyOpen(s, s.DepoDB())
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
defer conn.Close()
|
|
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
|
defer cancel()
|
|
var full string
|
|
err = conn.QueryRowContext(cctx,
|
|
fsql.QDb01,
|
|
contract, likeEscape(contract)+" %").Scan(&full)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return full, true
|
|
}
|
|
|
|
// depoClientPositions читает реальный портфель клиента по номеру договора.
|
|
// contract — «БР-1001» либо полная строка «БР-1001 Гравс Геннадий».
|
|
func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract string) ([]DepoPosition, error) {
|
|
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 := fsql.Dp01
|
|
rows, err := conn.QueryContext(cctx, q, contract, likeEscape(contract)+" %")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []DepoPosition
|
|
for rows.Next() {
|
|
var p DepoPosition
|
|
if err := rows.Scan(&p.Contract, &p.AccountNum, &p.ISIN, &p.SecurityName,
|
|
&p.Quantity, &p.BlockReason, &p.BalAcc); err != nil {
|
|
return out, err
|
|
}
|
|
// ФИО зашито в номер договора: «БР-1001 Гравс Геннадий»
|
|
if i := strings.Index(p.Contract, " "); i > 0 {
|
|
p.ClientName = strings.TrimSpace(p.Contract[i:])
|
|
}
|
|
if p.BlockReason != "" {
|
|
p.Blocked = p.Quantity
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// depoPositionsAsClient переводит позиции депозитария в вид, который отдаётся в
|
|
// Личный кабинет (агрегируя одинаковые бумаги по разным счетам клиента).
|
|
func depoPositionsAsClient(pos []DepoPosition) []ClientPosition {
|
|
type agg struct {
|
|
p ClientPosition
|
|
accts map[string]bool
|
|
}
|
|
byISIN := map[string]*agg{}
|
|
var order []string
|
|
for _, d := range pos {
|
|
key := strings.ToUpper(d.ISIN)
|
|
a, ok := byISIN[key]
|
|
if !ok {
|
|
a = &agg{p: ClientPosition{
|
|
ISIN: d.ISIN,
|
|
SecurityCode: d.ISIN,
|
|
SecurityName: d.SecurityName,
|
|
ClientName: d.ClientName,
|
|
}, accts: map[string]bool{}}
|
|
byISIN[key] = a
|
|
order = append(order, key)
|
|
}
|
|
a.p.Quantity += d.Quantity
|
|
a.p.Blocked += d.Blocked
|
|
if d.AccountNum != "" && !a.accts[d.AccountNum] {
|
|
a.accts[d.AccountNum] = true
|
|
if a.p.AccountID == "" {
|
|
a.p.AccountID = d.AccountNum
|
|
} else {
|
|
a.p.AccountID += ", " + d.AccountNum
|
|
}
|
|
}
|
|
}
|
|
out := make([]ClientPosition, 0, len(order))
|
|
for _, k := range order {
|
|
a := byISIN[k]
|
|
a.p.Available = a.p.Quantity - a.p.Blocked
|
|
a.p.UpdatedAt = time.Now().Format("02.01.2006 15:04")
|
|
out = append(out, a.p)
|
|
}
|
|
// Иностранные бумаги видны в портфеле, но к переводу недоступны.
|
|
return markTransferable(out)
|
|
}
|
|
|
|
// DepoContract — реальный клиент депозитария для списка в админке ЛК.
|
|
type DepoContract struct {
|
|
Contract string `json:"contract"` // «БР-1001 Гравс Геннадий»
|
|
ClientName string `json:"name"` // ФИО (если есть в номере договора)
|
|
Securities int `json:"securities"` // сколько бумаг с ненулевым остатком
|
|
}
|
|
|
|
// depoListContracts — реальные клиенты депозитария с ненулевыми остатками.
|
|
// search — часть номера договора или ФИО (пусто = первые limit по алфавиту).
|
|
func depoListContracts(ctx context.Context, s FancyMSSQLSettings, search string, limit int) ([]DepoContract, error) {
|
|
conn, err := fancyOpen(s, s.DepoDB())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
q := fmt.Sprintf(fsql.Dp02, limit)
|
|
rows, err := conn.QueryContext(cctx, q, search, "%"+likeEscape(search)+"%")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []DepoContract
|
|
for rows.Next() {
|
|
var c DepoContract
|
|
if err := rows.Scan(&c.Contract, &c.Securities); err != nil {
|
|
return out, err
|
|
}
|
|
if i := strings.Index(c.Contract, " "); i > 0 {
|
|
c.ClientName = strings.TrimSpace(c.Contract[i:])
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// depoFindContracts ищет договоры клиентов по части номера/ФИО (для админки).
|
|
func depoFindContracts(ctx context.Context, s FancyMSSQLSettings, like string, limit int) ([]string, error) {
|
|
conn, err := fancyOpen(s, s.DepoDB())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
rows, err := conn.QueryContext(cctx, fmt.Sprintf(
|
|
fsql.QDb02, limit),
|
|
"%"+like+"%")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var s string
|
|
if err := rows.Scan(&s); err != nil {
|
|
return out, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// likeEscape — экранирование шаблона LIKE. Номер договора приходит от клиента, и
|
|
// знаки % _ [ в нём должны означать сами себя, а не подстановку: иначе договор
|
|
// вида «БР-1%» совпал бы с чужими счетами.
|
|
func likeEscape(v string) string {
|
|
r := strings.NewReplacer("[", "[[]", "%", "[%]", "_", "[_]")
|
|
return r.Replace(v)
|
|
}
|
|
|
|
// ---- подсказки в поиске клиентов (админка ЛК) ----
|
|
|
|
// Список договоров нужен на каждой загрузке страницы «Клиенты»: браузер сам
|
|
// показывает выпадающий список по мере ввода (<datalist>), без скриптов на
|
|
// странице. Запрос дешёвый, но их 1300+, поэтому держим ответ в памяти.
|
|
var depoSuggestCache = struct {
|
|
mu sync.Mutex
|
|
list []string
|
|
at time.Time
|
|
}{}
|
|
|
|
// depoSuggestContracts — все номера договоров депозитария для подсказок поиска.
|
|
func depoSuggestContracts(ctx context.Context, s FancyMSSQLSettings) []string {
|
|
if strings.TrimSpace(s.Server) == "" {
|
|
return nil
|
|
}
|
|
depoSuggestCache.mu.Lock()
|
|
if time.Since(depoSuggestCache.at) < 10*time.Minute && depoSuggestCache.list != nil {
|
|
list := depoSuggestCache.list
|
|
depoSuggestCache.mu.Unlock()
|
|
return list
|
|
}
|
|
depoSuggestCache.mu.Unlock()
|
|
conn, err := fancyOpen(s, s.DepoDB())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer conn.Close()
|
|
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
rows, err := conn.QueryContext(cctx,
|
|
fsql.QDb03)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var v string
|
|
if rows.Scan(&v) != nil {
|
|
break
|
|
}
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
if rows.Err() != nil && len(out) == 0 {
|
|
return nil
|
|
}
|
|
depoSuggestCache.mu.Lock()
|
|
depoSuggestCache.list, depoSuggestCache.at = out, time.Now()
|
|
depoSuggestCache.mu.Unlock()
|
|
return out
|
|
}
|