Files
Bridge-and-Join-s/internal/lkgateway/lkapi.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

508 lines
19 KiB
Go

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)
}