Files
Bridge-and-Join-s/modules/lk/lk.go
T
zuevav 222d48e740 Кабинет: веб-установщик и управление пользователями в ядре
- установщик в самом кабинете: до настройки любая страница ведёт в /install; три шага —
  проверка сервера (каталог данных, https), связь с ядром (режим, адрес, ключ; связь
  проверяется до сохранения, в режиме ожидания страница ждёт подключения ядра),
  администратор кабинета (заводится в ядре через /api/lk/admin/bootstrap, только пока
  администраторов нет); настройки в data/lk.json 0600, окружение перекрывает файл;
  после установки установщик закрыт
- настройки кабинета можно менять на ходу: Server за мьютексом, один mux на всё время
- ключ модуля больше не кладётся в архив: его вводят в установщике; на странице настроек
  ключ показан целиком 10 минут после создания, потом маска
- ядро: карточка «Пользователи кабинета» в настройках — список, заведение, смена пароля
  (гасит сессии), блокировка, права сотрудника, разрешение поручений, удаление; письма
  не отправляются
- инструкция /admin/help/lk дополнена шагами установщика и разделом о подходящем хостинге
- тесты: TestInstaller, TestNotConfigured, TestSetupPageLKUsersAndLink; установщик
  проверен вживую вторым экземпляром против боевого ядра

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 09:30:47 +03:00

995 lines
35 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"
"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
}
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
// без перезапуска службы.
type Server struct {
mu sync.RWMutex
cfg Config
bjc *bjClient
agent *agentQueue
dataDir string // где лежит lk.json (веб-установщик)
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
}
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}
}
// 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.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 // задан в режиме ожидания
}
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)
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 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")
}
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
}
// 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 — движение по счёту депо.
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 {
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
}
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"`
}
_ = s.bj().call(reqCtx(r), 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,
"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,
"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 := 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")
if from == "" {
from = time.Now().AddDate(-1, 0, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Operations []operationRow `json:"operations"`
}
path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to})
return
}
var in, outq int
for _, o := range out.Operations {
if o.Quantity < 0 {
outq++
} else {
in++
}
}
s.render(w, r, "operations", map[string]any{"Nav": "o", "Operations": out.Operations,
"From": from, "To": to, "Total": len(out.Operations), "In": in, "Out": outq})
})
// Доходы и налоги
mux.HandleFunc("/income", 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")
if from == "" {
from = time.Now().Format("2006") + "-01-01"
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Income []incomeRow `json:"income"`
Accrued float64 `json:"accrued"`
Paid float64 `json:"paid"`
Tax float64 `json:"tax"`
}
path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to})
return
}
var ca struct {
Actions []corpAction `json:"actions"`
}
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca)
acts := ca.Actions
if len(acts) > 8 {
acts = acts[:8]
}
s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to,
"Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax, "Actions": acts})
})
// Документы: готовые выписки, заявки клиента и заказ нового документа
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) {
if !s.Configured() && !strings.HasPrefix(r.URL.Path, "/install") && !strings.HasPrefix(r.URL.Path, "/assets/") && r.URL.Path != "/health" {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
mux.ServeHTTP(w, r)
}))
}
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
func (s *Server) mode() string {
if s.conf().Agent {
return "agent"
}
return "direct"
}