package lkgateway import ( "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "net/http" "os" "strings" "sync" "time" ) // API для модуля Личного кабинета клиента (bj-lk). Модуль ставится на веб-сервер // клиента, сам к БД не ходит — только сюда, с ключом X-BJ-Key (задаётся при // установке). Клиент внутри модуля логинится по логину/паролю; сессии живут тут. // // POST /api/lk/login — вход клиента (логин/пароль) → session token // GET /api/lk/portfolio — портфель клиента (остатки бумаг из Fancy/Спектр) // POST /api/lk/transfer — заявка на перевод бумаг другому брокеру // GET /api/lk/claims — статусы заявок клиента // GET /api/lk/ping — проверка связи модуля с bj-server // Сессии ЛК живут дольше рабочего дня и ПРОДЛЕВАЮТСЯ при каждом обращении // (скользящий срок), поэтому активного клиента из кабинета не выкидывает. const lkSessionTTL = 12 * time.Hour // lkSessionsFile — сессии переживают перезапуск bj-server. Раньше они были // только в памяти, и любой рестарт (обновление, деплой) выбрасывал всех // клиентов с «сессия истекла, войдите заново». const lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json" type lkSession struct { user LKUser expiresAt time.Time } type lkSessions struct { mu sync.Mutex data map[string]lkSession } func newLKSessions() *lkSessions { s := &lkSessions{data: map[string]lkSession{}} s.load() return s } // persisted — формат хранения сессии на диске. type lkSessionDisk struct { User LKUser `json:"user"` ExpiresAt time.Time `json:"expires_at"` } // load поднимает непротухшие сессии с диска при старте сервера. func (s *lkSessions) load() { raw, err := os.ReadFile(lkSessionsFile) if err != nil { return } var m map[string]lkSessionDisk if json.Unmarshal(raw, &m) != nil { return } now := time.Now() for tok, v := range m { if now.Before(v.ExpiresAt) { s.data[tok] = lkSession{user: v.User, expiresAt: v.ExpiresAt} } } } // save сбрасывает сессии на диск (вызывается под удержанным mu). func (s *lkSessions) saveLocked() { m := make(map[string]lkSessionDisk, len(s.data)) for tok, v := range s.data { m[tok] = lkSessionDisk{User: v.user, ExpiresAt: v.expiresAt} } raw, err := json.Marshal(m) if err != nil { return } tmp := lkSessionsFile + ".tmp" if os.WriteFile(tmp, raw, 0o600) == nil { _ = os.Rename(tmp, lkSessionsFile) } } func (s *lkSessions) create(u LKUser) string { b := make([]byte, 24) _, _ = rand.Read(b) tok := hex.EncodeToString(b) s.mu.Lock() s.data[tok] = lkSession{user: u, expiresAt: time.Now().Add(lkSessionTTL)} // подчистим протухшие for k, v := range s.data { if time.Now().After(v.expiresAt) { delete(s.data, k) } } s.saveLocked() s.mu.Unlock() return tok } func (s *lkSessions) get(tok string) (LKUser, bool) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.data[tok] if !ok || time.Now().After(v.expiresAt) { return LKUser{}, false } // Скользящий срок: пока клиент работает — сессия не истекает. // Пишем на диск не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос. if until := time.Until(v.expiresAt); until < lkSessionTTL-10*time.Minute { v.expiresAt = time.Now().Add(lkSessionTTL) s.data[tok] = v s.saveLocked() } return v.user, true } func (s *lkSessions) drop(tok string) { s.mu.Lock() delete(s.data, tok) s.saveLocked() s.mu.Unlock() } // registerLKAPI вешает /api/lk/* — интерфейс для модуля личного кабинета. func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { sess := newLKSessions() // проверка ключа модуля auth := func(w http.ResponseWriter, r *http.Request) bool { key := strings.TrimSpace(rc.Snapshot().LK.APIKey) if key == "" { lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "API личного кабинета не настроен (нет ключа)"}) return false } got := r.Header.Get("X-BJ-Key") if subtle.ConstantTimeCompare([]byte(got), []byte(key)) != 1 { lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "неверный ключ модуля"}) return false } return true } // текущий клиент по session-токену current := func(w http.ResponseWriter, r *http.Request) (LKUser, bool) { tok := r.Header.Get("X-BJ-Session") u, ok := sess.get(tok) if !ok { lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "сессия истекла, войдите заново"}) return LKUser{}, false } return u, true } mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } lkJSON(w, http.StatusOK, map[string]any{ "ok": true, "service": "bj-server", "db_ready": lkDBReady(rc), "server_time": time.Now().UTC().Format(time.RFC3339), }) }) mux.HandleFunc("/api/lk/login", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } if r.Method != http.MethodPost { lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"}) return } var in struct{ Login, Password string } if json.NewDecoder(r.Body).Decode(&in) != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"}) return } u, err := lkAuth(r.Context(), rc, strings.TrimSpace(in.Login), in.Password) if err != nil { lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) return } lkJSON(w, http.StatusOK, map[string]any{ "session": sess.create(*u), "full_name": u.FullName, "contract": u.Contract, "is_admin": u.IsAdmin, "ttl_sec": int(lkSessionTTL.Seconds()), }) }) mux.HandleFunc("/api/lk/logout", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } sess.drop(r.Header.Get("X-BJ-Session")) lkJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } fancy := rc.Snapshot().Fancy // Сначала пробуем РЕАЛЬНЫЙ портфель из депозитарного учёта (BJ_DEPO_DATA) // по номеру договора. Если такого договора там нет — берём данные из // позиций модуля ЛК в PostgreSQL bj (тестовые клиенты). source := "депозитарный учёт" readOnly := true var pos []ClientPosition if dp, err := depoClientPositions(r.Context(), fancy, u.Contract); err == nil && len(dp) > 0 { pos = depoPositionsAsClient(dp) // Реальный депозитарный портфель — по умолчанию только просмотр. // Подачу заявок разрешаем точечно тестовым клиентам (флаг can_transfer). if u.CanTransfer { readOnly = false } } else { source = "позиции модуля ЛК" readOnly = false p2, err2 := lkClientPositions(r.Context(), rc, u.Contract) if err2 != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить портфель: " + err2.Error()}) return } pos = p2 } total, priced := lkApplyPrices(r.Context(), rc, pos) lkJSON(w, http.StatusOK, map[string]any{ "contract": u.Contract, "full_name": u.FullName, "positions": pos, "source": source, "read_only": readOnly, "total_value": total, // оценка портфеля, 0 — цен нет "priced": priced, // по скольким бумагам есть цена }) }) // Карточка бумаги: реквизиты выпуска, цена, позиция клиента, график выплат. mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin"))) if isin == "" { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указана бумага"}) return } var pos *ClientPosition if dp, err := depoClientPositions(r.Context(), rc.Snapshot().Fancy, u.Contract); err == nil && len(dp) > 0 { for _, p := range depoPositionsAsClient(dp) { if strings.EqualFold(p.ISIN, isin) { cp := p pos = &cp break } } } else if p2, err2 := lkClientPositions(r.Context(), rc, u.Contract); err2 == nil { for _, p := range markTransferable(p2) { if strings.EqualFold(p.ISIN, isin) { cp := p pos = &cp break } } } if pos != nil { one := []ClientPosition{*pos} lkApplyPrices(r.Context(), rc, one) pos = &one[0] } card, err := securityCard(r.Context(), rc, isin, pos) if err != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } lkJSON(w, http.StatusOK, map[string]any{"security": card}) }) // Движения по счетам депо за период. mux.HandleFunc("/api/lk/operations", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } q := r.URL.Query() ops, err := lkOperations(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to"), 200) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить операции: " + err.Error()}) return } lkJSON(w, http.StatusOK, map[string]any{"operations": ops, "from": q.Get("from"), "to": q.Get("to")}) }) // Выплаты дохода и удержанный налог. mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } q := r.URL.Query() rows, tax, err := lkIncome(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to")) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить доходы: " + err.Error()}) return } var accrued, paid, taxRows float64 for _, p := range rows { accrued += p.Accrued paid += p.Paid taxRows += p.Tax } if tax == 0 { tax = taxRows } lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax}) }) mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } rows, err := lkClaimsByContract(r.Context(), rc, u.Contract, 100) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } lkJSON(w, http.StatusOK, map[string]any{"claims": rows}) }) // --- Администратор ЛК: список клиентов и вход от их имени --- mux.HandleFunc("/api/lk/admin/clients", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } if !u.IsAdmin { lkJSON(w, http.StatusForbidden, map[string]string{"error": "доступно только администратору"}) return } fancy := rc.Snapshot().Fancy list, err := lkListUsers(r.Context(), rc) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } // Плюс реальные клиенты депозитария: их 1300+, учётки ЛК им не заводим — // админ ищет по номеру договора/ФИО и открывает кабинет на просмотр. search := strings.TrimSpace(r.URL.Query().Get("search")) depo, derr := depoListContracts(r.Context(), fancy, search, 100) resp := map[string]any{"clients": list, "depo": depo, "search": search} if derr != nil { resp["depo_error"] = derr.Error() } lkJSON(w, http.StatusOK, resp) }) mux.HandleFunc("/api/lk/admin/impersonate", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } if !u.IsAdmin { lkJSON(w, http.StatusForbidden, map[string]string{"error": "доступно только администратору"}) return } var in struct{ Login, Contract string } if json.NewDecoder(r.Body).Decode(&in) != nil || (strings.TrimSpace(in.Login) == "" && strings.TrimSpace(in.Contract) == "") { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указан клиент"}) return } // Вход в кабинет реального клиента депозитария — учётки ЛК у него нет, // сессия виртуальная и только на просмотр. if c := strings.TrimSpace(in.Contract); c != "" { full, ok := depoContractExists(r.Context(), rc.Snapshot().Fancy, c) if !ok { lkJSON(w, http.StatusNotFound, map[string]string{"error": "договор не найден в депозитарии: " + c}) return } name := full if i := strings.Index(full, " "); i > 0 { name = strings.TrimSpace(full[i:]) } target := LKUser{ Login: "depo:" + full, Contract: full, FullName: name, Active: true, ImpersonatedBy: u.Login, } lkJSON(w, http.StatusOK, map[string]any{ "session": sess.create(target), "full_name": name, "contract": full, "as_client": full, "by_admin": u.Login, }) return } list, err := lkListUsers(r.Context(), rc) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } for _, c := range list { if c.Login == in.Login { target := c target.ImpersonatedBy = u.Login // помечаем: вошёл админ lkJSON(w, http.StatusOK, map[string]any{ "session": sess.create(target), "full_name": c.FullName, "contract": c.Contract, "as_client": c.Login, "by_admin": u.Login, }) return } } lkJSON(w, http.StatusNotFound, map[string]string{"error": "клиент не найден"}) }) mux.HandleFunc("/api/lk/transfer", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } if r.Method != http.MethodPost { lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"}) return } var in LKTransferRequest if json.NewDecoder(r.Body).Decode(&in) != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"}) return } res, err := svc.CreateLKTransfer(r.Context(), rc, u, in) if err != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } lkJSON(w, http.StatusOK, res) }) } func lkJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) }