// 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 }