d154506464
1) Страница «Настройка» висела до 10 с, когда ЛК ЕСИА Финанс медленный/недоступен: setupData синхронно тянул список пользователей из ЛК (lkUsersForSetup, таймаут 10 с). Теперь список берётся из кэша мгновенно, обновляется в фоне с коротким таймаутом (3 с); при недоступном ЛК показывается сохранённый список с пометкой. Страница больше не ждёт ЛК. 2) Морфинг карточки: добавлена якорная привязка — запоминаем верхний видимый блок и после обновления корректируем прокрутку, чтобы при изменении высоты карточек выше обзора вид не подпрыгивал (устраняет «прыгнула чуть выше»). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
9.3 KiB
Go
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
|
|
}
|