-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>>; _ -> <> 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])).