feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
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
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"contract": u.Contract,
|
||||
"full_name": u.FullName,
|
||||
"positions": pos,
|
||||
"source": source,
|
||||
"read_only": readOnly,
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user