// Package lk — модуль «Личный кабинет клиента» (служба bj-lk, cmd/bj-lk). // // Ставится на любой веб-сервер клиента и обновляется независимо от ядра. Сам к базам данных НЕ // подключается — работает только с bj-server по HTTP API с ключом доступа (X-BJ-Key). Клиент // входит по логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру. package lk import ( "bytes" "context" "encoding/json" "fmt" "html/template" "log" "net/http" "net/url" "sort" "strconv" "strings" "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 // название кабинета в шапке } // Server — кабинет. type Server struct { cfg Config bj *bjClient tpl *template.Template } // 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": func(f float64) string { // 1 234 567,89 — с разделителем разрядов 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 }, "fmtpct": func(f float64) string { s := strconv.FormatFloat(f, 'f', 2, 64) if f > 0 { s = "+" + s } return strings.ReplaceAll(s, ".", ",") + " %" }, "shortid": func(s string) string { if len(s) > 8 { return s[:8] + "…" } return s }, }).Parse(pageTemplates) if err != nil { return nil, err } return &Server{cfg: cfg, bj: &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}, tpl: tpl}, nil } // Configured — заданы адрес bj-server и ключ доступа. func (s *Server) Configured() bool { return s.cfg.BJURL != "" && s.cfg.BJKey != "" } // ---- клиент к bj-server ---- type bjClient struct { cfg Config hc *http.Client } func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error { var rdr *bytes.Reader if body != nil { raw, _ := json.Marshal(body) rdr = bytes.NewReader(raw) } else { rdr = bytes.NewReader(nil) } req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr) if err != nil { return err } req.Header.Set("X-BJ-Key", b.cfg.BJKey) if session != "" { req.Header.Set("X-BJ-Session", session) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := b.hc.Do(req) if err != nil { return fmt.Errorf("нет связи с сервером: %w", 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 } // ---- модели ответов ---- 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"` } // 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(r.Context(), 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.cfg.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 } 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) } } func setFlag(w http.ResponseWriter, name, val string) { age := cookieTTL if val == "" { age = -1 } http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode}) } func setSession(w http.ResponseWriter, val string) { http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) } // 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, 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 — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки. func (s *Server) Handler() http.Handler { if !s.Configured() { return http.HandlerFunc(s.notConfigured) } bj := s.bj mux := http.NewServeMux() // Вход mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { s.render(w, r, "login", map[string]any{"Flash": r.URL.Query().Get("e")}) return } _ = r.ParseForm() var out struct { Session string `json:"session"` FullName string `json:"full_name"` Contract string `json:"contract"` IsAdmin bool `json:"is_admin"` } err := bj.call(r.Context(), 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{"Flash": err.Error()}) return } setSession(w, out.Session) if out.IsAdmin { setFlag(w, "bj_lk_admin", "1") http.Redirect(w, r, "/clients", http.StatusSeeOther) return } setFlag(w, "bj_lk_admin", "") http.Redirect(w, r, "/", http.StatusSeeOther) }) mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { _ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil) http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1}) 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 } pf, err := s.portfolio(r, sess) if err != nil { s.render(w, r, "board", map[string]any{"Nav": "b", "Error": err.Error()}) return } total, free, blocked, _ := portfolioView(pf.Positions) var cl struct { Claims []claimRow `json:"claims"` } _ = bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &cl) active, _ := claimsView(cl.Claims) pos := pf.Positions if len(pos) > 5 { pos = pos[:5] } claims := cl.Claims if len(claims) > 5 { claims = claims[:5] } s.render(w, r, "board", map[string]any{ "Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName, "Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"), "Total": total, "Free": free, "Blocked": blocked, "Active": active, "Value": pf.TotalValue, "Priced": pf.Priced, }) }) // Реестр бумаг mux.HandleFunc("/portfolio", 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, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()}) return } total, free, blocked, _ := portfolioView(pf.Positions) updated := "" if len(pf.Positions) > 0 { updated = pf.Positions[0].UpdatedAt } s.render(w, r, "portfolio", map[string]any{ "Nav": "p", "Contract": pf.Contract, "FullName": pf.FullName, "Positions": pf.Positions, "Flash": r.URL.Query().Get("m"), "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated, "Total": total, "Free": free, "Blocked": blocked, "Value": pf.TotalValue, "Priced": pf.Priced, }) }) // Карточка бумаги 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 := bj.call(r.Context(), 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("/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 := bj.call(r.Context(), 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 := bj.call(r.Context(), 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 } http.Redirect(w, r, "/orders?m="+url.QueryEscape(res.Message), 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 := bj.call(r.Context(), 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, "Flash": r.URL.Query().Get("m"), "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 := bj.call(r.Context(), 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, "Flash": r.URL.Query().Get("m")}) }) // Администратор: войти в кабинет клиента mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) { sess := sessionW(w, r) if sess == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } login := r.FormValue("login") if login == "" { login = r.URL.Query().Get("login") } contract := r.FormValue("contract") if contract == "" { contract = r.URL.Query().Get("contract") } var out struct { Session string `json:"session"` AsClient string `json:"as_client"` } if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", sess, map[string]string{"login": login, "contract": contract}, &out); err != nil { http.Redirect(w, r, "/clients?m="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } // сохраняем админскую сессию, чтобы можно было вернуться setFlag(w, "bj_lk_admin_sess", sess) setSession(w, out.Session) setFlag(w, "bj_lk_as", out.AsClient) http.Redirect(w, r, "/", http.StatusSeeOther) }) // Администратор: вернуться из кабинета клиента mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { setSession(w, c.Value) } setFlag(w, "bj_lk_as", "") setFlag(w, "bj_lk_admin_sess", "") http.Redirect(w, r, "/clients", http.StatusSeeOther) }) // Проверка связи с bj-server (для админа при установке) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { var out map[string]any err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out) w.Header().Set("Content-Type", "application/json; charset=utf-8") if err != nil { w.WriteHeader(http.StatusBadGateway) _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": s.cfg.BJURL}) return } _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": s.cfg.BJURL}) }) return mux } // notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы. func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) { const msg = "Личный кабинет не настроен: укажите адрес bj-server (BJ_URL) и ключ доступа модуля (BJ_KEY) в /etc/bj/lk.env, затем выполните systemctl restart bj-lk. Ключ задаётся в настройках bj-server." if r.URL.Path == "/health" { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg}) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg)) }