4cbefa41d5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
639 lines
25 KiB
Go
639 lines
25 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"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
|
|
globalLKCabinet = newLKCabinet(mux, rc) // кабинет внутри ядра: страницы для ретранслятора
|
|
|
|
// проверка ключа модуля
|
|
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
|
|
}
|
|
|
|
globalLKCabinet.registerRender(mux, auth)
|
|
|
|
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": lkClientName(*u),
|
|
"contract": lkContractNumber(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
|
|
}
|
|
// Проверка «админа ещё нет» и создание — под одним замком: иначе два
|
|
// параллельных bootstrap оба увидят «нет» и заведут двух администраторов.
|
|
lkBootstrapMu.Lock()
|
|
defer lkBootstrapMu.Unlock()
|
|
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": lkContractNumber(u.Contract),
|
|
"full_name": lkClientName(u),
|
|
"positions": pos,
|
|
"source": source,
|
|
"read_only": readOnly,
|
|
"total_value": total, // оценка портфеля, 0 — цен нет
|
|
"priced": priced, // по скольким бумагам есть цена
|
|
// Контакты из анкеты: клиент видит, куда депозитарий будет писать и звонить,
|
|
// и сразу заметит, если данные устарели. Менять их в кабинете нельзя.
|
|
"email": u.Email, "phone": u.Phone,
|
|
"last_login": lkTimeRU(u.LastLoginAt),
|
|
})
|
|
})
|
|
|
|
// Карточка бумаги: реквизиты выпуска, цена, позиция клиента, график выплат.
|
|
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")})
|
|
})
|
|
|
|
// Портфель клиента как у брокера: view=online — по экспорту QUIK (что есть сейчас), если
|
|
// в QUIK клиента нет — по брокерскому учёту на сегодня; view=archive&date=YYYY-MM-DD —
|
|
// портфель на дату по брокерскому учёту (SPECTRE).
|
|
// История стоимости портфеля: ежедневные срезы (lksnapshot.go), для графика.
|
|
mux.HandleFunc("/api/lk/history", func(w http.ResponseWriter, r *http.Request) {
|
|
if !auth(w, r) {
|
|
return
|
|
}
|
|
u, ok := current(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
days := 180
|
|
if v, err := strconv.Atoi(r.URL.Query().Get("days")); err == nil && v > 0 {
|
|
days = v
|
|
}
|
|
points, err := lkSnapshotHistory(r.Context(), rc, u.Contract, days)
|
|
if err != nil {
|
|
lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
set := rc.Snapshot().LK
|
|
hour, min := lkSnapshotTime(set)
|
|
lkJSON(w, http.StatusOK, map[string]any{"points": points, "on": lkSnapshotEnabled(rc),
|
|
"at": fmt.Sprintf("%02d:%02d", hour, min), "days": days})
|
|
})
|
|
|
|
mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) {
|
|
if !auth(w, r) {
|
|
return
|
|
}
|
|
u, ok := current(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
fancy := rc.Snapshot().Fancy
|
|
q := r.URL.Query()
|
|
var pf *LKPortfolio
|
|
var err error
|
|
switch q.Get("view") {
|
|
case "archive":
|
|
day := time.Now()
|
|
if d, e := time.ParseInLocation("2006-01-02", q.Get("date"), time.Local); e == nil {
|
|
day = d
|
|
}
|
|
pf, err = lkArchivePortfolio(r.Context(), fancy, u.Contract, day)
|
|
default:
|
|
pf, err = lkOnlinePortfolio(r.Context(), rc, u.Contract)
|
|
if err == nil && len(pf.Positions) == 0 && len(pf.Cash) == 0 {
|
|
// В торговой системе клиента нет (не торгует через QUIK) — текущий портфель из учёта.
|
|
if ar, e := lkArchivePortfolio(r.Context(), fancy, u.Contract, time.Now()); e == nil {
|
|
ar.Note = strings.TrimSpace(strings.TrimPrefix("в торговой системе позиций нет; портфель по брокерскому учёту на "+ar.AsOf+"; "+ar.Note, "; "))
|
|
ar.Note = strings.TrimSuffix(ar.Note, "; ")
|
|
pf = ar
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
pf.Note = strings.TrimSpace(pf.Note)
|
|
resp := map[string]any{"portfolio": pf, "contract": lkContractNumber(u.Contract), "full_name": lkClientName(u)}
|
|
if q.Get("view") != "archive" {
|
|
resp["upcoming"] = lkUpcoming(r.Context(), fancy, pf.Positions, 90) // ближайшие выплаты за 90 дней
|
|
}
|
|
lkJSON(w, http.StatusOK, resp)
|
|
})
|
|
|
|
// Операции по брокерскому счёту: сделки и движения бумаг (SPECTRE) плюс выплаты дохода (учёт).
|
|
mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) {
|
|
if !auth(w, r) {
|
|
return
|
|
}
|
|
u, ok := current(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
fancy := rc.Snapshot().Fancy
|
|
q := r.URL.Query()
|
|
fromT, toT := parseRange(q.Get("from"), q.Get("to"))
|
|
rows, err := lkArchiveOperations(r.Context(), fancy, u.Contract, fromT, toT)
|
|
if err != nil {
|
|
rows = nil // без брокерского учёта показываем хотя бы выплаты
|
|
}
|
|
if inc, _, e := lkIncome(r.Context(), fancy, u.Contract, q.Get("from"), q.Get("to")); e == nil {
|
|
for _, p := range inc {
|
|
rows = append(rows, LKOperationRow{Date: p.Date, Kind: p.Kind, Side: "in", Name: p.Security, ISIN: p.ISIN,
|
|
Quantity: p.Quantity, Amount: p.Paid, Currency: "SUR", AmountRUB: p.Paid})
|
|
}
|
|
}
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
di, _ := time.Parse("02.01.2006", rows[i].Date)
|
|
dj, _ := time.Parse("02.01.2006", rows[j].Date)
|
|
return di.After(dj)
|
|
})
|
|
resp := map[string]any{"operations": rows, "from": fromT.Format("2006-01-02"), "to": toT.Format("2006-01-02")}
|
|
if err != nil {
|
|
resp["note"] = err.Error()
|
|
}
|
|
lkJSON(w, http.StatusOK, resp)
|
|
})
|
|
|
|
// Выплаты дохода и удержанный налог.
|
|
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)
|
|
// Учётки кабинета ищем тем же запросом: сотрудник вводит фамилию или
|
|
// номер договора, а где заведена учётка — ему помнить не нужно.
|
|
if search != "" {
|
|
list = lkFilterUsers(list, search)
|
|
}
|
|
resp := map[string]any{"clients": list, "depo": depo, "search": search,
|
|
"suggest": depoSuggestContracts(r.Context(), fancy)}
|
|
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": lkContractNumber(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": "клиент не найден"})
|
|
})
|
|
|
|
// Состояние обмена с НРД для баннера кабинета: пусто — работает (nsdhealth.go).
|
|
mux.HandleFunc("/api/lk/nsd-status", func(w http.ResponseWriter, r *http.Request) {
|
|
if !auth(w, r) {
|
|
return
|
|
}
|
|
h := nsdHealthNow(rc, time.Now())
|
|
lkJSON(w, http.StatusOK, map[string]any{"ok": nsdClientNotice(h) == "", "notice": nsdClientNotice(h)})
|
|
})
|
|
|
|
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)
|
|
}
|