Files
Bridge-and-Join-s/modules/lk/lk.go
T
zuevav 6fee3c5ff7 Личный кабинет — модуль modules/lk: сборка в релизе, unit в комплекте, ключ вне unit-файла
Код bj-lk перенесён в modules/lk (cmd/bj-lk — запуск, флаг -version); релиз собирает bj-lk
со своей версией (LK_VERSION), unit bj-lk.service встроен в комплект установщика и берёт
адрес bj-server и ключ доступа из /etc/bj/lk.env, а не из unit-файла.

Исправлено: признаки «администратор» и «кабинет клиента» читались из общей переменной
последнего запроса (curReq) — при одновременной работе признак мог достаться чужому запросу.
Теперь из кук своего запроса. Без BJ_URL/BJ_KEY кабинет показывает подсказку настройки
вместо падения службы. Тесты: вход, портфель, health, флаг админа по запросу, без настройки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:21:01 +03:00

449 lines
16 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"
"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)
},
"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"`
Quantity float64 `json:"quantity"`
Blocked float64 `json:"blocked"`
Available float64 `json:"available"`
UpdatedAt string `json:"updated_at"`
Transferable bool `json:"transferable"`
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"`
}
// ---- страницы ----
// 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
}
var out struct {
Contract string `json:"contract"`
FullName string `json:"full_name"`
Positions []position `json:"positions"`
Source string `json:"source"`
ReadOnly bool `json:"read_only"`
}
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
return
}
s.render(w, r, "portfolio", map[string]any{
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
"Source": out.Source, "ReadOnly": out.ReadOnly,
})
})
// Заявка на перевод
mux.HandleFunc("/transfer", 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": "t", "Error": err.Error()})
return
}
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
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": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
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": "t", "Error": err.Error()})
return
}
http.Redirect(w, r, "/claims?m="+url.QueryEscape(res.Message), http.StatusSeeOther)
})
// Мои заявки
mux.HandleFunc("/claims", 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
}
s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
})
// Администратор: список клиентов
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, `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head>`+
`<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;line-height:1.5"><h1>%s</h1><p>%s</p></body></html>`,
template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg))
}