Личный кабинет — модуль 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>
This commit is contained in:
zuevav
2026-09-16 01:21:01 +03:00
parent 97e99b72c7
commit 6fee3c5ff7
7 changed files with 633 additions and 420 deletions
+448
View File
@@ -0,0 +1,448 @@
// 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))
}