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:
+107
-43
@@ -1,9 +1,10 @@
|
|||||||
-module(core_user).
|
-module(core_user).
|
||||||
-include("records.hrl").
|
-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,
|
-export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3,
|
||||||
delete/1, update_last_login/1]).
|
delete/1, update_last_login/1]).
|
||||||
-export([email_exists/1]).
|
-export([email_exists/1]).
|
||||||
-export([list_users/0]).
|
-export([list_users/0, list_users/2]).
|
||||||
-export([block/2, unblock/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/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([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}.
|
-spec get_by_email(Email :: binary()) -> {ok, #user{}} | {error, not_found}.
|
||||||
get_by_email(Email) ->
|
get_by_email(Email) ->
|
||||||
Match = #user{email = Email, _ = '_'},
|
%% Use the index on #user.email instead of a full table scan.
|
||||||
case mnesia:dirty_match_object(Match) of
|
case mnesia:dirty_index_read(user, Email, #user.email) of
|
||||||
[] -> {error, not_found};
|
[] -> {error, not_found};
|
||||||
[User] -> {ok, User}
|
[User | _] -> {ok, User}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -125,14 +126,21 @@ update(Id, Updates) ->
|
|||||||
%%% Устанавливает `last_login` в текущее UTC-время.
|
%%% Устанавливает `last_login` в текущее UTC-время.
|
||||||
%%% @end
|
%%% @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) ->
|
update_last_login(Id) ->
|
||||||
case get_by_id(Id) of
|
%% Transaction prevents lost updates when concurrent logins happen.
|
||||||
{ok, User} ->
|
F = fun() ->
|
||||||
|
case mnesia:read(user, Id, write) of
|
||||||
|
[] -> {error, not_found};
|
||||||
|
[User] ->
|
||||||
Updated = User#user{last_login = calendar:universal_time()},
|
Updated = User#user{last_login = calendar:universal_time()},
|
||||||
mnesia:dirty_write(Updated),
|
mnesia:write(Updated),
|
||||||
{ok, Updated};
|
{ok, Updated}
|
||||||
Error -> Error
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -140,15 +148,23 @@ update_last_login(Id) ->
|
|||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec update_status(Id :: binary(), Status :: atom(), Reason :: binary()) ->
|
-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) ->
|
update_status(Id, Status, Reason) ->
|
||||||
case get_by_id(Id) of
|
%% Transaction ensures atomic status change — prevents lost updates
|
||||||
{ok, User} ->
|
%% 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 = User#user{status = Status, reason = Reason,
|
||||||
updated_at = calendar:universal_time()},
|
updated_at = calendar:universal_time()},
|
||||||
mnesia:dirty_write(Updated),
|
mnesia:write(Updated),
|
||||||
{ok, Updated};
|
{ok, Updated}
|
||||||
Error -> Error
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, AbortReason} -> {error, AbortReason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -167,9 +183,32 @@ delete(Id) ->
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec list_users() -> {ok, [map()]}.
|
-spec list_users() -> {ok, [map()]}.
|
||||||
list_users() ->
|
list_users() ->
|
||||||
Users = mnesia:dirty_match_object(#user{_ = '_'}),
|
%% Default pagination: first 100 non-deleted users.
|
||||||
ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
|
list_users(100, 0).
|
||||||
{ok, [user_to_map(U) || U <- ActiveUsers]}.
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @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.
|
%%% @doc Преобразование записи пользователя в map.
|
||||||
@@ -203,15 +242,22 @@ user_to_map(User) ->
|
|||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec block(Id :: binary(), Reason :: binary()) ->
|
-spec block(Id :: binary(), Reason :: binary()) ->
|
||||||
{ok, #user{}} | {error, not_found}.
|
{ok, #user{}} | {error, not_found | term()}.
|
||||||
block(Id, Reason) ->
|
block(Id, Reason) ->
|
||||||
case get_by_id(Id) of
|
%% Transaction prevents concurrent status changes from racing.
|
||||||
{ok, User} ->
|
F = fun() ->
|
||||||
|
case mnesia:read(user, Id, write) of
|
||||||
|
[] -> {error, not_found};
|
||||||
|
[User] ->
|
||||||
Updated = User#user{status = blocked, reason = Reason,
|
Updated = User#user{status = blocked, reason = Reason,
|
||||||
updated_at = calendar:universal_time()},
|
updated_at = calendar:universal_time()},
|
||||||
mnesia:dirty_write(Updated),
|
mnesia:write(Updated),
|
||||||
{ok, Updated};
|
{ok, Updated}
|
||||||
Error -> Error
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, AbortReason} -> {error, AbortReason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -219,15 +265,22 @@ block(Id, Reason) ->
|
|||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec unblock(Id :: binary(), Reason :: binary()) ->
|
-spec unblock(Id :: binary(), Reason :: binary()) ->
|
||||||
{ok, #user{}} | {error, not_found}.
|
{ok, #user{}} | {error, not_found | term()}.
|
||||||
unblock(Id, Reason) ->
|
unblock(Id, Reason) ->
|
||||||
case get_by_id(Id) of
|
%% Transaction prevents concurrent status changes from racing.
|
||||||
{ok, User} ->
|
F = fun() ->
|
||||||
|
case mnesia:read(user, Id, write) of
|
||||||
|
[] -> {error, not_found};
|
||||||
|
[User] ->
|
||||||
Updated = User#user{status = active, reason = Reason,
|
Updated = User#user{status = active, reason = Reason,
|
||||||
updated_at = calendar:universal_time()},
|
updated_at = calendar:universal_time()},
|
||||||
mnesia:dirty_write(Updated),
|
mnesia:write(Updated),
|
||||||
{ok, Updated};
|
{ok, Updated}
|
||||||
Error -> Error
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, AbortReason} -> {error, AbortReason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -393,13 +446,13 @@ set_field(_, _, U) -> U.
|
|||||||
-spec create_bot(Email :: binary(), Password :: binary()) ->
|
-spec create_bot(Email :: binary(), Password :: binary()) ->
|
||||||
{ok, #user{}} | {error, email_exists | invalid_email}.
|
{ok, #user{}} | {error, email_exists | invalid_email}.
|
||||||
create_bot(Email, Password) ->
|
create_bot(Email, Password) ->
|
||||||
case email_exists(Email) of
|
%% Hash password outside the transaction to minimize lock time.
|
||||||
true ->
|
|
||||||
{error, email_exists};
|
|
||||||
false ->
|
|
||||||
case mnesia:dirty_index_read(user, Email, email) of
|
|
||||||
[] ->
|
|
||||||
{ok, PasswordHash} = logic_auth:hash_password(Password),
|
{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),
|
Id = infra_utils:generate_id(16),
|
||||||
Now = calendar:universal_time(),
|
Now = calendar:universal_time(),
|
||||||
User = #user{
|
User = #user{
|
||||||
@@ -420,23 +473,34 @@ create_bot(Email, Password) ->
|
|||||||
created_at = Now,
|
created_at = Now,
|
||||||
updated_at = Now
|
updated_at = Now
|
||||||
},
|
},
|
||||||
ok = mnesia:dirty_write(User),
|
ok = mnesia:write(User),
|
||||||
{ok, User};
|
{ok, User};
|
||||||
_ ->
|
_ ->
|
||||||
{error, duplicate_email}
|
{error, email_exists}
|
||||||
end
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, AbortReason} -> {error, AbortReason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
|
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec delete_bot(Id :: binary()) -> ok | {error, not_found}.
|
-spec delete_bot(Id :: binary()) -> ok | {error, not_found | term()}.
|
||||||
delete_bot(Id) ->
|
delete_bot(Id) ->
|
||||||
case mnesia:dirty_read({user, Id}) of
|
%% Transaction ensures the read-then-delete is atomic.
|
||||||
|
F = fun() ->
|
||||||
|
case mnesia:read(user, Id, write) of
|
||||||
[#user{role = bot}] ->
|
[#user{role = bot}] ->
|
||||||
mnesia:dirty_delete({user, Id}),
|
mnesia:delete({user, Id}),
|
||||||
ok;
|
ok;
|
||||||
[] ->
|
_ ->
|
||||||
{error, not_found}
|
{error, not_found}
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
end.
|
end.
|
||||||
Reference in New Issue
Block a user