Files
Bridge-and-Join-s/modules/lk/lk.go
T
zuevav 02fabb483d Кабинет клиента: портфель как у брокера — «Онлайн» по QUIK, «Архив» на дату и операции по SPECTRE
- lkclient.go: договор → код QUIK (БР-1545 → BR1545, +OUT), договор → D_B_CONTRACTS SPECTRE
- lkonline.go: позиции DepoLimits (T2, свежая строка на счёт), средняя цена покупки, деньги
  MoneyLimits по валютам, цены и P&L; стоимость, доли
- lkarchive.go: портфель на дату по остаткам OD_RESTS (REG_3 = договор, раздел 39, счета
  50/51/52/55 и 58.01/58.02; 58.05 НКД не показываем) с откатом по OD_TURNS после даты
  (индекс REST+WIRDATE, знак TYPE_ проверен: сумма оборотов = остаток); оценка по котировке
  OD_VALUES_RATES ≤ 90 дней (в рублях за бумагу), иначе балансовая; операции из OD_WIRING
  (покупка/продажа/ввод/вывод/погашение/конвертация, продажа — по счёту ВЫРУЧКА) и
  пополнения/выводы денег; рецепт — InvestControl Hub (build_portfolio_base, deals_report)
- API: /api/lk/portfolio2?view=online|archive&date=, /api/lk/ops (сделки + выплаты дохода);
  без клиента в QUIK онлайн падает на брокерский учёт за сегодня
- кабинет: разделы Портфель (вкладки Онлайн/Архив с датой), Операции, Поручения, Документы,
  Профиль; депозитарные реестр и доходы из клиентского вида убраны (/portfolio → /,
  /income → /operations); проверено на БР-704 и БР-1545
- настройка Fancy.SpectreDatabase (по умолчанию BJ_SPECTRE_DATA)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 15:44:53 +03:00

1098 lines
38 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
}
// 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,
"fmtpct": func(f float64) string {
s := strconv.FormatFloat(f, 'f', 2, 64)
if f > 0 {
s = "+" + s
}
return strings.ReplaceAll(s, ".", ",") + " %"
},
"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 { // цена: до 4 знаков, без хвостовых нулей, с пробелами разрядов
if f == 0 {
return "—"
}
s := strconv.FormatFloat(f, 'f', 4, 64)
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
whole, frac := s, ""
if i := strings.IndexByte(s, '.'); i >= 0 {
whole, frac = s[:i], s[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"`
}
// 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"`
}
// 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["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"`
}
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
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
}
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
}
if r.Method == http.MethodPost {
_ = r.ParseForm()
var res struct {
Message string `json:"message"`
}
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{
"kind": r.FormValue("kind"),
"from": r.FormValue("from"),
"to": r.FormValue("to"),
"account": r.FormValue("account"),
"comment": strings.TrimSpace(r.FormValue("comment")),
}, &res)
if err != nil {
setFlashCookie(w, r, err.Error())
http.Redirect(w, r, "/documents", http.StatusSeeOther)
return
}
setFlashCookie(w, r, res.Message)
http.Redirect(w, r, "/documents", http.StatusSeeOther)
return
}
var out struct {
Ready []documentRow `json:"ready"`
Requests []documentRow `json:"requests"`
Kinds []string `json:"kinds"`
}
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, "Requests": out.Requests,
"Kinds": out.Kinds,
"From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")})
})
// Профиль: договор, счета депо, доступ
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,
})
})
// Поручение депозитарию (сейчас — перевод бумаг в другой депозитарий)
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
}
_ = r.ParseForm()
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"`
}
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})
})
// Администратор: войти в кабинет клиента
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) {
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
// токены чужих кабинетов копились бы и жили ещё 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
}