Избавление от undefined, всем необязательным полям присваиваются дефолтные значения #22

This commit is contained in:
2026-05-27 16:18:33 +03:00
parent ae6fe21ce6
commit b78df927dd
21 changed files with 2037 additions and 1088 deletions
+203 -61
View File
@@ -1,52 +1,83 @@
-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([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, list_all/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) ->
% Проверяем, существует ли email
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,
id = Id,
email = Email,
password_hash = PasswordHash,
role = user,
status = active,
created_at = calendar:universal_time(),
updated_at = calendar:universal_time()
role = user,
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
},
F = fun() ->
mnesia:write(User),
{ok, User}
end,
F = fun() -> mnesia:write(User), {ok, User} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end
end.
%% Получение пользователя по ID
%%%-------------------------------------------------------------------
%%% @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.
%% Получение пользователя по email
%%%-------------------------------------------------------------------
%%% @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
@@ -54,31 +85,46 @@ get_by_email(Email) ->
[User] -> {ok, User}
end.
%% Проверка существования email
%%%-------------------------------------------------------------------
%%% @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};
[] -> {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} ->
@@ -88,76 +134,140 @@ update_last_login(Id) ->
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()},
Updated = User#user{status = Status, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%% Удаление пользователя (soft delete)
%%%-------------------------------------------------------------------
%%% @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, undefined),
created_at => maps:get(created_at, User),
updated_at => maps:get(updated_at, 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
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()},
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()},
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),
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
@@ -167,18 +277,35 @@ count_users_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @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 =:= admin; Value =:= bot -> U#user{role = Value};
set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> U#user{status = Value};
set_field(role, Value, U) when Value =:= user; Value =:= admin; Value =:= bot ->
U#user{role = Value};
set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted ->
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};
@@ -189,12 +316,12 @@ set_field(phone, Value, U) -> U#user{phone = Value};
set_field(preferences, Value, U) -> U#user{preferences = Value};
set_field(_, _, U) -> U.
%% ------------------------------------------------------------------
%% API для ботов
%% ------------------------------------------------------------------
%%%-------------------------------------------------------------------
%%% @doc Создание бота (аналогично пользователю, но с ролью `bot`).
%%% @end
%%%-------------------------------------------------------------------
-spec create_bot(Email :: binary(), Password :: binary()) ->
{ok, User :: #user{}} | {error, duplicate_email | invalid_email}.
{ok, #user{}} | {error, email_exists | invalid_email}.
create_bot(Email, Password) ->
case email_exists(Email) of
true ->
@@ -204,14 +331,24 @@ create_bot(Email, Password) ->
[] ->
{ok, PasswordHash} = logic_auth:hash_password(Password),
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
User = #user{
id = Id,
email = Email,
id = Id,
email = Email,
password_hash = PasswordHash,
role = bot,
status = active,
created_at = calendar:universal_time(),
updated_at = calendar:universal_time()
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};
@@ -220,11 +357,16 @@ create_bot(Email, Password) ->
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}
[] ->
{error, not_found}
end.