Files
Bridge-and-Join-s/internal/lkgateway/lksnapshot.go
T
zuevav 7ce4c2c79f Защита рецептов: SQL DEPO/SPECTRE/QUIK вынесен в пакет fsql под garble
Реализация варианта 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
2026-09-18 20:29:47 +03:00

217 lines
7.4 KiB
Go

package lkgateway
// lksnapshot.go — ежедневный срез стоимости портфелей для графика в кабинете.
//
// Ни QUIK, ни SPECTRE не хранят историю стоимости: экспорт QUIK показывает позиции
// «сейчас», SPECTRE — остатки на дату, но переоценивать каждый день задним числом
// дорого и всё равно неточно (котировки прошлых дней в копии не полны). Поэтому раз
// в день, после закрытия торгов, считаем стоимость каждого портфеля по экспорту QUIK
// и складываем одну строку на договор в lk.portfolio_daily. Из этих строк кабинет
// рисует график: чем дольше работает система, тем длиннее история.
import (
"context"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"log"
"strings"
"time"
)
var lkSnapshotSchema = fsql.Sn01
// lkSnapshotEnabled — ведём ли историю стоимости. По умолчанию (режим не выбран)
// ведём, если настроен экспорт QUIK: история накапливается только вперёд, и
// упущенные дни потом не восстановить.
func lkSnapshotEnabled(rc *RuntimeConfig) bool {
switch strings.ToLower(strings.TrimSpace(rc.Snapshot().LK.SnapshotMode)) {
case "on":
return true
case "off":
return false
}
_, err := nprDB(rc)
return err == nil
}
// lkSnapshotTime — когда снимаем срез (по умолчанию 20:30, после закрытия основной сессии).
func lkSnapshotTime(s LKSettings) (hour, min int) {
hour, min = 20, 30
v := strings.TrimSpace(s.SnapshotAt)
if len(v) == 5 && v[2] == ':' {
h := int(v[0]-'0')*10 + int(v[1]-'0')
m := int(v[3]-'0')*10 + int(v[4]-'0')
if h >= 0 && h < 24 && m >= 0 && m < 60 {
hour, min = h, m
}
}
return hour, min
}
// LKDailyPoint — точка графика стоимости портфеля.
type LKDailyPoint struct {
Day string `json:"day"` // 2026-09-18
Value float64 `json:"value"` // бумаги, ₽
Cash float64 `json:"cash"` // деньги в рублях
Total float64 `json:"total"` // бумаги + деньги
Positions int `json:"positions"`
}
// lkSnapshotHistory — история стоимости портфеля за последние days дней.
func lkSnapshotHistory(ctx context.Context, rc *RuntimeConfig, contract string, days int) ([]LKDailyPoint, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
num := lkContractNumber(contract)
if num == "" {
return nil, nil
}
if days <= 0 || days > 3650 {
days = 365
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, fsql.Sn02, num, days)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKDailyPoint
for rows.Next() {
var p LKDailyPoint
var day time.Time
if rows.Scan(&day, &p.Value, &p.Cash, &p.Positions) != nil {
continue
}
p.Day = day.Format("2006-01-02")
p.Total = p.Value + p.Cash
out = append(out, p)
}
return out, rows.Err()
}
// lkSnapshotSave — записать срез одного портфеля за день (повторный запуск перезаписывает).
func lkSnapshotSave(ctx context.Context, rc *RuntimeConfig, day time.Time, contract string, p *LKPortfolio) error {
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = db.ExecContext(cctx, fsql.Sn03,
day.Format("2006-01-02"), lkContractNumber(contract), p.Securities, p.CashRUB,
len(p.Positions), p.Priced, p.Source)
return err
}
// lkSnapshotContracts — чьи портфели снимаем: договоры, по которым в экспорте QUIK
// есть ненулевые позиции. Берём коды клиентов из экспорта и переводим их в номера
// договоров депозитария (BR746 → БР-746), иначе пришлось бы перебирать 1300 договоров.
func lkSnapshotContracts(ctx context.Context, rc *RuntimeConfig) []string {
p, err := nprDB(rc)
if err != nil {
return nil
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rows, err := p.Query(cctx, fsql.Sn04)
if err != nil {
return nil
}
defer rows.Close()
live := map[string]bool{}
for rows.Next() {
var code string
if rows.Scan(&code) == nil {
live[strings.ToUpper(strings.TrimSpace(code))] = true
}
}
// номер договора знает только депозитарий: сопоставляем его коды с живыми в QUIK
var out []string
seen := map[string]bool{}
for _, full := range depoSuggestContracts(ctx, rc.Snapshot().Fancy) {
num := lkContractNumber(full)
if num == "" || seen[num] {
continue
}
for _, code := range lkQuikCodes(num) {
if live[strings.ToUpper(code)] {
seen[num] = true
out = append(out, num)
break
}
}
}
return out
}
// lkSnapshotRun — один проход: считает и сохраняет срез за день по всем портфелям.
func lkSnapshotRun(ctx context.Context, rc *RuntimeConfig, day time.Time) (int, error) {
contracts := lkSnapshotContracts(ctx, rc)
saved := 0
for _, num := range contracts {
select {
case <-ctx.Done():
return saved, ctx.Err()
default:
}
pf, err := lkOnlinePortfolio(ctx, rc, num)
if err != nil || pf == nil {
continue
}
if err := lkSnapshotSave(ctx, rc, day, num, pf); err == nil {
saved++
}
}
return saved, nil
}
// lkSnapshotLoop — раз в день в назначенное время снимает срез. Если сервер был
// выключен и срез за сегодня не снят, а время уже прошло — снимаем при старте.
func (s *Server) lkSnapshotLoop(ctx context.Context) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if !lkSnapshotEnabled(s.rc) {
continue
}
set := s.rc.Snapshot()
hour, min := lkSnapshotTime(set.LK)
now := time.Now()
if now.Hour() < hour || (now.Hour() == hour && now.Minute() < min) {
continue
}
day := now.Truncate(24 * time.Hour)
if lkSnapshotDone(ctx, s.rc, now) {
continue
}
n, err := lkSnapshotRun(ctx, s.rc, day)
if err != nil {
log.Printf("кабинет: срез стоимости портфелей прерван: %v", err)
continue
}
log.Printf("кабинет: срез стоимости портфелей за %s — %d договоров", now.Format("02.01.2006"), n)
}
}
// lkSnapshotDone — срез за этот день уже снят.
func lkSnapshotDone(ctx context.Context, rc *RuntimeConfig, day time.Time) bool {
db, err := lkDB(rc)
if err != nil {
return true // без базы срез всё равно не сохранить, и жаловаться каждые 5 минут незачем
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var n int
if db.QueryRowContext(cctx, fsql.Sn05,
day.Format("2006-01-02")).Scan(&n) != nil {
return true
}
return n > 0
}