335 lines
11 KiB
Erlang
335 lines
11 KiB
Erlang
-module(logic_ticket).
|
|
-include("records.hrl").
|
|
|
|
-export([report_error/3,
|
|
report_error/4,
|
|
get_ticket/2,
|
|
list_tickets/1,
|
|
list_tickets_by_status/2,
|
|
update_status/3,
|
|
assign_ticket/3,
|
|
resolve_ticket/3,
|
|
close_ticket/2,
|
|
get_statistics/1]).
|
|
-export([delete_ticket/2]).
|
|
-export([get_user_ticket/2]).
|
|
-export([compute_error_hash/3]).
|
|
|
|
-define(STACK_FINGERPRINT_MAX, 500).
|
|
-define(NEW_TICKET_RATE_LIMIT, 30).
|
|
-define(RATE_WINDOW_MS, 60000).
|
|
-define(RATE_ETS, eventhub_ticket_rate).
|
|
|
|
%% Зарегистрировать ошибку (source по умолчанию backend)
|
|
report_error(ErrorMessage, Stacktrace, Context) ->
|
|
report_error(backend, ErrorMessage, Stacktrace, Context).
|
|
|
|
%% @doc Создать или обновить тикет по error_hash.
|
|
%% Source: backend | frontend | manual (atom или binary).
|
|
report_error(Source0, ErrorMessage0, Stacktrace0, Context0) ->
|
|
case get(eventhub_reporting_ticket) of
|
|
true ->
|
|
{error, reporting_recursion};
|
|
_ ->
|
|
put(eventhub_reporting_ticket, true),
|
|
try
|
|
do_report_error(Source0, ErrorMessage0, Stacktrace0, Context0)
|
|
after
|
|
erase(eventhub_reporting_ticket)
|
|
end
|
|
end.
|
|
|
|
do_report_error(Source0, ErrorMessage0, Stacktrace0, Context0) ->
|
|
Source = normalize_source(Source0),
|
|
ErrorMessage = ensure_binary(ErrorMessage0),
|
|
Stacktrace = ensure_binary(Stacktrace0),
|
|
ContextMap = normalize_context(Context0),
|
|
ReporterId = maps:get(<<"reporter_id">>, ContextMap,
|
|
maps:get(reporter_id, ContextMap, <<"anonymous">>)),
|
|
ReporterIdBin = ensure_binary(ReporterId),
|
|
Hash = compute_error_hash(Source, ErrorMessage, Stacktrace, ReporterIdBin),
|
|
ContextBin = encode_context(ContextMap#{<<"source">> => Source}),
|
|
case find_open_by_hash(Hash) of
|
|
[Ticket | _] ->
|
|
bump_ticket(Ticket, Stacktrace, ContextBin);
|
|
[] ->
|
|
case allow_new_ticket() of
|
|
false ->
|
|
{error, rate_limited};
|
|
true ->
|
|
create_and_notify(ReporterIdBin, Hash, ErrorMessage, Stacktrace, ContextBin, Source)
|
|
end
|
|
end.
|
|
|
|
%% Получить тикет, проверяя, что пользователь является репортером
|
|
get_user_ticket(UserId, TicketId) ->
|
|
case core_ticket:get_by_id(TicketId) of
|
|
{ok, Ticket} ->
|
|
case Ticket#ticket.reporter_id =:= UserId of
|
|
true -> {ok, Ticket};
|
|
false -> {error, access_denied}
|
|
end;
|
|
Error -> Error
|
|
end.
|
|
|
|
%% Получить тикет (только для админов)
|
|
get_ticket(AdminId, TicketId) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true -> core_ticket:get_by_id(TicketId);
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Список всех тикетов (только для админов)
|
|
list_tickets(AdminId) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true -> core_ticket:list_all();
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Список тикетов по статусу (только для админов)
|
|
list_tickets_by_status(AdminId, Status) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true ->
|
|
All = core_ticket:list_all(),
|
|
[T || T <- All, T#ticket.status =:= Status];
|
|
false -> {error, access_denied}
|
|
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});
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Назначить тикет администратору
|
|
assign_ticket(AdminId, TicketId, AssignToId) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true -> core_ticket:update_ticket(TicketId, #{<<"assigned_to">> => AssignToId});
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Отметить тикет как решённый с примечанием
|
|
resolve_ticket(AdminId, TicketId, ResolutionNote) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true ->
|
|
core_ticket:update_ticket(TicketId, #{
|
|
<<"status">> => <<"resolved">>,
|
|
<<"resolution_note">> => ResolutionNote,
|
|
<<"closed_at">> => calendar:universal_time()
|
|
});
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Закрыть тикет
|
|
close_ticket(AdminId, TicketId) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true -> core_ticket:close(TicketId, AdminId);
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Удалить тикет (только для админов)
|
|
delete_ticket(AdminId, TicketId) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true -> core_ticket:delete_ticket(TicketId);
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
%% Получить статистику по тикетам
|
|
get_statistics(AdminId) ->
|
|
case admin_utils:is_admin(AdminId) of
|
|
true ->
|
|
All = core_ticket:list_all(),
|
|
Open = count_by_status(All, open),
|
|
InProgress = count_by_status(All, in_progress),
|
|
Resolved = count_by_status(All, resolved),
|
|
Closed = count_by_status(All, closed),
|
|
TotalErrors = lists:sum([T#ticket.count || T <- All]),
|
|
#{
|
|
total_tickets => length(All),
|
|
open => Open,
|
|
in_progress => InProgress,
|
|
resolved => Resolved,
|
|
closed => Closed,
|
|
total_errors => TotalErrors
|
|
};
|
|
false -> {error, access_denied}
|
|
end.
|
|
|
|
count_by_status(Tickets, Status) ->
|
|
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
|
|
|
|
normalize_status(Status) when is_atom(Status) -> Status;
|
|
normalize_status(Status) when is_binary(Status) ->
|
|
try binary_to_existing_atom(Status, utf8)
|
|
catch error:badarg -> Status
|
|
end.
|
|
|
|
%% ============ Hash / dedupe ============
|
|
|
|
-spec compute_error_hash(binary() | atom(), binary(), binary()) -> binary().
|
|
compute_error_hash(Source, ErrorMessage, Stacktrace) ->
|
|
compute_error_hash(Source, ErrorMessage, Stacktrace, <<"anonymous">>).
|
|
|
|
compute_error_hash(Source0, ErrorMessage, Stacktrace, ReporterId) ->
|
|
Source = normalize_source(Source0),
|
|
Msg = normalize_message(ErrorMessage),
|
|
Fingerprint = stack_fingerprint(Stacktrace),
|
|
Payload = case Source of
|
|
<<"manual">> ->
|
|
<<"manual:", ReporterId/binary, ":", Msg/binary>>;
|
|
_ ->
|
|
<<Source/binary, ":", Msg/binary, ":", Fingerprint/binary>>
|
|
end,
|
|
HashBin = crypto:hash(sha256, Payload),
|
|
binary:encode_hex(HashBin, lowercase).
|
|
|
|
stack_fingerprint(<<>>) -> <<>>;
|
|
stack_fingerprint(Stack) when is_binary(Stack) ->
|
|
Normalized = re:replace(Stack, <<"0x[0-9a-fA-F]+">>, <<"0x*">>,
|
|
[global, {return, binary}]),
|
|
case byte_size(Normalized) > ?STACK_FINGERPRINT_MAX of
|
|
true -> binary_part(Normalized, 0, ?STACK_FINGERPRINT_MAX);
|
|
false -> Normalized
|
|
end;
|
|
stack_fingerprint(_) -> <<>>.
|
|
|
|
normalize_message(Msg) when is_binary(Msg) ->
|
|
string:trim(Msg);
|
|
normalize_message(Msg) ->
|
|
string:trim(ensure_binary(Msg)).
|
|
|
|
find_open_by_hash(Hash) ->
|
|
Candidates = try
|
|
mnesia:dirty_index_read(ticket, Hash, #ticket.error_hash)
|
|
catch
|
|
_:_ ->
|
|
[T || T <- core_ticket:list_all(), T#ticket.error_hash =:= Hash]
|
|
end,
|
|
[T || T <- Candidates,
|
|
lists:member(normalize_status(T#ticket.status), [open, in_progress])].
|
|
|
|
bump_ticket(Ticket, Stacktrace, ContextBin) ->
|
|
Updated0 = Ticket#ticket{
|
|
count = Ticket#ticket.count + 1,
|
|
last_seen = calendar:universal_time()
|
|
},
|
|
Updated1 = case Updated0#ticket.stacktrace =:= <<>> andalso Stacktrace =/= <<>> of
|
|
true -> Updated0#ticket{stacktrace = Stacktrace};
|
|
false -> Updated0
|
|
end,
|
|
Updated = case Updated1#ticket.context =:= <<>> andalso ContextBin =/= <<>> of
|
|
true -> Updated1#ticket{context = ContextBin};
|
|
false -> Updated1
|
|
end,
|
|
mnesia:dirty_write(Updated),
|
|
{ok, Updated}.
|
|
|
|
create_and_notify(ReporterId, Hash, ErrorMessage, Stacktrace, ContextBin, Source) ->
|
|
Data = #{
|
|
<<"reporter_id">> => ReporterId,
|
|
<<"error_hash">> => Hash,
|
|
<<"error_message">> => ErrorMessage,
|
|
<<"stacktrace">> => Stacktrace,
|
|
<<"context">> => ContextBin,
|
|
<<"source">> => Source
|
|
},
|
|
case core_ticket:create_ticket(Data) of
|
|
{ok, Ticket} = Result ->
|
|
notify_admins(Ticket),
|
|
Result;
|
|
Error -> Error
|
|
end.
|
|
|
|
%% ============ Rate limit (new tickets only) ============
|
|
|
|
allow_new_ticket() ->
|
|
ensure_rate_ets(),
|
|
Now = erlang:monotonic_time(millisecond),
|
|
WindowStart = Now - ?RATE_WINDOW_MS,
|
|
ets:insert(?RATE_ETS, {Now, true}),
|
|
ets:select_delete(?RATE_ETS, [{{'$1', '_'}, [{'<', '$1', WindowStart}], [true]}]),
|
|
Count = ets:info(?RATE_ETS, size),
|
|
Count =< ?NEW_TICKET_RATE_LIMIT.
|
|
|
|
ensure_rate_ets() ->
|
|
case ets:info(?RATE_ETS) of
|
|
undefined ->
|
|
try
|
|
ets:new(?RATE_ETS, [named_table, public, set, {write_concurrency, true}])
|
|
catch
|
|
error:badarg -> ok
|
|
end;
|
|
_ -> ok
|
|
end.
|
|
|
|
%% ============ Notify ============
|
|
|
|
notify_admins(Ticket) ->
|
|
logic_notification:notify_admin(ticket_created, #{
|
|
ticket_id => Ticket#ticket.id,
|
|
error_message => Ticket#ticket.error_message,
|
|
error_hash => Ticket#ticket.error_hash,
|
|
source => Ticket#ticket.source,
|
|
count => Ticket#ticket.count,
|
|
status => Ticket#ticket.status
|
|
}),
|
|
ok.
|
|
|
|
%% ============ Helpers ============
|
|
|
|
normalize_source(backend) -> <<"backend">>;
|
|
normalize_source(frontend) -> <<"frontend">>;
|
|
normalize_source(manual) -> <<"manual">>;
|
|
normalize_source(<<"backend">>) -> <<"backend">>;
|
|
normalize_source(<<"frontend">>) -> <<"frontend">>;
|
|
normalize_source(<<"manual">>) -> <<"manual">>;
|
|
normalize_source(_) -> <<"backend">>.
|
|
|
|
normalize_context(Ctx) when is_map(Ctx) ->
|
|
maps:fold(fun(K, V, Acc) ->
|
|
Acc#{ensure_binary(K) => V}
|
|
end, #{}, Ctx);
|
|
normalize_context(Ctx) when is_binary(Ctx) ->
|
|
try jsx:decode(Ctx, [return_maps]) of
|
|
Map when is_map(Map) -> Map;
|
|
_ -> #{<<"raw">> => Ctx}
|
|
catch
|
|
_:_ -> #{<<"raw">> => Ctx}
|
|
end;
|
|
normalize_context(Ctx) ->
|
|
#{<<"raw">> => list_to_binary(io_lib:format("~p", [Ctx]))}.
|
|
|
|
encode_context(Map) when is_map(Map) ->
|
|
try jsx:encode(sanitize_for_json(Map))
|
|
catch
|
|
_:_ -> list_to_binary(io_lib:format("~p", [Map]))
|
|
end.
|
|
|
|
sanitize_for_json(Map) when is_map(Map) ->
|
|
maps:fold(fun(K, V, Acc) ->
|
|
Acc#{ensure_binary(K) => sanitize_value(V)}
|
|
end, #{}, Map).
|
|
|
|
sanitize_value(V) when is_binary(V); is_integer(V); is_float(V); is_boolean(V); V =:= null ->
|
|
V;
|
|
sanitize_value(V) when is_atom(V) ->
|
|
atom_to_binary(V, utf8);
|
|
sanitize_value(V) when is_list(V) ->
|
|
case io_lib:printable_unicode_list(V) of
|
|
true -> unicode:characters_to_binary(V);
|
|
false -> [sanitize_value(X) || X <- V]
|
|
end;
|
|
sanitize_value(V) when is_map(V) ->
|
|
sanitize_for_json(V);
|
|
sanitize_value(V) ->
|
|
list_to_binary(io_lib:format("~p", [V])).
|
|
|
|
ensure_binary(V) when is_binary(V) -> V;
|
|
ensure_binary(V) when is_atom(V) -> atom_to_binary(V, utf8);
|
|
ensure_binary(V) when is_list(V) -> unicode:characters_to_binary(V);
|
|
ensure_binary(V) when is_integer(V) -> integer_to_binary(V);
|
|
ensure_binary(V) -> list_to_binary(io_lib:format("~p", [V])).
|