fix(backend): IP в аудите, closed_at тикетов и admin_activity в статистике. Refs EventHub/EventHubBack#27 #28 #29
This commit is contained in:
@@ -318,7 +318,8 @@ ticket_to_json(Ticket) ->
|
||||
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
|
||||
status => Ticket#ticket.status,
|
||||
assigned_to => Ticket#ticket.assigned_to,
|
||||
resolution_note => Ticket#ticket.resolution_note
|
||||
resolution_note => Ticket#ticket.resolution_note,
|
||||
closed_at => datetime_to_iso8601(Ticket#ticket.closed_at)
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #calendar{} в JSON-карту.
|
||||
|
||||
@@ -50,15 +50,26 @@ get_permissions(_) -> [].
|
||||
%% -- работа с IP -----------------------------------------------------------
|
||||
|
||||
%% @doc Возвращает IP-адрес клиента в виде бинарной строки (напр. <<"127.0.0.1">>).
|
||||
%% Сохранена для обратной совместимости; внутренне вызывает ip_to_binary/1.
|
||||
%% Сначала читает заголовки прокси, затем fallback на cowboy_req:peer/1.
|
||||
-spec client_ip(cowboy_req:req()) -> binary().
|
||||
client_ip(Req) ->
|
||||
ip_to_binary(cowboy_req:peer(Req)).
|
||||
case forwarded_client_ip(Req) of
|
||||
undefined -> ip_to_binary(cowboy_req:peer(Req));
|
||||
Ip -> Ip
|
||||
end.
|
||||
|
||||
%% @doc Преобразует IP-адрес из кортежа {A,B,C,D} в бинарную строку.
|
||||
-spec ip_to_binary({byte(), byte(), byte(), byte()}) -> binary().
|
||||
ip_to_binary({A, B, C, D}) ->
|
||||
list_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
|
||||
%% @doc Преобразует IP-адрес в бинарную строку.
|
||||
-spec ip_to_binary(term()) -> binary().
|
||||
ip_to_binary(IP) when is_tuple(IP) ->
|
||||
case tuple_size(IP) of
|
||||
4 -> list_to_binary(inet:ntoa(IP));
|
||||
8 -> list_to_binary(inet:ntoa(IP));
|
||||
_ -> <<"unknown">>
|
||||
end;
|
||||
ip_to_binary(IP) when is_binary(IP) ->
|
||||
trim_ip_binary(IP);
|
||||
ip_to_binary(IP) when is_list(IP) ->
|
||||
list_to_binary(string:trim(IP));
|
||||
ip_to_binary(_) ->
|
||||
<<"unknown">>.
|
||||
|
||||
@@ -97,8 +108,31 @@ log_admin_action(AdminId, Action, EntityType, EntityId, Reason, Req) ->
|
||||
{ok, Admin} ->
|
||||
Email = Admin#admin.email,
|
||||
Role = atom_to_binary(Admin#admin.role, utf8),
|
||||
Ip = ip_to_binary(cowboy_req:peer(Req)),
|
||||
Ip = client_ip(Req),
|
||||
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason),
|
||||
ok;
|
||||
_ -> ok
|
||||
end.
|
||||
end.
|
||||
|
||||
forwarded_client_ip(Req) ->
|
||||
pick_forwarded_ip(Req, [<<"x-forwarded-for">>, <<"x-real-ip">>]).
|
||||
|
||||
|
||||
pick_forwarded_ip(_Req, []) ->
|
||||
undefined;
|
||||
pick_forwarded_ip(Req, [Header | Rest]) ->
|
||||
case cowboy_req:header(Header, Req) of
|
||||
undefined -> pick_forwarded_ip(Req, Rest);
|
||||
Value -> first_forwarded_ip(Value)
|
||||
end.
|
||||
|
||||
|
||||
first_forwarded_ip(Value) ->
|
||||
case binary:split(Value, <<",">>) of
|
||||
[Head | _] -> trim_ip_binary(Head);
|
||||
[] -> undefined
|
||||
end.
|
||||
|
||||
|
||||
trim_ip_binary(Ip) ->
|
||||
list_to_binary(string:trim(binary_to_list(Ip))).
|
||||
@@ -148,9 +148,60 @@ trunc_hours(Hours) ->
|
||||
round(Hours * 100) / 100.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Сбор активности администраторов (заглушка).
|
||||
%%% @doc Сбор активности администраторов из записей аудита.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec collect_admin_activity() -> [map()].
|
||||
collect_admin_activity() ->
|
||||
[].
|
||||
Audits = core_admin_audit:list(),
|
||||
Grouped = group_audit_by_admin(Audits),
|
||||
Activity = maps:fold(fun build_admin_activity_entry/3, [], Grouped),
|
||||
lists:sort(fun sort_by_actions_desc/2, Activity).
|
||||
|
||||
group_audit_by_admin(Audits) ->
|
||||
lists:foldl(fun aggregate_audit_entry/2, #{}, Audits).
|
||||
|
||||
aggregate_audit_entry(#admin_audit{admin_id = AdminId, action = Action, timestamp = Ts}, Acc) ->
|
||||
Entry = maps:get(AdminId, Acc, #{actions => 0, last_login => undefined}),
|
||||
Actions = maps:get(actions, Entry) + 1,
|
||||
LastLogin = case Action of
|
||||
<<"login">> -> latest_datetime(maps:get(last_login, Entry), Ts);
|
||||
_ -> maps:get(last_login, Entry)
|
||||
end,
|
||||
maps:put(AdminId, #{actions => Actions, last_login => LastLogin}, Acc).
|
||||
|
||||
build_admin_activity_entry(AdminId, #{actions := Actions, last_login := LastLogin}, Acc) ->
|
||||
case core_admin:get_by_id(AdminId) of
|
||||
{ok, Admin} ->
|
||||
[#{
|
||||
<<"admin_id">> => AdminId,
|
||||
<<"email">> => Admin#admin.email,
|
||||
<<"nickname">> => Admin#admin.nickname,
|
||||
<<"role">> => atom_to_binary(Admin#admin.role, utf8),
|
||||
<<"actions">> => Actions,
|
||||
<<"last_login">> => format_datetime(maybe_last_login(LastLogin, Admin#admin.last_login))
|
||||
} | Acc];
|
||||
_ ->
|
||||
Acc
|
||||
end.
|
||||
|
||||
maybe_last_login(undefined, AdminLastLogin) -> AdminLastLogin;
|
||||
maybe_last_login(LoginFromAudit, _) -> LoginFromAudit.
|
||||
|
||||
latest_datetime(undefined, Ts) -> Ts;
|
||||
latest_datetime(Existing, Ts) ->
|
||||
case calendar:datetime_to_gregorian_seconds(Ts) >=
|
||||
calendar:datetime_to_gregorian_seconds(Existing) of
|
||||
true -> Ts;
|
||||
false -> Existing
|
||||
end.
|
||||
|
||||
sort_by_actions_desc(#{<<"actions">> := A}, #{<<"actions">> := B}) ->
|
||||
A >= B.
|
||||
|
||||
format_datetime(undefined) -> <<"">>;
|
||||
format_datetime(Bin) when is_binary(Bin) -> Bin;
|
||||
format_datetime({{Y, M, D}, {H, Mi, S}}) ->
|
||||
iolist_to_binary(
|
||||
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
||||
[Y, M, D, H, Mi, S])).
|
||||
@@ -26,7 +26,10 @@ report_error(ErrorMessage, Stacktrace, Context) ->
|
||||
mnesia:dirty_write(Updated),
|
||||
{ok, Updated};
|
||||
[] ->
|
||||
ReporterId = maps:get(<<"reporter_id">>, Context,
|
||||
maps:get(reporter_id, Context, <<"anonymous">>)),
|
||||
Data = #{
|
||||
<<"reporter_id">> => ReporterId,
|
||||
<<"error_message">> => ErrorMessage,
|
||||
<<"stacktrace">> => Stacktrace,
|
||||
<<"context">> => list_to_binary(io_lib:format("~p", [Context]))
|
||||
@@ -75,6 +78,8 @@ list_tickets_by_status(AdminId, Status) ->
|
||||
end.
|
||||
|
||||
%% Обновить статус тикета
|
||||
update_status(AdminId, TicketId, closed) ->
|
||||
close_ticket(AdminId, TicketId);
|
||||
update_status(AdminId, TicketId, Status) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> core_ticket:update_ticket(TicketId, #{<<"status">> => Status});
|
||||
@@ -94,7 +99,8 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) ->
|
||||
true ->
|
||||
core_ticket:update_ticket(TicketId, #{
|
||||
<<"status">> => <<"resolved">>,
|
||||
<<"resolution_note">> => ResolutionNote
|
||||
<<"resolution_note">> => ResolutionNote,
|
||||
<<"closed_at">> => calendar:universal_time()
|
||||
});
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
@@ -102,7 +108,7 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) ->
|
||||
%% Закрыть тикет
|
||||
close_ticket(AdminId, TicketId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> core_ticket:update_ticket(TicketId, #{<<"status">> => <<"closed">>});
|
||||
true -> core_ticket:close(TicketId, AdminId);
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user