Files
Bridge-and-Join-s/modules/lk/lk.go
T

1349 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package lk — модуль «Личный кабинет клиента» (служба bj-lk, cmd/bj-lk).
//
// Ставится на любой веб-сервер клиента и обновляется независимо от ядра. Сам к базам данных НЕ
// подключается — работает только с bj-server по HTTP API с ключом доступа (X-BJ-Key). Клиент
// входит по логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
package lk
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
const cookieTTL = 12 * 3600
// Config — подключение к bj-server и оформление.
type Config struct {
BJURL string // адрес bj-server (https://...) — в прямом режиме
BJKey string // ключ доступа модуля (выдаётся в настройках bj-server)
Title string // название кабинета в шапке
// Agent — режим ожидания: у ядра нет адреса, доступного кабинету, поэтому
// не кабинет ходит в ядро, а ядро само подключается сюда за запросами.
Agent bool
// Relay — ретранслятор: страницы рисует ядро, здесь только передача конвертов.
Relay bool
// Local — кабинет собран внутрь ядра: API вызывается в том же процессе, ключ
// берётся функцией LocalKey (он меняется в настройках без перезапуска).
Local http.Handler
LocalKey func() string
}
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
// без перезапуска службы.
type Server struct {
mu sync.RWMutex
cfg Config
bjc *bjClient
agent *agentQueue
dataDir string // где лежит lk.json (веб-установщик)
tpl *template.Template
handlerOnce sync.Once
handler http.Handler
nsdMu sync.Mutex // состояние обмена с НРД для баннера (кэш 30 с)
nsdAt time.Time
nsdNotice string
}
// nsdBanner — предупреждение «обмен с НРД недоступен» для вошедшего клиента (пусто — всё работает).
// Спрашивает ядро не чаще раза в 30 с; ядро недоступно — баннер не показываем (об этом скажет сама страница).
func (s *Server) nsdBanner(r *http.Request) string {
sess := session(r)
if sess == "" {
return ""
}
s.nsdMu.Lock()
defer s.nsdMu.Unlock()
if time.Since(s.nsdAt) < 30*time.Second {
return s.nsdNotice
}
var out struct {
Notice string `json:"notice"`
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := s.bj().call(ctx, http.MethodGet, "/api/lk/nsd-status", sess, nil, &out); err == nil {
s.nsdNotice = out.Notice
}
s.nsdAt = time.Now()
return s.nsdNotice
}
// New — кабинет с разобранными шаблонами.
func New(cfg Config) (*Server, error) {
cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/")
cfg.BJKey = strings.TrimSpace(cfg.BJKey)
if cfg.Title == "" {
cfg.Title = "Личный кабинет"
}
tpl, err := template.New("t").Funcs(template.FuncMap{
"fmtnum": func(f float64) string {
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
},
"fmtmoney": fmtMoney,
// доля координаты X в ширине холста — для подписей дат под графиком
"pctOf": func(x, w float64) float64 {
if w == 0 {
return 0
}
return x / w * 100
},
// Русское число: 1 договор, 2 договора, 5 договоров.
"plural": func(n int, one, few, many string) string {
w := many
switch m100 := n % 100; {
case m100 >= 11 && m100 <= 14:
case n%10 == 1:
w = one
case n%10 >= 2 && n%10 <= 4:
w = few
}
return strconv.Itoa(n) + " " + w
},
"fmtpct": func(f float64) string {
s := strconv.FormatFloat(f, 'f', 2, 64)
if f > 0 {
s = "+" + s
}
return strings.ReplaceAll(s, ".", ",") + " %"
},
"fmtdate": func(iso string) string { // 2026-09-18 → 18.09.2026
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01.2006")
}
return iso
},
"fmtpct1": func(f float64) string { // 84,1%
return strings.ReplaceAll(strconv.FormatFloat(f, 'f', 1, 64), ".", ",") + "%"
},
"fmtsigned": func(f float64) string { // +1 234,56 / −1 234,56
if f > 0 {
return "+" + fmtMoney(f)
}
return fmtMoney(f)
},
"fmtprice": func(f float64) string { // цена: крупные — 2 знака, мелкие — до 4
if f == 0 {
return "—"
}
digits := 4
if f >= 10 || f <= -10 {
digits = 2
}
str := strconv.FormatFloat(f, 'f', digits, 64)
if strings.Contains(str, ".") {
str = strings.TrimRight(strings.TrimRight(str, "0"), ".")
}
whole, frac := str, ""
if i := strings.IndexByte(str, '.'); i >= 0 {
whole, frac = str[:i], str[i+1:]
}
neg := strings.HasPrefix(whole, "-")
whole = strings.TrimPrefix(whole, "-")
var b strings.Builder
for i, c := range whole {
if i > 0 && (len(whole)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if frac != "" {
out += "," + frac
}
if neg {
out = "−" + out
}
return out
},
"shortid": func(s string) string {
if len(s) > 8 {
return s[:8] + "…"
}
return s
},
}).Parse(pageTemplates)
if err != nil {
return nil, err
}
srv := &Server{tpl: tpl}
srv.apply(cfg)
return srv, nil
}
// apply — применить настройки: пересобрать клиента к ядру и очередь режима ожидания.
func (s *Server) apply(cfg Config) {
cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/")
cfg.BJKey = strings.TrimSpace(cfg.BJKey)
if cfg.Title == "" {
cfg.Title = "Личный кабинет"
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg = cfg
s.agent = nil
if cfg.Agent {
s.agent = newAgentQueue()
}
s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent, local: cfg.Local, localKey: cfg.LocalKey}
}
// conf — текущие настройки (копия).
func (s *Server) conf() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
// bj — клиент к ядру по текущим настройкам.
func (s *Server) bj() *bjClient {
s.mu.RLock()
defer s.mu.RUnlock()
return s.bjc
}
func (s *Server) agentQueue() *agentQueue {
s.mu.RLock()
defer s.mu.RUnlock()
return s.agent
}
// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра.
func (s *Server) Configured() bool { return s.conf().configured() }
func (c Config) configured() bool {
if c.Local != nil {
return true // внутри ядра настраивать нечего
}
if c.BJKey == "" {
return false
}
if c.Agent {
return true // адрес ядра не нужен: ядро подключается само
}
return c.BJURL != ""
}
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
// токены сессий клиентов пойдут по сети в открытом виде.
func (s *Server) InsecureCore() bool { return s.conf().insecureCore() }
func (c Config) insecureCore() bool {
if c.Agent || c.BJURL == "" || strings.HasPrefix(c.BJURL, "https://") {
return false
}
rest := strings.TrimPrefix(c.BJURL, "http://")
return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost")
}
// ---- клиент к bj-server ----
type bjClient struct {
cfg Config
hc *http.Client
agent *agentQueue // задан в режиме ожидания
local http.Handler // задан внутри ядра: API вызывается без сети
localKey func() string
}
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body)
}
if b.agent != nil {
return b.callAgent(ctx, method, path, session, raw, out)
}
rdr := bytes.NewReader(raw)
target := b.cfg.BJURL + path
if b.local != nil {
target = path
}
req, err := http.NewRequestWithContext(ctx, method, target, rdr)
if err != nil {
return err
}
key := b.cfg.BJKey
if b.localKey != nil {
key = b.localKey()
}
req.Header.Set("X-BJ-Key", key)
if session != "" {
req.Header.Set("X-BJ-Session", session)
}
if v, ok := ctx.Value(visitorKey{}).(visitor); ok {
// Ядро привязывает сессию к браузеру и сети клиента, а вход считает по адресу:
// кабинет — единственный, кто их знает, поэтому передаёт вместе с ключом.
req.Header.Set("X-BJ-Client-IP", v.ip)
req.Header.Set("X-BJ-Client-Agent", v.agent)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
var resp *http.Response
if b.local != nil {
// Внутри ядра: тот же обработчик API, только без сети. Граница проверок та же.
rec := &envelopeRecorder{status: http.StatusOK, hdr: http.Header{}}
req.RemoteAddr = "127.0.0.1:0"
b.local.ServeHTTP(rec, req)
resp = &http.Response{StatusCode: rec.status, Header: rec.hdr, Body: io.NopCloser(&rec.buf)}
} else {
resp, err = b.hc.Do(req)
if err != nil {
return errNoCore(err)
}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var e struct{ Error string }
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "" {
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
}
return fmt.Errorf("%s", e.Error)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// errNoCore / errCore — ошибки связи с ядром одним текстом для страниц.
func errNoCore(err error) error { return fmt.Errorf("нет связи с сервером: %w", err) }
func errCore(msg string) error { return fmt.Errorf("%s", msg) }
var errRequestTooLarge = fmt.Errorf("запрос слишком большой")
// callAgent — тот же запрос, но через очередь: его заберёт ядро, когда подключится.
func (b *bjClient) callAgent(ctx context.Context, method, path, session string, raw []byte, out any) error {
res, err := b.agent.push(ctx, method, path, raw, session, 40*time.Second)
if err != nil {
return err
}
if res.Status >= 400 {
var e struct{ Error string }
_ = json.Unmarshal(res.Body, &e)
if e.Error == "" {
e.Error = fmt.Sprintf("ошибка сервера (%d)", res.Status)
}
return fmt.Errorf("%s", e.Error)
}
if out != nil && len(res.Body) > 0 {
return json.Unmarshal(res.Body, out)
}
return nil
}
// visitor — кто пришёл в кабинет: адрес и браузер. Передаём ядру, чтобы сессия
// была привязана к устройству, а перебор пароля считался по адресу посетителя.
type visitor struct{ ip, agent string }
type visitorKey struct{}
// reqCtx — контекст запроса вместе с данными посетителя.
func reqCtx(r *http.Request) context.Context {
return context.WithValue(r.Context(), visitorKey{}, visitor{ip: clientIP(r), agent: r.UserAgent()})
}
// ---- модели ответов ----
type position struct {
ISIN string `json:"isin"`
SecurityCode string `json:"security_code"`
SecurityName string `json:"security_name"`
AccountID string `json:"account_id"`
Quantity float64 `json:"quantity"`
Blocked float64 `json:"blocked"`
Available float64 `json:"available"`
UpdatedAt string `json:"updated_at"`
Transferable bool `json:"transferable"`
Price float64 `json:"price"`
Value float64 `json:"value"`
ChangePct float64 `json:"change_pct"`
Note string `json:"note"`
}
type claimRow struct {
ID int64 `json:"id"`
GUID string `json:"guid"`
StatusRu string `json:"status_ru"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Securit int `json:"securities"`
Reason string `json:"reason"`
}
// portfolioView — сводка реестра для доски: сколько выпусков всего, свободных и с ограничением.
func portfolioView(pos []position) (total, free, blocked int, accounts []string) {
seen := map[string]bool{}
for _, p := range pos {
total++
switch {
case p.Blocked > 0 || !p.Transferable:
blocked++
default:
free++
}
if p.AccountID != "" && !seen[p.AccountID] {
seen[p.AccountID] = true
accounts = append(accounts, p.AccountID)
}
}
sort.Strings(accounts)
return
}
// claimsView — сколько поручений в работе и сколько завершено.
func claimsView(rows []claimRow) (active, done int) {
for _, c := range rows {
switch c.Status {
case "done":
done++
case "rejected", "declined", "error":
default:
active++
}
}
return
}
// portfolioResp — ответ /api/lk/portfolio.
type portfolioResp struct {
Contract string `json:"contract"`
FullName string `json:"full_name"`
Positions []position `json:"positions"`
Source string `json:"source"`
ReadOnly bool `json:"read_only"`
TotalValue float64 `json:"total_value"`
Priced int `json:"priced"`
Email string `json:"email"`
Phone string `json:"phone"`
LastLogin string `json:"last_login"`
}
// operationRow — движение по счёту депо.
// lkPortfolio — портфель как у брокера (ответ /api/lk/portfolio2).
type lkPortfolio struct {
Source string `json:"source"`
AsOf string `json:"as_of"`
Total float64 `json:"total"`
Securities float64 `json:"securities_value"`
CashRUB float64 `json:"cash_rub"`
Cost float64 `json:"cost"`
PnL float64 `json:"pnl"`
PnLPct float64 `json:"pnl_pct"`
Priced int `json:"priced"`
Cash []lkCash `json:"cash"`
Positions []lkPosition `json:"positions"`
Note string `json:"note"`
}
type lkCash struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
}
type lkPosition struct {
ISIN string `json:"isin"`
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Quantity float64 `json:"quantity"`
AvgPrice float64 `json:"avg_price"`
Price float64 `json:"price"`
InPct bool `json:"in_pct"`
Value float64 `json:"value"`
Cost float64 `json:"cost"`
PnL float64 `json:"pnl"`
PnLPct float64 `json:"pnl_pct"`
DayPct float64 `json:"day_pct"`
Share float64 `json:"share"`
Currency string `json:"currency"`
Note string `json:"note"`
}
// lkUpcoming — ожидаемая выплата по бумаге портфеля.
type lkUpcoming struct {
Date string `json:"date"`
ISIN string `json:"isin"`
Security string `json:"security"`
Kind string `json:"kind"`
PerUnit float64 `json:"per_unit"`
Quantity float64 `json:"quantity"`
Total float64 `json:"total"`
Percent float64 `json:"percent"`
}
// lkPoint — точка истории стоимости портфеля (ответ /api/lk/history).
type lkPoint struct {
Day string `json:"day"`
Value float64 `json:"value"`
Cash float64 `json:"cash"`
Total float64 `json:"total"`
Positions int `json:"positions"`
}
// lkChart — готовый график для вставки в страницу: считаем его на сервере и
// отдаём координатами, потому что скриптов в кабинете нет (строгая CSP).
type lkChart struct {
Line, Area string // пути SVG
W, H float64 // размеры холста
Min, Max float64 // границы шкалы
First, Last float64 // стоимость в начале и в конце периода
Change float64 // изменение за период, ₽
ChangePct float64 // изменение за период, %
FromDay string // первая дата периода
ToDay string // последняя дата
Points int
GridY []float64 // горизонтальные линии (y в координатах холста)
GridLabel []string // подписи к ним
Marks []lkMark // подписи дат под осью
}
// lkMark — подпись даты на оси времени.
type lkMark struct {
X float64
Text string
}
// portfolioChart — линия стоимости портфеля по ежедневным срезам.
func portfolioChart(points []lkPoint) *lkChart {
if len(points) < 2 {
return nil
}
const w, h, pad = 980.0, 190.0, 10.0
lo, hi := points[0].Total, points[0].Total
for _, p := range points {
if p.Total < lo {
lo = p.Total
}
if p.Total > hi {
hi = p.Total
}
}
// Ось не от нуля: иначе движение на 1–2 % не видно. Запас сверху и снизу —
// чтобы линия не липла к краю.
span := hi - lo
if span <= 0 {
span = hi * 0.02
if span <= 0 {
span = 1
}
}
lo, hi = lo-span*0.15, hi+span*0.15
x := func(i int) float64 { return pad + (w-2*pad)*float64(i)/float64(len(points)-1) }
y := func(v float64) float64 { return pad + (h-2*pad)*(1-(v-lo)/(hi-lo)) }
var line, area strings.Builder
for i, p := range points {
cmd := "L"
if i == 0 {
cmd = "M"
}
fmt.Fprintf(&line, "%s%.1f %.1f ", cmd, x(i), y(p.Total))
}
area.WriteString(line.String())
fmt.Fprintf(&area, "L%.1f %.1f L%.1f %.1f Z", x(len(points)-1), h, x(0), h)
c := &lkChart{Line: strings.TrimSpace(line.String()), Area: area.String(), W: w, H: h,
Min: lo, Max: hi, First: points[0].Total, Last: points[len(points)-1].Total,
FromDay: ruDateFull(points[0].Day), ToDay: ruDateFull(points[len(points)-1].Day), Points: len(points)}
c.Change = c.Last - c.First
if c.First != 0 {
c.ChangePct = c.Change / c.First * 100
}
for _, frac := range []float64{0, 0.5, 1} {
v := lo + (hi-lo)*frac
c.GridY = append(c.GridY, y(v))
c.GridLabel = append(c.GridLabel, fmtMoney(v))
}
// Подписи дат: начало, середина, конец — больше на узкой карточке не влезает.
for _, i := range []int{0, len(points) / 2, len(points) - 1} {
c.Marks = append(c.Marks, lkMark{X: x(i), Text: ruDate(points[i].Day)})
}
return c
}
// ruDateFull — 2026-09-18 → 18.09.2026.
func ruDateFull(iso string) string {
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01.2006")
}
return iso
}
// ruDate — 2026-09-18 → 18.09.
func ruDate(iso string) string {
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01")
}
return iso
}
// lkSegment — доля вида активов в портфеле для полосы структуры.
type lkSegment struct {
Kind string
Value float64
Share float64
Color string
}
// portfolioStructure — структура портфеля по видам активов (бумаги по виду + деньги).
func portfolioStructure(p lkPortfolio) []lkSegment {
if p.Total <= 0 {
return nil
}
colors := map[string]string{"акция": "#e8a33d", "облигация": "#4f7cac", "фонд": "#5f9e7a", "пай": "#5f9e7a", "депозитарная расписка": "#b07aa1", "деньги": "#8e8879"}
byKind := map[string]float64{}
for _, pos := range p.Positions {
k := pos.Kind
if k == "" || k == "бумага" {
k = "прочее"
}
byKind[k] += pos.Value
}
if p.CashRUB > 0 {
byKind["деньги"] += p.CashRUB
}
plural := map[string]string{"акция": "акции", "облигация": "облигации", "фонд": "фонды", "пай": "паи", "депозитарная расписка": "расписки"}
var out []lkSegment
for k, v := range byKind {
if v <= 0 {
continue
}
c := colors[k]
if c == "" {
c = "#c9c3b4"
}
label := k
if pl, ok := plural[k]; ok {
label = pl
}
out = append(out, lkSegment{Kind: label, Value: v, Share: v / p.Total * 100, Color: c})
}
sort.Slice(out, func(i, j int) bool { return out[i].Value > out[j].Value })
return out
}
// lkOpRow — операция по брокерскому счёту (ответ /api/lk/ops).
type lkOpRow struct {
Date string `json:"date"`
Kind string `json:"kind"`
Side string `json:"side"`
Name string `json:"name"`
ISIN string `json:"isin"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
AmountRUB float64 `json:"amount_rub"`
}
type operationRow struct {
Date string `json:"date"`
DoneAt string `json:"done_at"`
Kind string `json:"kind"`
Number string `json:"number"`
Account string `json:"account"`
ISIN string `json:"isin"`
Security string `json:"security"`
Quantity float64 `json:"quantity"`
State string `json:"state"`
Note string `json:"note"`
}
// incomeRow — выплата дохода.
type incomeRow struct {
Date string `json:"date"`
Kind string `json:"kind"`
ISIN string `json:"isin"`
Security string `json:"security"`
Quantity float64 `json:"quantity"`
Accrued float64 `json:"accrued"`
Tax float64 `json:"tax"`
Paid float64 `json:"paid"`
State string `json:"state"`
Account string `json:"account"`
}
// corpAction — корпоративное действие по бумаге клиента.
type corpAction struct {
FixDate string `json:"fix_date"`
OperDate string `json:"oper_date"`
Kind string `json:"kind"`
ISIN string `json:"isin"`
Security string `json:"security"`
Note string `json:"note"`
}
// documentRow — документ депозитария или заявка клиента.
type documentRow struct {
ID int64 `json:"id"`
Date string `json:"date"`
Title string `json:"title"`
Period string `json:"period"`
Account string `json:"account"`
State string `json:"state"`
Note string `json:"note"`
Ordered bool `json:"ordered"`
Kind string `json:"kind"`
}
// securityCard — карточка бумаги из ядра.
type securityCard struct {
ISIN string `json:"isin"`
Name string `json:"name"`
Kind string `json:"kind"`
Issuer string `json:"issuer"`
RegNumber string `json:"reg_number"`
CFI string `json:"cfi"`
Face float64 `json:"face"`
Maturity string `json:"maturity"`
Accepted bool `json:"accepted"`
QualOnly bool `json:"qual_only"`
QualNote string `json:"qual_note"`
Price float64 `json:"price"`
PriceIn string `json:"price_in"`
Board string `json:"board"`
ChangePct float64 `json:"change_pct"`
Position *position `json:"position"`
Payments []struct {
Date string `json:"date"`
Kind string `json:"kind"`
Amount float64 `json:"amount"`
Percent float64 `json:"percent"`
FixDate string `json:"fix_date"`
Paid bool `json:"paid"`
} `json:"payments"`
Note string `json:"note"`
}
// portfolio — реестр клиента из ядра.
func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) {
var out portfolioResp
err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out)
return out, err
}
// ---- страницы ----
// render — страница; признаки режима (админ / просмотр кабинета клиента) — из кук этого запроса.
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Title"] = s.conf().Title
if _, ok := data["Nav"]; !ok {
data["Nav"] = ""
}
if _, ok := data["IsAdmin"]; !ok {
if c, err := r.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
data["IsAdmin"] = true
}
}
if c, err := r.Cookie("bj_lk_as"); err == nil && c.Value != "" {
data["AsClient"] = c.Value
}
data["CSRF"] = csrfToken(w, r)
if _, ok := data["NSDNotice"]; !ok {
data["NSDNotice"] = s.nsdBanner(r)
}
if _, ok := data["Flash"]; !ok {
if f := takeFlash(w, r); f != "" {
data["Flash"] = f
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("шаблон %s: %v", name, err)
}
}
// setFlag — служебная кука кабинета. HttpOnly обязателен: в bj_lk_admin_sess лежит
// живой токен сотрудника, и скрипт на странице не должен уметь его прочитать.
func setFlag(w http.ResponseWriter, r *http.Request, name, val string) {
age := cookieTTL
if val == "" {
age = -1
}
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
}
func setSession(w http.ResponseWriter, r *http.Request, val string) {
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/",
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode, MaxAge: cookieTTL})
}
// flash — сообщение посетителю до следующей страницы. Раньше текст ехал в адресе
// (?m=…), то есть попадал в журнал nginx и в историю браузера вместе с ФИО и
// текстами ошибок; теперь он живёт одну страницу в куке.
func setFlashCookie(w http.ResponseWriter, r *http.Request, msg string) {
http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 60,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
}
func takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("bj_lk_flash")
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
msg, _ := url.QueryUnescape(c.Value)
return msg
}
// clearAll — погасить все куки кабинета (выход, сбой сессии).
func clearAll(w http.ResponseWriter, r *http.Request) {
for _, n := range []string{"bj_lk", "bj_lk_admin", "bj_lk_as", "bj_lk_admin_sess", "bj_lk_flash", csrfCookie} {
http.SetCookie(w, &http.Cookie{Name: n, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
}
}
// sessionW — как session, но заодно продлевает куку, чтобы у активного
// клиента кабинет не сбрасывался на середине работы.
func sessionW(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("bj_lk")
if err != nil || c.Value == "" {
return ""
}
setSession(w, r, c.Value)
return c.Value
}
func session(r *http.Request) string {
c, err := r.Cookie("bj_lk")
if err != nil {
return ""
}
return c.Value
}
// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
func (s *Server) Handler() http.Handler {
s.handlerOnce.Do(func() { s.handler = s.buildHandler() })
return s.handler
}
func (s *Server) buildHandler() http.Handler {
mux := http.NewServeMux()
s.registerInstall(mux)
// Вход
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.render(w, r, "login", nil)
return
}
if !csrfOK(r) {
s.render(w, r, "login", map[string]any{"Error": "Форма устарела, попробуйте войти ещё раз."})
return
}
var out struct {
Session string `json:"session"`
FullName string `json:"full_name"`
Contract string `json:"contract"`
IsAdmin bool `json:"is_admin"`
}
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{
"login": r.FormValue("login"), "password": r.FormValue("password"),
}, &out)
if err != nil {
// Текст ошибки приходит из ядра: «неверный логин или пароль» либо
// «слишком много попыток входа» — показываем его как есть.
s.render(w, r, "login", map[string]any{"Error": err.Error()})
return
}
setSession(w, r, out.Session)
if out.IsAdmin {
setFlag(w, r, "bj_lk_admin", "1")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
setFlag(w, r, "bj_lk_admin", "")
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после
// «выхода» в браузере оставался действующий токен сотрудника.
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil)
}
clearAll(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
// Табло — обзор: реестр и последние поручения на одной странице.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Сотрудник, не открывший кабинет клиента: его «табло» — список клиентов.
if c, err := r.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
if as, err := r.Cookie("bj_lk_as"); err != nil || as.Value == "" {
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
}
view := "online"
q := url.Values{}
today := time.Now().Format("2006-01-02")
date := r.URL.Query().Get("date")
if r.URL.Query().Get("view") == "archive" {
view = "archive"
if _, err := time.Parse("2006-01-02", date); err != nil || date > today {
date = today
}
q.Set("view", "archive")
q.Set("date", date)
}
var out struct {
Portfolio lkPortfolio `json:"portfolio"`
Contract string `json:"contract"`
FullName string `json:"full_name"`
Upcoming []lkUpcoming `json:"upcoming"`
}
path := "/api/lk/portfolio2"
if len(q) > 0 {
path += "?" + q.Encode()
}
data := map[string]any{"Nav": "b", "View": view, "Date": date, "Today": today}
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
data["Error"] = err.Error()
data["P"] = lkPortfolio{}
s.render(w, r, "portfolio", data)
return
}
data["P"], data["Contract"], data["FullName"] = out.Portfolio, out.Contract, out.FullName
data["Structure"], data["Upcoming"] = portfolioStructure(out.Portfolio), out.Upcoming
// Рубли и так видны в шапке и структуре — отдельный блок нужен, когда валют несколько.
showCash := len(out.Portfolio.Cash) > 1
if len(out.Portfolio.Cash) == 1 && out.Portfolio.Cash[0].Currency != "SUR" {
showCash = true
}
data["ShowCash"] = showCash
var upTotal float64
for _, u := range out.Upcoming {
upTotal += u.Total
}
data["UpcomingTotal"] = upTotal
// График стоимости — только для «Онлайн»: в архиве он был бы историей
// срезов, а не тем, что показано на экране.
if view != "archive" {
var hist struct {
Points []lkPoint `json:"points"`
On bool `json:"on"`
At string `json:"at"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/history?days=180", sess, nil, &hist); err == nil {
data["Chart"] = portfolioChart(hist.Points)
data["ChartPoints"] = len(hist.Points)
data["ChartOn"] = hist.On
}
}
s.render(w, r, "portfolio", data)
})
mux.HandleFunc("/portfolio", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusMovedPermanently)
})
mux.HandleFunc("/security", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin")))
if isin == "" {
http.Redirect(w, r, "/portfolio", http.StatusSeeOther)
return
}
var out struct {
Security securityCard `json:"security"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil {
s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin})
return
}
s.render(w, r, "security", map[string]any{"Nav": "p", "S": out.Security, "ISIN": isin})
})
// Операции по счетам депо
mux.HandleFunc("/operations", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
var out struct {
Rows []lkOpRow `json:"operations"`
From string `json:"from"`
To string `json:"to"`
Note string `json:"note"`
}
path := "/api/lk/ops?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
data := map[string]any{"Nav": "o", "From": from, "To": to}
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
data["Error"] = err.Error()
s.render(w, r, "operations", data)
return
}
sort.SliceStable(out.Rows, func(i, j int) bool { // свежие сверху, независимо от порядка в ответе
di, _ := time.Parse("02.01.2006", out.Rows[i].Date)
dj, _ := time.Parse("02.01.2006", out.Rows[j].Date)
return di.After(dj)
})
var buy, sell, income float64
for _, o := range out.Rows {
switch o.Kind {
case "покупка", "ввод бумаг", "пополнение":
buy += o.AmountRUB
case "продажа", "погашение", "вывод бумаг", "вывод денег":
sell += o.AmountRUB
default:
if o.Side == "in" {
income += o.AmountRUB
}
}
}
data["Rows"], data["From"], data["To"], data["Note"] = out.Rows, out.From, out.To, out.Note
data["BuyRUB"], data["SellRUB"], data["IncomeRUB"] = buy, sell, income
s.render(w, r, "operations", data)
})
mux.HandleFunc("/income", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/operations", http.StatusMovedPermanently)
})
mux.HandleFunc("/documents", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var out struct {
Ready []documentRow `json:"ready"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil {
s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()})
return
}
// Заказ документов через кабинет убран: выписки готовит депозитарий,
// клиенту показываем только готовое.
s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready})
})
// Профиль: договор, счета депо, доступ
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
pf, err := s.portfolio(r, sess)
if err != nil {
s.render(w, r, "profile", map[string]any{"Nav": "u", "Error": err.Error()})
return
}
_, _, _, accounts := portfolioView(pf.Positions)
login := ""
if c, err := r.Cookie("bj_lk_as"); err == nil {
login = c.Value
}
if login == "" {
login = pf.Contract
}
s.render(w, r, "profile", map[string]any{
"Nav": "u", "Contract": pf.Contract, "FullName": pf.FullName,
"AccountList": accounts, "Accounts": len(accounts), "ReadOnly": pf.ReadOnly, "Login": login,
"Email": pf.Email, "Phone": pf.Phone, "LastLogin": pf.LastLogin,
})
})
// Поручение депозитарию (сейчас — перевод бумаг в другой депозитарий)
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/order", http.StatusMovedPermanently)
})
mux.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodGet {
var out struct {
Positions []position `json:"positions"`
FullName string `json:"full_name"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Positions": out.Positions, "FullName": out.FullName,
"PickISIN": r.URL.Query().Get("isin"), "Today": time.Now().Format("02.01.2006")})
return
}
// Изменяющее действие (поручение на списание бумаг) — только по своей форме
// с CSRF-токеном, как /login и /impersonate. Иначе чужая страница могла бы
// отправить поручение от имени вошедшего клиента.
if !csrfOK(r) {
setFlashCookie(w, r, "Форма устарела, откройте поручение заново.")
http.Redirect(w, r, "/order", http.StatusSeeOther)
return
}
var secs []map[string]any
for _, isin := range r.Form["isin"] {
q := strings.TrimSpace(r.FormValue("qty_" + isin))
if q == "" || q == "0" {
continue
}
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
if err != nil || f <= 0 {
continue
}
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
}
if len(secs) == 0 {
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": "Укажите количество хотя бы по одной бумаге", "Today": time.Now().Format("02.01.2006")})
return
}
var res struct {
Message string `json:"message"`
GUID string `json:"guid"`
}
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
"comment": strings.TrimSpace(r.FormValue("comment")),
"securities": secs,
}, &res)
if err != nil {
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
setFlashCookie(w, r, res.Message)
http.Redirect(w, r, "/orders", http.StatusSeeOther)
})
// Поручения — журнал всех видов
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/orders", http.StatusMovedPermanently)
})
mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var out struct {
Claims []claimRow `json:"claims"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
return
}
active, done := claimsView(out.Claims)
s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims,
"Total": len(out.Claims), "Active": active, "Done": done})
})
// Администратор: список клиентов
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var out struct {
Clients []struct {
Login string `json:"login"`
Contract string `json:"contract_number"`
FullName string `json:"full_name"`
Email string `json:"email"`
Active bool `json:"active"`
IsAdmin bool `json:"is_admin"`
} `json:"clients"`
Depo []struct {
Contract string `json:"contract"`
Name string `json:"name"`
Securities int `json:"securities"`
} `json:"depo"`
DepoError string `json:"depo_error"`
Suggest []string `json:"suggest"`
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
path := "/api/lk/admin/clients"
if search != "" {
path += "?search=" + url.QueryEscape(search)
}
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
return
}
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
"Suggest": out.Suggest, "Found": len(out.Clients) + len(out.Depo)})
})
// Администратор: войти в кабинет клиента
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Только POST с формой кабинета: иначе достаточно было заманить сотрудника
// на страницу с картинкой /impersonate?contract=… , чтобы его сессия молча
// переключилась в кабинет чужого клиента.
if r.Method != http.MethodPost || !csrfOK(r) {
setFlashCookie(w, r, "Форма устарела, откройте список клиентов заново.")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
login := r.FormValue("login")
contract := r.FormValue("contract")
var out struct {
Session string `json:"session"`
AsClient string `json:"as_client"`
}
if err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess,
map[string]string{"login": login, "contract": contract}, &out); err != nil {
setFlashCookie(w, r, err.Error())
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
// сохраняем админскую сессию, чтобы можно было вернуться
setFlag(w, r, "bj_lk_admin_sess", sess)
setSession(w, r, out.Session)
setFlag(w, r, "bj_lk_as", out.AsClient)
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Администратор: вернуться из кабинета клиента
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
// Меняет состояние (гасит сессию просмотра, переключает cookie) — только POST
// своей формы с CSRF, как и остальные изменяющие действия.
if r.Method != http.MethodPost || !csrfOK(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
// токены чужих кабинетов копились бы и жили ещё 12 часов.
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
setSession(w, r, c.Value)
}
setFlag(w, r, "bj_lk_as", "")
setFlag(w, r, "bj_lk_admin_sess", "")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
})
// Проверка связи с bj-server (для админа при установке)
// Проверка связи с ядром. Наружу отдаём только признак жизни: подробности
// (внутренний адрес ядра, состояние базы) — администратору по ключу модуля.
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
cfg := s.conf()
full := secureEqual(r.Header.Get("X-BJ-Key"), cfg.BJKey)
var out map[string]any
err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
w.WriteHeader(http.StatusBadGateway)
resp := map[string]any{"ok": false}
if full {
resp["error"], resp["bj_url"], resp["mode"] = err.Error(), cfg.BJURL, s.mode()
}
_ = json.NewEncoder(w).Encode(resp)
return
}
resp := map[string]any{"ok": true}
if full {
resp["bj"], resp["bj_url"], resp["mode"] = out, cfg.BJURL, s.mode()
}
_ = json.NewEncoder(w).Encode(resp)
})
s.registerAgent(mux)
mux.Handle("/assets/", assetsHandler())
return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if !s.Configured() && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/assets/") && p != "/health" {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
// Ретранслятор: всё, кроме своих служебных страниц, уезжает в ядро конвертом.
if s.conf().Relay && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/agent/") && p != "/health" {
s.relay(w, r)
return
}
mux.ServeHTTP(w, r)
}))
}
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
func (s *Server) mode() string {
if s.conf().Agent {
return "agent"
}
return "direct"
}
// fmtMoney — 1 234 567,89 с разделителем разрядов; отрицательные — с минусом.
func fmtMoney(f float64) string {
neg := f < 0
if neg {
f = -f
}
whole := int64(f)
cents := int64((f-float64(whole))*100 + 0.5)
if cents >= 100 {
whole, cents = whole+1, 0
}
d := strconv.FormatInt(whole, 10)
var b strings.Builder
for i, c := range d {
if i > 0 && (len(d)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if cents > 0 {
out += "," + fmt.Sprintf("%02d", cents)
}
if neg {
out = "−" + out
}
return out
}