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
225 lines
9.3 KiB
Go
225 lines
9.3 KiB
Go
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")
|
||
}
|