Files
EventHubBack/src/logic/logic_search.erl
T

368 lines
14 KiB
Erlang
Executable File

%%%-------------------------------------------------------------------
%%% @doc Модуль поиска событий и календарей.
%%%
%%% Предоставляет публичную функцию `search/4`, которая выполняет
%%% фильтрацию, сортировку и пагинацию доступных пользователю сущностей.
%%% Поддерживается полнотекстовый поиск, фильтры по тегам, датам и
%%% географическому положению.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_search).
-include("records.hrl").
-export([search/4]).
%% ─────────────────────────────────────────────────────────────────
%% Константы
%% ─────────────────────────────────────────────────────────────────
-define(DEFAULT_LIMIT, 20).
-define(MAX_LIMIT, 100).
-define(DISCOVERY_FETCH, 200).
-define(EARTH_RADIUS_KM, 6371.0).
%%%-------------------------------------------------------------------
%%% @doc Выполняет поиск по событиям и календарям.
%%%
%%% `Type` может принимать значения `<<"event">>`, `<<"calendar">>`
%%% или `undefined` (оба типа).
%%%
%%% Возвращает кортеж `{ok, TotalCount, ResultMap}`, где ResultMap
%%% содержит ключи `<<"events">>` и/или `<<"calendars">>`.
%%% @end
%%%-------------------------------------------------------------------
-spec search(Type :: binary() | undefined,
Query :: binary() | undefined,
UserId :: binary(),
Params :: map()) ->
{ok, non_neg_integer(), map()}.
search(Type, Query, UserId, Params) ->
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
Offset = maps:get(offset, Params, 0),
case is_discovery_request(Query, Params) of
true ->
discovery_search(Type, UserId, Params, Limit, Offset);
false ->
filtered_search(Type, Query, UserId, Params, Limit, Offset)
end.
filtered_search(Type, Query, UserId, Params, Limit, Offset) ->
case Type of
<<"event">> ->
{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
{ok, Total, #{<<"events">> => Events}};
<<"calendar">> ->
{ok, Total, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
{ok, Total, #{<<"calendars">> => Calendars}};
_ ->
{ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset),
{ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
{ok, EventsTotal + CalendarsTotal, #{
<<"events">> => Events,
<<"calendars">> => Calendars
}}
end.
%% Пустой search (страница «Главная»): tops из stats_tops; иначе — полный scan.
is_discovery_request(Query, Params) ->
QueryEmpty = Query =:= undefined orelse Query =:= <<>>,
QueryEmpty andalso
not maps:is_key(tags, Params) andalso
not maps:is_key(from, Params) andalso
not maps:is_key(to, Params) andalso
not maps:is_key(lat, Params) andalso
not maps:is_key(lon, Params) andalso
not maps:is_key(sort, Params).
discovery_search(Type, UserId, Params, Limit, Offset) ->
case Type of
<<"event">> ->
{ok, Total, Events} = discovery_events(UserId, Params, Limit, Offset),
{ok, Total, #{<<"events">> => Events}};
<<"calendar">> ->
{ok, Total, Calendars} = discovery_calendars(UserId, Params, Limit, Offset),
{ok, Total, #{<<"calendars">> => Calendars}};
_ ->
{ok, EventsTotal, Events} = discovery_events(UserId, Params, Limit, Offset),
{ok, CalendarsTotal, Calendars} = discovery_calendars(UserId, Params, Limit, Offset),
{ok, EventsTotal + CalendarsTotal, #{
<<"events">> => Events,
<<"calendars">> => Calendars
}}
end.
discovery_events(UserId, Params, Limit, Offset) ->
FetchN = max(Limit + Offset, ?DISCOVERY_FETCH),
Tops = core_event:get_top_events_by_rating(FetchN),
Accessible = filter_accessible_events(Tops, UserId),
case Accessible of
[] ->
search_events(undefined, UserId, Params, Limit, Offset);
Items ->
Total = length(Items),
{ok, Total, format_events(paginate(Items, Limit, Offset))}
end.
discovery_calendars(UserId, Params, Limit, Offset) ->
FetchN = max(Limit + Offset, ?DISCOVERY_FETCH),
Tops = core_calendar:get_top_calendars_by_rating(FetchN),
Accessible = filter_accessible_calendars(Tops, UserId),
case Accessible of
[] ->
search_calendars(undefined, UserId, Params, Limit, Offset);
Items ->
Total = length(Items),
{ok, Total, format_calendars(paginate(Items, Limit, Offset))}
end.
%% ============ Поиск событий ============
-spec search_events(Query :: binary() | undefined,
UserId :: binary(),
Params :: map(),
Limit :: non_neg_integer(),
Offset :: non_neg_integer()) ->
{ok, non_neg_integer(), [map()]}.
search_events(Query, UserId, Params, Limit, Offset) ->
AllEvents = get_all_events(),
AccessibleEvents = filter_accessible_events(AllEvents, UserId),
Filtered = apply_event_filters(AccessibleEvents, Query, Params),
Sorted = sort_events(Filtered, Params),
Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_events(Paginated)}.
%% ============ Поиск календарей ============
-spec search_calendars(Query :: binary() | undefined,
UserId :: binary(),
Params :: map(),
Limit :: non_neg_integer(),
Offset :: non_neg_integer()) ->
{ok, non_neg_integer(), [map()]}.
search_calendars(Query, UserId, Params, Limit, Offset) ->
AllCalendars = get_all_calendars(),
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
Paginated = paginate(Filtered, Limit, Offset),
{ok, length(Filtered), format_calendars(Paginated)}.
%% ============ Получение данных ============
-spec get_all_events() -> [#event{}].
get_all_events() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
-spec get_all_calendars() -> [#calendar{}].
get_all_calendars() ->
Match = #calendar{status = active, _ = '_'},
mnesia:dirty_match_object(Match).
%% ============ Фильтрация по доступности ============
-spec filter_accessible_events([#event{}], binary()) -> [#event{}].
filter_accessible_events(Events, UserId) ->
lists:filter(fun(Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Calendar} ->
CanAccess = logic_calendar:can_access(UserId, Calendar),
case CanAccess of
false ->
%% io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n",
%% [UserId, Calendar#calendar.id, Calendar#calendar.type,
%% Calendar#calendar.owner_id, Calendar#calendar.status]);
false;
true -> ok
end,
CanAccess;
_ -> false
end
end, Events).
-spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}].
filter_accessible_calendars(Calendars, UserId) ->
lists:filter(fun(Calendar) -> logic_calendar:can_access(UserId, Calendar) end, Calendars).
%% ============ Применение фильтров ============
-spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}].
apply_event_filters(Events, Query, Params) ->
Events1 = filter_by_text(Events, Query),
Events2 = filter_by_tags(Events1, Params),
Events3 = filter_by_date_range(Events2, Params),
filter_by_location(Events3, Params).
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
apply_calendar_filters(Calendars, Query, Params) ->
Calendars1 = filter_by_text(Calendars, Query),
filter_by_tags(Calendars1, Params).
%% --- Текстовый поиск ---
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
filter_by_text(Items, undefined) -> Items;
filter_by_text(Items, <<>>) -> Items;
filter_by_text(Items, Query) ->
QueryLower = string:lowercase(binary_to_list(Query)),
lists:filter(fun(Item) ->
Title = binary_to_list(get_title(Item)),
Description = binary_to_list(get_description(Item)),
string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse
string:find(string:lowercase(Description), QueryLower) =/= nomatch
end, Items).
%% --- Фильтр по тегам ---
-spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}].
filter_by_tags(Items, Params) ->
case maps:get(tags, Params, undefined) of
undefined -> Items;
TagsStr ->
Tags = [string:trim(T) || T <- string:split(TagsStr, ",", all)],
lists:filter(fun(Item) ->
ItemTags = get_tags(Item),
has_any_tag(ItemTags, Tags)
end, Items)
end.
%% --- Фильтр по дате ---
-spec filter_by_date_range([#event{}], map()) -> [#event{}].
filter_by_date_range(Events, Params) ->
From = maps:get(from, Params, undefined),
To = maps:get(to, Params, undefined),
case {From, To} of
{undefined, undefined} -> Events;
_ ->
lists:filter(fun(Event) ->
StartTime = Event#event.start_time,
(From =:= undefined orelse StartTime >= From) andalso
(To =:= undefined orelse StartTime =< To)
end, Events)
end.
%% --- Гео-фильтр ---
-spec filter_by_location([#event{}], map()) -> [#event{}].
filter_by_location(Events, Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
{undefined, _} -> Events;
{_, undefined} -> Events;
{Lat, Lon} ->
Radius = maps:get(radius, Params, 10),
lists:filter(fun(Event) ->
case Event#event.location of
#location{lat = EventLat, lon = EventLon}
when is_number(EventLat), is_number(EventLon) ->
distance(Lat, Lon, EventLat, EventLon) =< Radius;
_ -> false
end
end, Events)
end.
%% ============ Вспомогательные функции ============
-spec get_title(#event{} | #calendar{}) -> binary().
get_title(#event{title = Title}) -> Title;
get_title(#calendar{title = Title}) -> Title.
-spec get_description(#event{} | #calendar{}) -> binary().
get_description(#event{description = Desc}) -> Desc;
get_description(#calendar{description = Desc}) -> Desc.
-spec get_tags(#event{} | #calendar{}) -> [binary()].
get_tags(#event{tags = Tags}) -> Tags;
get_tags(#calendar{tags = Tags}) -> Tags.
-spec has_any_tag([binary()], [string()]) -> boolean().
has_any_tag(ItemTags, SearchTags) ->
lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags).
%% ============ Гео-вычисления ============
%% @doc Вычисляет расстояние между двумя географическими точками по формуле Хаверсина.
-spec distance(number(), number(), number(), number()) -> float().
distance(Lat1, Lon1, Lat2, Lon2) ->
DLat = deg_to_rad(Lat2 - Lat1),
DLon = deg_to_rad(Lon2 - Lon1),
A = math:sin(DLat / 2) * math:sin(DLat / 2) +
math:cos(deg_to_rad(Lat1)) * math:cos(deg_to_rad(Lat2)) *
math:sin(DLon / 2) * math:sin(DLon / 2),
C = 2 * math:atan2(math:sqrt(A), math:sqrt(1 - A)),
?EARTH_RADIUS_KM * C.
-spec deg_to_rad(number()) -> float().
deg_to_rad(Deg) -> Deg * math:pi() / 180.
%% ============ Сортировка ============
-spec sort_events([#event{}], map()) -> [#event{}].
sort_events(Events, Params) ->
SortBy = maps:get(sort, Params, <<"start_time">>),
Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
_ -> Events
end,
case Order of
<<"desc">> -> lists:reverse(Sorted);
_ -> Sorted
end.
%% ============ Пагинация ============
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
paginate(List, Limit, Offset) ->
lists:sublist(List, Offset + 1, Limit).
%% ============ Форматирование ответа ============
-spec format_events([#event{}]) -> [map()].
format_events(Events) ->
lists:map(fun format_event/1, Events).
-spec format_event(#event{}) -> map().
format_event(Event) ->
Location = case Event#event.location of
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} -> Cal#calendar.title;
_ -> null
end,
#{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
calendar_title => CalendarTitle,
title => Event#event.title,
description => Event#event.description,
event_type => Event#event.event_type,
start_time => datetime_to_iso8601(Event#event.start_time),
duration => Event#event.duration,
location => Location,
tags => Event#event.tags,
capacity => Event#event.capacity,
rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count,
status => Event#event.status
}.
-spec format_calendars([#calendar{}]) -> [map()].
format_calendars(Calendars) ->
lists:map(fun format_calendar/1, Calendars).
-spec format_calendar(#calendar{}) -> map().
format_calendar(Calendar) ->
#{
id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title,
description => Calendar#calendar.description,
type => Calendar#calendar.type,
tags => Calendar#calendar.tags,
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status
}.
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y, M, D, H, Min, S])).