Files
Bridge-and-Join-s/internal/lkgateway/lkusers_admin.go
T
fontvielle d154506464 Настройка грузится быстро: список пользователей ЛК из кэша; морфинг — якорная привязка прокрутки
1) Страница «Настройка» висела до 10 с, когда ЛК ЕСИА Финанс медленный/недоступен: setupData
   синхронно тянул список пользователей из ЛК (lkUsersForSetup, таймаут 10 с). Теперь список
   берётся из кэша мгновенно, обновляется в фоне с коротким таймаутом (3 с); при недоступном ЛК
   показывается сохранённый список с пометкой. Страница больше не ждёт ЛК.
2) Морфинг карточки: добавлена якорная привязка — запоминаем верхний видимый блок и после
   обновления корректируем прокрутку, чтобы при изменении высоты карточек выше обзора вид не
   подпрыгивал (устраняет «прыгнула чуть выше»).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:42 +03:00

227 lines
9.3 KiB
Go

package lkgateway
// lkusers_admin.go — учётные записи кабинета клиента: управление из настроек ядра и
// первый администратор из веб-установщика кабинета.
//
// Пользователи кабинета живут только здесь, в lk.users: кабинет на хостинге своей
// базы не имеет. Здесь заводят клиентов и сотрудников, меняют пароли, блокируют.
// Письма никому не отправляются: почта хранится, но рассылка выключена — данные
// пока тестовые, и случайное письмо реальному человеку недопустимо.
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// globalLKSessions — сессии кабинета: нужны, чтобы гасить их при смене пароля и блокировке.
var globalLKSessions *lkSessions
// lkHasAdmin — есть ли хотя бы один администратор кабинета.
func lkHasAdmin(ctx context.Context, rc *RuntimeConfig) (bool, error) {
db, err := lkDB(rc)
if err != nil {
return false, err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var n int
if err := db.QueryRowContext(cctx, `SELECT count(*) FROM lk.users WHERE is_admin AND active`).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// lkUserSetFlags — включить/выключить учётную запись, права сотрудника, подачу поручений.
func lkUserSetFlags(ctx context.Context, rc *RuntimeConfig, id int64, column string, value bool) error {
switch column {
case "active", "is_admin", "can_transfer":
default:
return errors.New("неизвестный признак")
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
res, err := db.ExecContext(cctx, `UPDATE lk.users SET `+column+`=$1 WHERE id=$2`, value, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("учётная запись не найдена")
}
if globalLKSessions != nil {
globalLKSessions.forget(id)
if !value && column == "active" {
globalLKSessions.dropUser(id) // заблокированного выбрасываем сразу
}
}
return nil
}
// lkUserUpdate — контакты и договор.
func lkUserUpdate(ctx context.Context, rc *RuntimeConfig, id int64, contract, fullName, email, phone string) error {
if strings.TrimSpace(contract) == "" {
return errors.New("номер договора обязателен")
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = db.ExecContext(cctx, `UPDATE lk.users SET contract_number=$1, full_name=$2, email=$3, phone=$4 WHERE id=$5`,
strings.TrimSpace(contract), strings.TrimSpace(fullName), strings.TrimSpace(email), strings.TrimSpace(phone), id)
if err == nil && globalLKSessions != nil {
globalLKSessions.forget(id)
}
return err
}
// lkUserDelete — удалить учётную запись. Поручения и журнал остаются: они привязаны к договору.
func lkUserDelete(ctx context.Context, rc *RuntimeConfig, id int64) error {
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if _, err := db.ExecContext(cctx, `DELETE FROM lk.users WHERE id=$1`, id); err != nil {
return err
}
if globalLKSessions != nil {
globalLKSessions.dropUser(id)
}
return nil
}
// lkUserByLogin — учётная запись по логину (для смены пароля из настроек).
func lkUserIDByLogin(ctx context.Context, rc *RuntimeConfig, login string) (int64, error) {
db, err := lkDB(rc)
if err != nil {
return 0, err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var id int64
if err := db.QueryRowContext(cctx, `SELECT id FROM lk.users WHERE login=$1`, login).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, errors.New("учётная запись не найдена")
}
return 0, err
}
return id, nil
}
// lkUsersSave — POST /admin/setup/lk/users: заведение, пароль, признаки, удаление.
func (h *setupHandlers) lkUsersSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx := r.Context()
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
var err error
msg := "Пользователи кабинета: сохранено"
switch op := r.FormValue("op"); op {
case "create":
u := LKUser{
Login: strings.TrimSpace(r.FormValue("login")), Contract: strings.TrimSpace(r.FormValue("contract")),
FullName: strings.TrimSpace(r.FormValue("full_name")), Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")), Active: true,
IsAdmin: r.FormValue("is_admin") == "1", CanTransfer: r.FormValue("can_transfer") == "1",
}
if u.IsAdmin && u.Contract == "" {
u.Contract = "ADMIN" // у сотрудника своего договора нет
}
err = lkCreateUser(ctx, h.rc, u, r.FormValue("password"))
msg = fmt.Sprintf("Учётная запись «%s» создана. Пароль сообщите пользователю лично: письма кабинет не отправляет.", u.Login)
case "password":
login := strings.TrimSpace(r.FormValue("login"))
if err = lkSetPassword(ctx, h.rc, login, r.FormValue("password")); err == nil {
if uid, e := lkUserIDByLogin(ctx, h.rc, login); e == nil && globalLKSessions != nil {
globalLKSessions.dropUser(uid) // старые сессии после смены пароля не живут
}
msg = fmt.Sprintf("Пароль «%s» изменён, его сессии завершены.", login)
}
case "update":
err = lkUserUpdate(ctx, h.rc, id, r.FormValue("contract"), r.FormValue("full_name"), r.FormValue("email"), r.FormValue("phone"))
case "block", "unblock":
err = lkUserSetFlags(ctx, h.rc, id, "active", op == "unblock")
msg = map[bool]string{true: "Учётная запись разблокирована", false: "Учётная запись заблокирована, сессии завершены"}[op == "unblock"]
case "admin_on", "admin_off":
err = lkUserSetFlags(ctx, h.rc, id, "is_admin", op == "admin_on")
case "transfer_on", "transfer_off":
err = lkUserSetFlags(ctx, h.rc, id, "can_transfer", op == "transfer_on")
case "delete":
err = lkUserDelete(ctx, h.rc, id)
msg = "Учётная запись удалена"
default:
err = errors.New("неизвестное действие")
}
if err != nil {
setupFlash(w, r, "Пользователи кабинета: "+err.Error())
return
}
setupFlash(w, r, msg)
}
// lkUsersForSetup — список для страницы настроек; ошибка базы — строкой, чтобы страница открылась.
// lkUsersCache — список пользователей ЛК для страницы «Настройка». Берётся из кэша (мгновенно),
// обновляется в фоне с коротким таймаутом. Иначе медленный/недоступный ЛК вешал страницу на 10 с.
var lkUsersCache struct {
sync.Mutex
list []LKUser
errMsg string
at time.Time
refreshing bool
}
func lkUsersRefresh(rc *RuntimeConfig) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
list, err := lkListUsers(ctx, rc)
lkUsersCache.Lock()
defer lkUsersCache.Unlock()
lkUsersCache.refreshing = false
lkUsersCache.at = time.Now()
if err != nil {
if lkUsersCache.list != nil { // ЛК не ответил — оставляем прошлый список с пометкой
lkUsersCache.errMsg = "ЛК сейчас недоступен — показан сохранённый список (" + err.Error() + ")"
return
}
lkUsersCache.errMsg = err.Error()
return
}
lkUsersCache.list, lkUsersCache.errMsg = list, ""
}
// lkUsersForSetup — список пользователей ЛК для страницы «Настройка»: отдаёт кэш сразу, при
// устаревании обновляет в фоне (страница не ждёт ЛК). Первый раз (холодный кэш) — одно короткое ожидание.
func lkUsersForSetup(rc *RuntimeConfig) ([]LKUser, string) {
lkUsersCache.Lock()
cold := lkUsersCache.at.IsZero()
stale := time.Since(lkUsersCache.at) > 20*time.Second
if !cold && stale && !lkUsersCache.refreshing {
lkUsersCache.refreshing = true
go lkUsersRefresh(rc)
}
list, msg := lkUsersCache.list, lkUsersCache.errMsg
lkUsersCache.Unlock()
if cold {
lkUsersRefresh(rc) // первый заход — подождём коротко (≤3 с), дальше всегда из кэша
lkUsersCache.Lock()
list, msg = lkUsersCache.list, lkUsersCache.errMsg
lkUsersCache.Unlock()
}
return list, msg
}