434 lines
18 KiB
Erlang
434 lines
18 KiB
Erlang
-module(core_user).
|
||
-include("records.hrl").
|
||
-export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3,
|
||
delete/1, update_last_login/1]).
|
||
-export([email_exists/1]).
|
||
-export([list_users/0]).
|
||
-export([block/2, unblock/2]).
|
||
-export([count_users/0, count_users_by_date/2, count_pending_users/0, count_pending_users_by_date/2, list_all/0]).
|
||
-export([count_users_by_role/0, count_users_by_status/0]).
|
||
-export([create_bot/2, delete_bot/1]).
|
||
|
||
%% ─────────────────────────────────────────────────────────────────
|
||
%% Значения по умолчанию для необязательных полей
|
||
%% ─────────────────────────────────────────────────────────────────
|
||
-define(DEFAULT_REASON, <<>>).
|
||
-define(DEFAULT_AVATAR_URL, <<"default">>).
|
||
-define(DEFAULT_TIMEZONE, <<>>).
|
||
-define(DEFAULT_LANGUAGE, <<>>).
|
||
-define(DEFAULT_SOCIAL_LINKS, []).
|
||
-define(DEFAULT_PHONE, <<>>).
|
||
-define(DEFAULT_PREFERENCES, #{}).
|
||
-define(DEFAULT_LAST_LOGIN, {{1970,1,1},{0,0,0}}).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Создание обычного пользователя.
|
||
%%% Если email уже существует, возвращает ошибку `{error, email_exists}`.
|
||
%%% Все необязательные поля инициализируются значениями по умолчанию
|
||
%%% (пустые строки, пустой список и т.д.), чтобы избежать `undefined` в базе.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec create(Email :: binary(), Password :: binary()) ->
|
||
{ok, #user{}} | {error, email_exists | term()}.
|
||
create(Email, Password) ->
|
||
case email_exists(Email) of
|
||
true ->
|
||
{error, email_exists};
|
||
false ->
|
||
Id = infra_utils:generate_id(16),
|
||
{ok, PasswordHash} = logic_auth:hash_password(Password),
|
||
Now = calendar:universal_time(),
|
||
User = #user{
|
||
id = Id,
|
||
email = Email,
|
||
password_hash = PasswordHash,
|
||
role = user,
|
||
status = pending,
|
||
reason = ?DEFAULT_REASON,
|
||
nickname = extract_nickname(Email),
|
||
avatar_url = ?DEFAULT_AVATAR_URL,
|
||
timezone = ?DEFAULT_TIMEZONE,
|
||
language = ?DEFAULT_LANGUAGE,
|
||
social_links = ?DEFAULT_SOCIAL_LINKS,
|
||
phone = ?DEFAULT_PHONE,
|
||
preferences = ?DEFAULT_PREFERENCES,
|
||
last_login = ?DEFAULT_LAST_LOGIN,
|
||
created_at = Now,
|
||
updated_at = Now
|
||
},
|
||
F = fun() -> mnesia:write(User), {ok, User} end,
|
||
case mnesia:transaction(F) of
|
||
{atomic, Result} -> Result;
|
||
{aborted, Reason} -> {error, Reason}
|
||
end
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Получить пользователя по идентификатору.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec get_by_id(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
|
||
get_by_id(Id) ->
|
||
case mnesia:dirty_read(user, Id) of
|
||
[] -> {error, not_found};
|
||
[User] -> {ok, User}
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Получить пользователя по email.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec get_by_email(Email :: binary()) -> {ok, #user{}} | {error, not_found}.
|
||
get_by_email(Email) ->
|
||
Match = #user{email = Email, _ = '_'},
|
||
case mnesia:dirty_match_object(Match) of
|
||
[] -> {error, not_found};
|
||
[User] -> {ok, User}
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Проверить, существует ли email в базе.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec email_exists(Email :: binary()) -> boolean().
|
||
email_exists(Email) ->
|
||
case get_by_email(Email) of
|
||
{ok, _} -> true;
|
||
{error, _} -> false
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Обновить поля пользователя.
|
||
%%% `Updates` – список пар `{atom(), term()}`.
|
||
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
|
||
{ok, #user{}} | {error, not_found | term()}.
|
||
update(Id, Updates) ->
|
||
F = fun() ->
|
||
case mnesia:read(user, Id) of
|
||
[] -> {error, not_found};
|
||
[User] ->
|
||
UpdatedUser = apply_updates(User, Updates),
|
||
mnesia:write(UpdatedUser),
|
||
{ok, UpdatedUser}
|
||
end
|
||
end,
|
||
case mnesia:transaction(F) of
|
||
{atomic, Result} -> Result;
|
||
{aborted, Reason} -> {error, Reason}
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Обновить время последнего входа пользователя.
|
||
%%% Устанавливает `last_login` в текущее UTC-время.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
|
||
update_last_login(Id) ->
|
||
case get_by_id(Id) of
|
||
{ok, User} ->
|
||
Updated = User#user{last_login = calendar:universal_time()},
|
||
mnesia:dirty_write(Updated),
|
||
{ok, Updated};
|
||
Error -> Error
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Изменить статус и причину пользователя.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec update_status(Id :: binary(), Status :: atom(), Reason :: binary()) ->
|
||
{ok, #user{}} | {error, not_found}.
|
||
update_status(Id, Status, Reason) ->
|
||
case get_by_id(Id) of
|
||
{ok, User} ->
|
||
Updated = User#user{status = Status, reason = Reason,
|
||
updated_at = calendar:universal_time()},
|
||
mnesia:dirty_write(Updated),
|
||
{ok, Updated};
|
||
Error -> Error
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Мягкое удаление пользователя (установка статуса `deleted`).
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec delete(Id :: binary()) -> {ok, #user{}} | {error, not_found | term()}.
|
||
delete(Id) ->
|
||
update(Id, [{status, deleted}]).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Получить список активных пользователей (без удалённых).
|
||
%%% Результат – список карт с ключами `id`, `email`, `role`, `status`, `reason`,
|
||
%%% `created_at`, `updated_at`.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec list_users() -> {ok, [map()]}.
|
||
list_users() ->
|
||
Users = mnesia:dirty_match_object(#user{_ = '_'}),
|
||
ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
|
||
{ok, [user_to_map(U) || U <- ActiveUsers]}.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Преобразование записи пользователя в map.
|
||
%%% Используется для API‑ответов.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec user_to_map(#user{} | map()) -> map().
|
||
user_to_map(User) when is_map(User) ->
|
||
#{
|
||
id => maps:get(id, User),
|
||
email => maps:get(email, User),
|
||
role => maps:get(role, User, <<"user">>),
|
||
status => maps:get(status, User, <<"active">>),
|
||
reason => maps:get(reason, User, ?DEFAULT_REASON),
|
||
created_at => maps:get(created_at, User),
|
||
updated_at => maps:get(updated_at, User)
|
||
};
|
||
user_to_map(User) ->
|
||
#{
|
||
id => User#user.id,
|
||
email => User#user.email,
|
||
role => atom_to_binary(User#user.role, utf8),
|
||
status => atom_to_binary(User#user.status, utf8),
|
||
reason => User#user.reason,
|
||
created_at => User#user.created_at,
|
||
updated_at => User#user.updated_at
|
||
}.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Заблокировать пользователя с указанием причины.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec block(Id :: binary(), Reason :: binary()) ->
|
||
{ok, #user{}} | {error, not_found}.
|
||
block(Id, Reason) ->
|
||
case get_by_id(Id) of
|
||
{ok, User} ->
|
||
Updated = User#user{status = blocked, reason = Reason,
|
||
updated_at = calendar:universal_time()},
|
||
mnesia:dirty_write(Updated),
|
||
{ok, Updated};
|
||
Error -> Error
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Разблокировать пользователя с указанием причины.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec unblock(Id :: binary(), Reason :: binary()) ->
|
||
{ok, #user{}} | {error, not_found}.
|
||
unblock(Id, Reason) ->
|
||
case get_by_id(Id) of
|
||
{ok, User} ->
|
||
Updated = User#user{status = active, reason = Reason,
|
||
updated_at = calendar:universal_time()},
|
||
mnesia:dirty_write(Updated),
|
||
{ok, Updated};
|
||
Error -> Error
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Извлекает локальную часть email (до символа `@`).
|
||
%%% Если `@` отсутствует, возвращает email целиком.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec extract_nickname(Email :: binary()) -> binary().
|
||
extract_nickname(Email) ->
|
||
case binary:split(Email, <<"@">>) of
|
||
[LocalPart, _Domain] -> LocalPart;
|
||
_ -> Email
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Количество всех записей в таблице `user`.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec count_users() -> non_neg_integer().
|
||
count_users() ->
|
||
mnesia:table_info(user, size).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Получить всех пользователей (включая удалённых).
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec list_all() -> [#user{}].
|
||
list_all() ->
|
||
mnesia:dirty_match_object(#user{_ = '_'}).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Подсчёт пользователей, созданных в заданном временном диапазоне.
|
||
%%% Возвращает список кортежей `{{Year,Month,Day}, Count}`, отсортированный по дате.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec count_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||
count_users_by_date(From, To) ->
|
||
All = mnesia:dirty_match_object(#user{_ = '_'}),
|
||
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
|
||
U#user.created_at =< To end, All),
|
||
Counts = lists:foldl(fun(U, Acc) ->
|
||
Day = date_part(U#user.created_at),
|
||
case lists:keyfind(Day, 1, Acc) of
|
||
false -> [{Day, 1} | Acc];
|
||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||
end
|
||
end, [], Filtered),
|
||
lists:sort(Counts).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Количество неподтверждённых пользователей (status = pending).
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec count_pending_users() -> non_neg_integer().
|
||
count_pending_users() ->
|
||
Match = #user{status = pending, _ = '_'},
|
||
length(mnesia:dirty_match_object(Match)).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||
count_pending_users_by_date(From, To) ->
|
||
All = mnesia:dirty_match_object(#user{_ = '_'}),
|
||
Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All),
|
||
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
|
||
U#user.created_at =< To end, Pending),
|
||
Counts = lists:foldl(fun(U, Acc) ->
|
||
Day = date_part(U#user.created_at),
|
||
case lists:keyfind(Day, 1, Acc) of
|
||
false -> [{Day, 1} | Acc];
|
||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||
end
|
||
end, [], Filtered),
|
||
lists:sort(Counts).
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Подсчёт пользователей по ролям.
|
||
%%% Возвращает список [{Role :: atom(), Count :: non_neg_integer()}].
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec count_users_by_role() -> [{atom(), non_neg_integer()}].
|
||
count_users_by_role() ->
|
||
Users = mnesia:dirty_match_object(#user{_ = '_'}),
|
||
Dict = lists:foldl(fun(#user{role = Role}, Acc) ->
|
||
case lists:keyfind(Role, 1, Acc) of
|
||
false -> [{Role, 1} | Acc];
|
||
{Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1})
|
||
end
|
||
end, [], Users),
|
||
Dict.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Подсчёт пользователей по статусам.
|
||
%%% Возвращает список [{Status :: atom(), Count :: non_neg_integer()}].
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec count_users_by_status() -> [{atom(), non_neg_integer()}].
|
||
count_users_by_status() ->
|
||
Users = mnesia:dirty_match_object(#user{_ = '_'}),
|
||
Dict = lists:foldl(fun(#user{status = Status}, Acc) ->
|
||
case lists:keyfind(Status, 1, Acc) of
|
||
false -> [{Status, 1} | Acc];
|
||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||
end
|
||
end, [], Users),
|
||
Dict.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec date_part(calendar:datetime()) -> {pos_integer(), pos_integer(), pos_integer()}.
|
||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Применить список обновлений к записи пользователя.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec apply_updates(#user{}, [{atom(), term()}]) -> #user{}.
|
||
apply_updates(User, Updates) ->
|
||
Updated = lists:foldl(fun({Field, Value}, U) ->
|
||
set_field(Field, Value, U)
|
||
end, User, Updates),
|
||
Updated#user{updated_at = calendar:universal_time()}.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Установить одно поле записи. Неизвестные поля игнорируются.
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec set_field(atom(), term(), #user{}) -> #user{}.
|
||
set_field(email, Value, U) -> U#user{email = Value};
|
||
set_field(password_hash, Value, U) -> U#user{password_hash = Value};
|
||
set_field(role, Value, U) when Value =:= user; Value =:= bot ->
|
||
U#user{role = Value};
|
||
set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted; Value =:= pending ->
|
||
U#user{status = Value};
|
||
set_field(reason, Value, U) -> U#user{reason = Value};
|
||
set_field(nickname, Value, U) -> U#user{nickname = Value};
|
||
set_field(avatar_url, Value, U) -> U#user{avatar_url = Value};
|
||
set_field(timezone, Value, U) -> U#user{timezone = Value};
|
||
set_field(language, Value, U) -> U#user{language = Value};
|
||
set_field(social_links, Value, U) -> U#user{social_links = Value};
|
||
set_field(phone, Value, U) -> U#user{phone = Value};
|
||
set_field(preferences, Value, U) -> U#user{preferences = Value};
|
||
set_field(_, _, U) -> U.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Создание бота (аналогично пользователю, но с ролью `bot`).
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec create_bot(Email :: binary(), Password :: binary()) ->
|
||
{ok, #user{}} | {error, email_exists | invalid_email}.
|
||
create_bot(Email, Password) ->
|
||
case email_exists(Email) of
|
||
true ->
|
||
{error, email_exists};
|
||
false ->
|
||
case mnesia:dirty_index_read(user, Email, email) of
|
||
[] ->
|
||
{ok, PasswordHash} = logic_auth:hash_password(Password),
|
||
Id = infra_utils:generate_id(16),
|
||
Now = calendar:universal_time(),
|
||
User = #user{
|
||
id = Id,
|
||
email = Email,
|
||
password_hash = PasswordHash,
|
||
role = bot,
|
||
status = active,
|
||
reason = ?DEFAULT_REASON,
|
||
nickname = extract_nickname(Email),
|
||
avatar_url = ?DEFAULT_AVATAR_URL,
|
||
timezone = ?DEFAULT_TIMEZONE,
|
||
language = ?DEFAULT_LANGUAGE,
|
||
social_links = ?DEFAULT_SOCIAL_LINKS,
|
||
phone = ?DEFAULT_PHONE,
|
||
preferences = ?DEFAULT_PREFERENCES,
|
||
last_login = ?DEFAULT_LAST_LOGIN,
|
||
created_at = Now,
|
||
updated_at = Now
|
||
},
|
||
ok = mnesia:dirty_write(User),
|
||
{ok, User};
|
||
_ ->
|
||
{error, duplicate_email}
|
||
end
|
||
end.
|
||
|
||
%%%-------------------------------------------------------------------
|
||
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-spec delete_bot(Id :: binary()) -> ok | {error, not_found}.
|
||
delete_bot(Id) ->
|
||
case mnesia:dirty_read({user, Id}) of
|
||
[#user{role = bot}] ->
|
||
mnesia:dirty_delete({user, Id}),
|
||
ok;
|
||
[] ->
|
||
{error, not_found}
|
||
end. |