Files
Bridge-and-Join-s/internal/lkgateway/lkusers.go
zuevav 510a34b7bc Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле
Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной
системы было нельзя: в DEPO он от 31.05.2026, в SPECTRE — от 18.07.2026.
Берём официальный курс Банка России (открытый XML, без ключа — тем же каналом
пользуется Инвест-хаб) и складываем по дням в lk.fx_rates: без интернета
кабинет считает по последнему известному курсу, а не отказывается оценивать.
Источник переключается в настройках («Данные кабинета»), там же кнопка
«Обновить курсы сейчас». Портфель БР-719: 560,3 млн ₽ против 522 млн по
старому курсу.

История стоимости. Ни QUIK, ни SPECTRE её не хранят, поэтому раз в день после
закрытия торгов считаем стоимость каждого портфеля по экспорту QUIK и пишем
строку в lk.portfolio_daily (257 портфелей за 14 секунд). Кабинет рисует по
ним график на странице портфеля — SVG собирается на сервере, скриптов в
кабинете по-прежнему нет. Режим по умолчанию — «автоматически»: ведём, если
настроен экспорт QUIK, потому что задним числом историю не восстановить.

Операции. Связка «JOIN OD_RESTS ON sr.ID = CASE WHEN w.D_REST IN (…)»
заставляла SQL Server разбирать список счетов на каждой строке журнала
проводок: год данных читался 6–7 секунд. Обе стороны проводки выбираются
заранее двумя ветвями UNION ALL, индексы IX_WIRING_D_REST_WIRDATE и
IX_WIRING_K_REST_WIRDATE отрабатывают поиском — 89 мс на тех же 64 строках.
Период по умолчанию вернули к году, ответ кэшируется на пять минут.

Профиль: почта, телефон и прошлый вход из анкеты — клиент видит, куда с ним
свяжется депозитарий, и заметит устаревшие данные. Правка только через
менеджера. Заказ документов из кабинета убран совсем (форма была убрана
раньше, теперь нет и обработчика).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 19:25:18 +03:00

225 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"errors"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Пользователи Личного кабинета клиента. Хранятся в PostgreSQL bj (lk.users), пароль —
// только bcrypt-хеш. Клиент входит по логину/паролю, его портфель и заявки определяются
// номером договора (contract_number).
// LKUser — учётная запись клиента в Личном кабинете.
type LKUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Contract string `json:"contract_number"`
FullName string `json:"full_name"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
MiddleName string `json:"middle_name"`
DocType string `json:"doc_type"`
DocSeries string `json:"doc_series"`
DocNumber string `json:"doc_number"`
Email string `json:"email"`
Phone string `json:"phone"`
Active bool `json:"active"`
IsAdmin bool `json:"is_admin"`
// CanTransfer — клиенту разрешена подача заявок на перевод. Для реальных
// депозитарных клиентов false (только просмотр); включаем точечно тестовым.
CanTransfer bool `json:"can_transfer"`
// ImpersonatedBy — логин администратора, если он вошёл от имени этого клиента.
ImpersonatedBy string `json:"impersonated_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at,omitempty"`
}
var errLKAuth = errors.New("неверный логин или пароль")
// lkCreateUser заводит пользователя ЛК (пароль хешируется bcrypt).
func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password string) error {
if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" {
return errors.New("логин и номер договора обязательны")
}
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
_, err = db.ExecContext(cctx, `INSERT INTO lk.users (login, password_hash, contract_number, full_name, email, phone, active,
last_name, first_name, middle_name, doc_type, doc_series, doc_number, is_admin, can_transfer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
u.Login, string(hash), u.Contract, u.FullName, u.Email, u.Phone, u.Active,
u.LastName, u.FirstName, u.MiddleName, u.DocType, u.DocSeries, u.DocNumber, u.IsAdmin, u.CanTransfer)
return err
}
// lkSetPassword меняет пароль пользователя ЛК.
func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error {
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
res, err := db.ExecContext(cctx, "UPDATE lk.users SET password_hash=$1 WHERE login=$2", string(hash), login)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("пользователь не найден")
}
return nil
}
// lkAuth проверяет логин/пароль клиента. Возвращает пользователя при успехе.
func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var u LKUser
var hash string
if err := db.QueryRowContext(cctx, `SELECT id, login, password_hash, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active,
COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''),
COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer
FROM lk.users WHERE login=$1`, login).Scan(&u.ID, &u.Login, &hash, &u.Contract,
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
// Логина нет. Всё равно считаем bcrypt по фиктивному хешу: иначе ответ
// приходит заметно быстрее и по времени можно перебрать существующие логины.
_ = bcrypt.CompareHashAndPassword([]byte(lkDummyHash), []byte(password))
return nil, errLKAuth // не раскрываем, что логина нет
}
if !u.Active {
return nil, errors.New("учётная запись заблокирована")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return nil, errLKAuth
}
_, _ = db.ExecContext(cctx, "UPDATE lk.users SET last_login_at=now() WHERE id=$1", u.ID) // отметка входа, не критично
return &u, nil
}
// lkListUsers — список пользователей ЛК (для админки).
func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active, is_admin, can_transfer
FROM lk.users ORDER BY login`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKUser
for rows.Next() {
var u LKUser
if err := rows.Scan(&u.ID, &u.Login, &u.Contract, &u.FullName, &u.Email, &u.Phone, &u.Active, &u.IsAdmin, &u.CanTransfer); err != nil {
return out, err
}
out = append(out, u)
}
return out, rows.Err()
}
// lkDummyHash — заведомо не подходящий bcrypt-хеш: по нему считаем проверку, когда
// логина нет, чтобы ответ занимал столько же времени, сколько при существующем логине.
const lkDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// lkCheckPassword — требования к паролю кабинета: в кабинете видны паспортные данные
// и остатки на счетах депо, поэтому шести символов мало.
func lkCheckPassword(password string) error {
if len([]rune(password)) < 10 {
return errors.New("пароль минимум 10 символов")
}
var hasLetter, hasOther bool
for _, r := range password {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= 'а' && r <= 'я') || (r >= 'А' && r <= 'Я'):
hasLetter = true
default:
hasOther = true
}
}
if !hasLetter || !hasOther {
return errors.New("пароль должен содержать буквы и хотя бы одну цифру или знак")
}
return nil
}
// lkUserByID — карточка клиента по идентификатору. Нужна сессиям: на диске хранится
// только ссылка на учётную запись, а сами данные читаются отсюда на каждый запрос.
func lkUserByID(ctx context.Context, rc *RuntimeConfig, id int64) (*LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var u LKUser
if err := db.QueryRowContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active,
COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''),
COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer
FROM lk.users WHERE id=$1`, id).Scan(&u.ID, &u.Login, &u.Contract,
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
return nil, errors.New("учётная запись не найдена")
}
return &u, nil
}
// lkFilterUsers — учётки кабинета, подходящие под строку поиска сотрудника
// (часть логина, номера договора, ФИО или почты; регистр не важен).
func lkFilterUsers(list []LKUser, search string) []LKUser {
q := strings.ToLower(strings.TrimSpace(search))
if q == "" {
return list
}
out := make([]LKUser, 0, len(list))
for _, u := range list {
hay := strings.ToLower(u.Login + " " + u.Contract + " " + u.FullName + " " + u.Email)
if strings.Contains(hay, q) {
out = append(out, u)
}
}
return out
}
// lkTimeRU — время для кабинета: «18.09.2026 19:40», пусто — если не было.
func lkTimeRU(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Local().Format("02.01.2006 15:04")
}