package lkgateway import ( "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "strings" "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 // registerLKAPI вешает /api/lk/* — интерфейс для модуля личного кабинета. func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { sess := newLKSessions() sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork } globalLKSessions = sess // проверка ключа модуля 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-токену. Карточка читается из lk.users на каждом // запросе (с коротким кэшем), поэтому блокировка учётной записи и снятие прав // администратора действуют сразу, а не после истечения сессии. current := func(w http.ResponseWriter, r *http.Request) (LKUser, bool) { v, ok := sess.get(r.Header.Get("X-BJ-Session"), r) if !ok { lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "сессия истекла, войдите заново"}) return LKUser{}, false } u, err := sess.user(r.Context(), rc, v) if err != nil { sess.drop(r.Header.Get("X-BJ-Session")) lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.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(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"}) return } login := strings.TrimSpace(in.Login) // Защита от перебора: логин клиента — это обычно номер договора, то есть // пространство перебора маленькое. Считаем неудачи и по адресу, и по логину. byIP := "lk-ip:" + lkClientAddr(r) byLogin := "lk-login:" + strings.ToLower(login) if loginFailCount(byIP) >= 10 || loginFailCount(byLogin) >= 8 { time.Sleep(700 * time.Millisecond) lkJSON(w, http.StatusTooManyRequests, map[string]string{"error": "слишком много попыток входа, повторите через 15 минут"}) return } u, err := lkAuth(r.Context(), rc, login, in.Password) if err != nil { loginFailAdd(byIP) loginFailAdd(byLogin) time.Sleep(300 * time.Millisecond) // и неудача, и перебор одинаково медленные lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) return } loginFailReset(byIP) loginFailReset(byLogin) tok, err := sess.create(lkSession{UserID: u.ID, Login: u.Login, Contract: u.Contract, IsAdmin: u.IsAdmin, CanTransfer: u.CanTransfer}, r) if err != nil { lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return } lkJSON(w, http.StatusOK, map[string]any{ "session": tok, "full_name": u.FullName, "contract": u.Contract, "is_admin": u.IsAdmin, "ttl_sec": int(lkSessionTTL.Seconds()), }) }) // Первый администратор кабинета из веб-установщика. Только по ключу модуля и только // пока администраторов нет: потом учётные записи заводит администратор в настройках. mux.HandleFunc("/api/lk/admin/bootstrap", 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 } has, err := lkHasAdmin(r.Context(), rc) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": "база кабинета недоступна: " + err.Error()}) return } if has { lkJSON(w, http.StatusConflict, map[string]string{"error": "администратор кабинета уже создан — войдите под ним или пропустите шаг"}) return } var in struct{ Login, Password, FullName, Email string } if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"}) return } u := LKUser{Login: strings.TrimSpace(in.Login), Contract: "ADMIN", FullName: strings.TrimSpace(in.FullName), Email: strings.TrimSpace(in.Email), Active: true, IsAdmin: true} if err := lkCreateUser(r.Context(), rc, u, in.Password); err != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } raiseSystemAlert(rc, "Кабинет: создан администратор", fmt.Sprintf("Из установщика кабинета создан администратор «%s».", u.Login)) lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "администратор создан"}) }) 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/corpactions", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return } u, ok := current(w, r) if !ok { return } fancy := rc.Snapshot().Fancy var isins []string if dp, err := depoClientPositions(r.Context(), fancy, u.Contract); err == nil { for _, p := range depoPositionsAsClient(dp) { if p.ISIN != "" { isins = append(isins, p.ISIN) } } } acts, err := lkCorpActions(r.Context(), fancy, isins) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить корпоративные действия: " + err.Error()}) return } lkJSON(w, http.StatusOK, map[string]any{"actions": acts}) }) // Документы: готовые выписки депозитария и заявки клиента. mux.HandleFunc("/api/lk/documents", 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 { var in struct { Kind string `json:"kind"` From string `json:"from"` To string `json:"to"` Account string `json:"account"` Comment string `json:"comment"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не разобрали заявку"}) return } if err := lkDocRequestAdd(r.Context(), rc, u.Contract, u.FullName, in.Kind, in.From, in.To, in.Account, in.Comment); err != nil { lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } notifyDocRequest(rc, u, in.Kind, in.From, in.To) lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Заявка принята: депозитарий подготовит документ и он появится в этом разделе."}) return } ready, err := lkReadyDocuments(r.Context(), rc.Snapshot().Fancy, u.Contract) if err != nil { log.Printf("lk: документы депозитария: %v", err) } reqs, err2 := lkDocRequests(r.Context(), rc, u.Contract) if err2 != nil { log.Printf("lk: заявки на документы: %v", err2) } lkJSON(w, http.StatusOK, map[string]any{"ready": ready, "requests": reqs, "kinds": lkDocKinds}) }) 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:]) } tok, err := sess.create(lkSession{Login: "depo:" + full, Contract: full, Virtual: true, VirtualName: name, ImpersonatedBy: u.Login}, r) if err != nil { lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return } lkAuditImpersonate(r.Context(), rc, u.Login, "depo:"+full, full, lkClientAddr(r)) lkJSON(w, http.StatusOK, map[string]any{ "session": tok, "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 { tok, err := sess.create(lkSession{UserID: c.ID, Login: c.Login, Contract: c.Contract, ImpersonatedBy: u.Login}, r) // помечаем: вошёл сотрудник, только просмотр if err != nil { lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return } lkAuditImpersonate(r.Context(), rc, u.Login, c.Login, c.Contract, lkClientAddr(r)) lkJSON(w, http.StatusOK, map[string]any{ "session": tok, "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) }