fix(user): transactional status updates and paginated list_users

Protect status/login/bot writes with transactions, fix abort Reason
shadowing, keep create_bot email_exists, and add list_users/2 pagination.
This commit is contained in:
2026-08-03 22:56:16 +03:00
parent d88689f1a3
commit 774f58f9ed
+141 -77
View File
@@ -1,9 +1,10 @@
-module(core_user).
-include("records.hrl").
-include_lib("stdlib/include/ms_transform.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([list_users/0, list_users/2]).
-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]).
@@ -80,10 +81,10 @@ get_by_id(Id) ->
%%%-------------------------------------------------------------------
-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
%% Use the index on #user.email instead of a full table scan.
case mnesia:dirty_index_read(user, Email, #user.email) of
[] -> {error, not_found};
[User] -> {ok, User}
[User | _] -> {ok, User}
end.
%%%-------------------------------------------------------------------
@@ -125,14 +126,21 @@ update(Id, Updates) ->
%%% Устанавливает `last_login` в текущее UTC-время.
%%% @end
%%%-------------------------------------------------------------------
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found | term()}.
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
%% Transaction prevents lost updates when concurrent logins happen.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{last_login = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%%%-------------------------------------------------------------------
@@ -140,15 +148,23 @@ update_last_login(Id) ->
%%% @end
%%%-------------------------------------------------------------------
-spec update_status(Id :: binary(), Status :: atom(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
{ok, #user{}} | {error, not_found | term()}.
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
%% Transaction ensures atomic status change — prevents lost updates
%% when two admins change the same user's status simultaneously.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{status = Status, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
@@ -167,9 +183,32 @@ delete(Id) ->
%%%-------------------------------------------------------------------
-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]}.
%% Default pagination: first 100 non-deleted users.
list_users(100, 0).
%%%-------------------------------------------------------------------
%%% @doc Получить список активных пользователей с пагинацией.
%%% `Limit` — максимальное количество записей, `Offset` — смещение.
%%% Фильтрация status =/= deleted выполняется в match-спецификации.
%%% @end
%%%-------------------------------------------------------------------
-spec list_users(Limit :: pos_integer(), Offset :: non_neg_integer()) -> {ok, [map()]}.
list_users(Limit, Offset) ->
%% Use select with a match spec to filter out deleted users at DB level
%% and apply limit for pagination.
MS = ets:fun2ms(fun(#user{status = S} = U) when S =/= deleted -> U end),
case mnesia:dirty_select(user, MS) of
[] ->
{ok, []};
All ->
%% Apply offset/limit at the list level (Mnesia dirty_select
%% doesn't support offset natively, but we filtered at DB level).
Paged = case length(All) > Offset of
true -> lists:sublist(lists:nthtail(Offset, All), Limit);
false -> []
end,
{ok, [user_to_map(U) || U <- Paged]}
end.
%%%-------------------------------------------------------------------
%%% @doc Преобразование записи пользователя в map.
@@ -203,15 +242,22 @@ user_to_map(User) ->
%%% @end
%%%-------------------------------------------------------------------
-spec block(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
{ok, #user{}} | {error, not_found | term()}.
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
%% Transaction prevents concurrent status changes from racing.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{status = blocked, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
@@ -219,15 +265,22 @@ block(Id, Reason) ->
%%% @end
%%%-------------------------------------------------------------------
-spec unblock(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
{ok, #user{}} | {error, not_found | term()}.
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
%% Transaction prevents concurrent status changes from racing.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{status = active, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
@@ -393,50 +446,61 @@ set_field(_, _, U) -> U.
-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
%% Hash password outside the transaction to minimize lock time.
{ok, PasswordHash} = logic_auth:hash_password(Password),
%% Transaction ensures the email-uniqueness check and write are atomic,
%% preventing duplicate bots under concurrent creation.
F = fun() ->
case mnesia:index_read(user, Email, #user.email) of
[] ->
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:write(User),
{ok, User};
_ ->
{error, email_exists}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_bot(Id :: binary()) -> ok | {error, not_found}.
-spec delete_bot(Id :: binary()) -> ok | {error, not_found | term()}.
delete_bot(Id) ->
case mnesia:dirty_read({user, Id}) of
[#user{role = bot}] ->
mnesia:dirty_delete({user, Id}),
ok;
[] ->
{error, not_found}
%% Transaction ensures the read-then-delete is atomic.
F = fun() ->
case mnesia:read(user, Id, write) of
[#user{role = bot}] ->
mnesia:delete({user, Id}),
ok;
_ ->
{error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.