feat(geo): Photon proxy, address-only location, nearby search. Refs EventHub/EventHubBack#77
CI / test (push) Successful in 7m38s
CI / deploy-ift (push) Successful in 3m27s
CI / e2e-ift (push) Successful in 1m16s
CI / deploy-stage (push) Successful in 2m18s
CI / e2e-stage (push) Successful in 4m11s

This commit is contained in:
2026-08-17 23:23:10 +03:00
parent 501334e241
commit 6b70e1336d
14 changed files with 919 additions and 90 deletions
+4
View File
@@ -43,4 +43,8 @@ REMINDER_LEAD_HOURS=24
# Uploads (avatar/cover) — каталог на volume /app/data (Back#71) # Uploads (avatar/cover) — каталог на volume /app/data (Back#71)
UPLOAD_DIR=/app/data/uploads UPLOAD_DIR=/app/data/uploads
UPLOAD_MAX_BYTES=2097152 UPLOAD_MAX_BYTES=2097152
# Photon (DevOps#16). Пусто = /v1/geo → 503. Включить: PHOTON_REPLICAS=1 и PHOTON_URL=http://photon:2322
PHOTON_URL=
PHOTON_TIMEOUT_MS=3000
PHOTON_REPLICAS=0
+27 -1
View File
@@ -70,6 +70,8 @@ services:
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
- CLUSTER_MODE=true - CLUSTER_MODE=true
- DNS_NAME=eventhub-node - DNS_NAME=eventhub-node
- PHOTON_URL=${PHOTON_URL:-}
- PHOTON_TIMEOUT_MS=${PHOTON_TIMEOUT_MS:-3000}
networks: networks:
eventhub-net: eventhub-net:
aliases: aliases:
@@ -233,6 +235,29 @@ services:
max_attempts: 3 max_attempts: 3
window: 120s window: 120s
# OSM geocoder (komoot Photon). Internal only — do not publish :2322.
# Default replicas 0. Enable: PHOTON_REPLICAS=1 PHOTON_URL=http://photon:2322
photon:
image: ${PHOTON_IMAGE:-rtuszik/photon-docker:latest}
environment:
- REGION=${PHOTON_REGION:-russia}
- UPDATE_STRATEGY=${PHOTON_UPDATE_STRATEGY:-PARALLEL}
volumes:
- photon-data:/photon/data
networks:
eventhub-net:
aliases:
- photon
deploy:
replicas: ${PHOTON_REPLICAS:-0}
resources:
limits:
memory: ${PHOTON_MEMORY_LIMIT:-1536M}
reservations:
memory: 128M
restart_policy:
condition: any
networks: networks:
eventhub-net: eventhub-net:
driver: overlay driver: overlay
@@ -243,4 +268,5 @@ volumes:
prometheus-data: prometheus-data:
grafana-data: grafana-data:
traefik-logs: traefik-logs:
loglynx-data: loglynx-data:
photon-data:
+3
View File
@@ -108,6 +108,9 @@ start_http() ->
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []}, {"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
{"/v1/users/lookup", handler_users_lookup, []}, {"/v1/users/lookup", handler_users_lookup, []},
{"/v1/search", handler_search, []}, {"/v1/search", handler_search, []},
{"/v1/geo/suggest", handler_geo, []},
{"/v1/geo/geocode", handler_geo, []},
{"/v1/geo/reverse", handler_geo, []},
{"/v1/calendars", handler_calendars, []}, {"/v1/calendars", handler_calendars, []},
{"/v1/calendars/:id", handler_calendar_by_id, []}, {"/v1/calendars/:id", handler_calendar_by_id, []},
{"/v1/calendars/:id/cover", handler_calendar_cover, []}, {"/v1/calendars/:id/cover", handler_calendar_cover, []},
@@ -206,14 +206,7 @@ convert_field({<<"start_time">>, Val}) ->
convert_field({<<"duration">>, Val}) -> {duration, Val}; convert_field({<<"duration">>, Val}) -> {duration, Val};
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)}; convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) -> convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
Loc = #location{
address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined)
},
{location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val};
+1 -8
View File
@@ -249,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
RuleJson = jsx:encode(Val), RuleJson = jsx:encode(Val),
{recurrence_rule, RuleJson}; {recurrence_rule, RuleJson};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) -> convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
Loc = #location{
address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined)
},
{location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val};
+1 -8
View File
@@ -302,14 +302,7 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
end. end.
parse_location(undefined) -> undefined; parse_location(undefined) -> undefined;
parse_location(LocationMap) when is_map(LocationMap) -> parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
case LocationMap of
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
Address = maps:get(<<"address">>, LocationMap, <<"">>),
#location{address = Address, lat = Lat, lon = Lon};
_ -> undefined
end;
parse_location(_) -> undefined.
expand_recurring_events(UserId, Events, From, To) -> expand_recurring_events(UserId, Events, From, To) ->
lists:flatmap(fun(Event) -> lists:flatmap(fun(Event) ->
+162
View File
@@ -0,0 +1,162 @@
%%%-------------------------------------------------------------------
%%% @doc GET /v1/geo/suggest, POST /v1/geo/geocode, POST /v1/geo/reverse.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_geo).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
[
#{
path => <<"/v1/geo/suggest">>,
method => <<"GET">>,
description => <<"Address typeahead (Photon). Public + IP rate-limit.">>,
tags => [<<"Geo">>],
parameters => [
#{name => <<"q">>, in => <<"query">>, required => true, schema => #{type => string}},
#{name => <<"lang">>, in => <<"query">>, schema => #{type => string}}
],
responses => #{
200 => #{description => <<"OK">>},
400 => #{description => <<"invalid_query">>},
429 => #{description => <<"rate limited">>},
503 => #{description => <<"geo_unavailable">>}
}
},
#{
path => <<"/v1/geo/geocode">>,
method => <<"POST">>,
description => <<"Forward geocode (Photon). Bearer required.">>,
tags => [<<"Geo">>],
responses => #{
200 => #{description => <<"OK">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"not_found">>},
503 => #{description => <<"geo_unavailable">>}
}
},
#{
path => <<"/v1/geo/reverse">>,
method => <<"POST">>,
description => <<"Reverse geocode (Photon). Bearer required.">>,
tags => [<<"Geo">>],
responses => #{
200 => #{description => <<"OK">>},
401 => #{description => <<"Unauthorized">>},
503 => #{description => <<"geo_unavailable">>}
}
}
].
handle(Req, _Opts) ->
Path = cowboy_req:path(Req),
Method = cowboy_req:method(Req),
case {Path, Method} of
{<<"/v1/geo/suggest">>, <<"GET">>} -> suggest(Req);
{<<"/v1/geo/geocode">>, <<"POST">>} -> geocode(Req);
{<<"/v1/geo/reverse">>, <<"POST">>} -> reverse(Req);
{_, <<"GET">>} when Path =:= <<"/v1/geo/geocode">> orelse Path =:= <<"/v1/geo/reverse">> ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
{<<"/v1/geo/suggest">>, _} ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
_ ->
handler_utils:send_error(Req, 404, <<"Not found">>)
end.
suggest(Req) ->
Qs = cowboy_req:parse_qs(Req),
Q = proplists:get_value(<<"q">>, Qs, <<>>),
Lang = proplists:get_value(<<"lang">>, Qs, <<>>),
Key = <<"s:", (client_key(Req))/binary>>,
reply_suggest(Req, logic_geo:suggest(Q, Lang, Key)).
geocode(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
case decode_map(Body) of
{ok, Map} ->
Q = maps:get(<<"q">>, Map, <<>>),
Lang = maps:get(<<"lang">>, Map, <<>>),
Key = <<"g:", UserId/binary>>,
reply_hit(Req2, logic_geo:geocode(Q, Lang, Key), true);
error ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
reverse(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
case decode_map(Body) of
{ok, Map} ->
Lat = maps:get(<<"lat">>, Map, undefined),
Lon = maps:get(<<"lon">>, Map, undefined),
Lang = maps:get(<<"lang">>, Map, <<>>),
Key = <<"r:", UserId/binary>>,
reply_hit(Req2, logic_geo:reverse(Lat, Lon, Lang, Key), false);
error ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
reply_suggest(Req, {ok, Hits}) ->
handler_utils:send_json(Req, 200, #{<<"results">> => Hits});
reply_suggest(Req, {error, invalid_query}) ->
handler_utils:send_error(Req, 400, <<"invalid_query">>);
reply_suggest(Req, {error, rate_limited}) ->
handler_utils:send_error(Req, 429, <<"rate_limited">>);
reply_suggest(Req, {error, unavailable}) ->
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
reply_hit(Req, {ok, Hit}, _NotFound404) ->
handler_utils:send_json(Req, 200, Hit);
reply_hit(Req, {error, not_found}, true) ->
handler_utils:send_error(Req, 404, <<"not_found">>);
reply_hit(Req, {error, invalid_query}, _) ->
handler_utils:send_error(Req, 400, <<"invalid_query">>);
reply_hit(Req, {error, rate_limited}, _) ->
handler_utils:send_error(Req, 429, <<"rate_limited">>);
reply_hit(Req, {error, unavailable}, _) ->
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
decode_map(Body) when is_binary(Body) ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) -> {ok, Map};
_ -> error
catch
_:_ -> error
end.
client_key(Req) ->
case cowboy_req:header(<<"x-real-ip">>, Req) of
undefined ->
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
undefined -> peer_ip(Req);
<<>> -> peer_ip(Req);
Fwd ->
case binary:split(Fwd, <<",">>) of
[First | _] -> string:trim(First);
_ -> peer_ip(Req)
end
end;
<<>> -> peer_ip(Req);
IP -> IP
end.
peer_ip(Req) ->
case cowboy_req:peer(Req) of
{{A, B, C, D}, _} ->
iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
_ -> <<"unknown">>
end.
+59 -21
View File
@@ -32,7 +32,7 @@ trails() ->
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>}, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>}, #{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>}, #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>, <<"distance">>]}, description => <<"Field to sort by">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>}, #{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>}, #{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>}, #{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
@@ -79,18 +79,22 @@ search(Req) ->
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
Type = proplists:get_value(<<"type">>, Qs, undefined), Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined), Query = proplists:get_value(<<"q">>, Qs, undefined),
Params = parse_params(Qs), case parse_params(Qs) of
case logic_search:search(Type, Query, UserId, Params) of {error, invalid_radius} ->
{ok, Total, Results} -> handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
Response = #{ Params ->
total => Total, case logic_search:search(Type, Query, UserId, Params) of
limit => maps:get(limit, Params, 20), {ok, Total, Results} ->
offset => maps:get(offset, Params, 0), Response = #{
results => Results total => Total,
}, limit => maps:get(limit, Params, 20),
handler_utils:send_json(Req1, 200, Response); offset => maps:get(offset, Params, 0),
{error, _} -> results => Results
handler_utils:send_error(Req1, 500, <<"Search failed">>) },
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Search failed">>)
end
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
@@ -103,7 +107,8 @@ search(Req) ->
%% @private Собирает карту параметров для поискового движка. %% @private Собирает карту параметров для поискового движка.
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе %% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50). %% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
-spec parse_params(cowboy_req:qs()) -> map(). %% radius без ключа при lat/lon — без отсечения (GEO.md).
-spec parse_params(cowboy_req:qs()) -> map() | {error, invalid_radius}.
parse_params(Qs) -> parse_params(Qs) ->
Params0 = #{ Params0 = #{
limit => parse_int_param(Qs, <<"limit">>, 20), limit => parse_int_param(Qs, <<"limit">>, 20),
@@ -125,12 +130,22 @@ parse_params(Qs) ->
end, end,
Params1#{sort => Sort, order => Order} Params1#{sort => Sort, order => Order}
end, end,
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
{{ok, Lat}, {ok, Lon}} -> {{ok, Lat}, {ok, Lon}} ->
Radius = parse_int_param(Qs, <<"radius">>, 10), case parse_optional_radius(Qs) of
Params2#{lat => Lat, lon => Lon, radius => Radius}; {error, invalid_radius} = E -> E;
_ -> Params2 Radius ->
end, Params3 = case Radius of
undefined -> Params2#{lat => Lat, lon => Lon};
R -> Params2#{lat => Lat, lon => Lon, radius => R}
end,
with_dates(Qs, Params3)
end;
_ ->
with_dates(Qs, Params2)
end.
with_dates(Qs, Params3) ->
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To}; {{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
{{ok, From}, error} -> Params3#{from => From}; {{ok, From}, error} -> Params3#{from => From};
@@ -138,6 +153,18 @@ parse_params(Qs) ->
_ -> Params3 _ -> Params3
end. end.
parse_optional_radius(Qs) ->
case proplists:get_value(<<"radius">>, Qs) of
undefined -> undefined;
<<>> -> undefined;
Val ->
N = handler_utils:parse_int_qs(Val, 0),
case N >= 1 andalso N =< 100 of
true -> N;
false -> {error, invalid_radius}
end
end.
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer(). -spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
parse_int_param(Qs, Key, Default) -> parse_int_param(Qs, Key, Default) ->
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default). handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
@@ -146,7 +173,18 @@ parse_int_param(Qs, Key, Default) ->
parse_float_param(Qs, Key) -> parse_float_param(Qs, Key) ->
case proplists:get_value(Key, Qs) of case proplists:get_value(Key, Qs) of
undefined -> error; undefined -> error;
Val -> {ok, binary_to_float(Val)} <<>> -> error;
Val ->
try binary_to_float(Val) of
F -> {ok, F}
catch
_:_ ->
try binary_to_integer(Val) of
I -> {ok, float(I)}
catch
_:_ -> error
end
end
end. end.
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error. -spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
+64 -7
View File
@@ -33,7 +33,10 @@
is_superadmin/1, is_superadmin/1,
pagination_headers/2, pagination_headers/2,
maybe_report_internal_error/3, maybe_report_internal_error/3,
report_and_send_error/4 report_and_send_error/4,
parse_location/1,
location_to_json/1,
meaningful_coords/2
]). ]).
-export([admin_to_json/1, audit_to_json/1]). -export([admin_to_json/1, audit_to_json/1]).
@@ -317,11 +320,7 @@ audit_reason(_) -> <<>>.
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость). %% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
-spec event_to_json(#event{}) -> map(). -spec event_to_json(#event{}) -> map().
event_to_json(Event) -> event_to_json(Event) ->
LocationJson = case Event#event.location of LocationJson = location_to_json(Event#event.location),
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
RecurrenceJson = case Event#event.recurrence_rule of RecurrenceJson = case Event#event.recurrence_rule of
undefined -> null; undefined -> null;
Rule -> try jsx:decode(Rule, [return_maps]) of Rule -> try jsx:decode(Rule, [return_maps]) of
@@ -647,4 +646,62 @@ trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
200 => #{description => <<"Record deleted">>} 200 => #{description => <<"Record deleted">>}
} }
} }
]. ].
%%%===================================================================
%%% Location
%%%===================================================================
-spec parse_location(term()) -> #location{} | undefined.
parse_location(undefined) -> undefined;
parse_location(null) -> undefined;
parse_location(LocationMap) when is_map(LocationMap) ->
Addr0 = maps:get(<<"address">>, LocationMap, maps:get(address, LocationMap, <<>>)),
Addr = case Addr0 of
A when is_binary(A) -> A;
_ -> <<>>
end,
Lat = to_coord(maps:get(<<"lat">>, LocationMap,
maps:get(lat, LocationMap, undefined))),
Lon = to_coord(maps:get(<<"lon">>, LocationMap,
maps:get(lon, LocationMap, undefined))),
Coords = meaningful_coords(Lat, Lon),
HasAddr = Addr =/= <<>> andalso Addr =/= <<"undefined">>,
case {HasAddr, Coords, Lat =/= undefined orelse Lon =/= undefined} of
{false, false, _} -> undefined;
{_, true, _} ->
#location{address = Addr, lat = Lat, lon = Lon};
{true, false, false} ->
#location{address = Addr, lat = undefined, lon = undefined};
{true, false, true} ->
%% одна координата без пары — храним только адрес
#location{address = Addr, lat = undefined, lon = undefined};
_ -> undefined
end;
parse_location(_) -> undefined.
-spec location_to_json(undefined | #location{}) -> null | map().
location_to_json(undefined) -> null;
location_to_json(#location{address = Addr, lat = Lat, lon = Lon}) ->
AddrJ = case Addr of
undefined -> null;
<<>> -> null;
A -> A
end,
case meaningful_coords(Lat, Lon) of
true -> #{address => AddrJ, lat => Lat, lon => Lon};
false -> #{address => AddrJ, lat => null, lon => null}
end.
-spec meaningful_coords(term(), term()) -> boolean().
meaningful_coords(Lat, Lon) ->
is_number(Lat) andalso is_number(Lon)
andalso not (Lat == 0 andalso Lon == 0)
andalso Lat >= -90 andalso Lat =< 90
andalso Lon >= -180 andalso Lon =< 180.
to_coord(undefined) -> undefined;
to_coord(null) -> undefined;
to_coord(N) when is_integer(N) -> float(N);
to_coord(N) when is_float(N) -> N;
to_coord(_) -> undefined.
+316
View File
@@ -0,0 +1,316 @@
%%%-------------------------------------------------------------------
%%% @doc Прокси к self-host Photon: suggest / geocode / reverse.
%%% Кэш и rate-limit в ETS. Без PHOTON_URL — {error, unavailable}.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_geo).
-export([suggest/3, geocode/3, reverse/4, ensure/0]).
-export([format_address/1]).
-define(CACHE, eventhub_geo_cache).
-define(RL, eventhub_geo_rl).
-define(MIN_Q, 3).
-define(CACHE_TTL_SEC, 604800).
-define(RL_WINDOW_SEC, 60).
-define(RL_SUGGEST, 40).
-define(RL_WRITE, 20).
-spec ensure() -> ok.
ensure() ->
case ets:info(?CACHE) of
undefined ->
ets:new(?CACHE, [named_table, public, set, {read_concurrency, true}]);
_ -> ok
end,
case ets:info(?RL) of
undefined ->
ets:new(?RL, [named_table, public, set, {write_concurrency, true}]);
_ -> ok
end,
ok.
-spec suggest(binary(), binary(), binary()) ->
{ok, [map()]} | {error, invalid_query | rate_limited | unavailable}.
suggest(Q, Lang, RlKey) ->
ensure(),
case normalize_q(Q) of
{error, invalid_query} = E -> E;
{ok, Qn} ->
case allow(RlKey, ?RL_SUGGEST) of
false -> {error, rate_limited};
true ->
LangN = lang(Lang),
CacheKey = {suggest, Qn, LangN},
case cache_get(CacheKey) of
{ok, Hits} -> {ok, Hits};
miss ->
case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
{<<"limit">>, <<"8">>}]) of
{ok, Body} ->
Hits = features_to_hits(Body),
cache_put(CacheKey, Hits),
{ok, Hits};
{error, _} -> {error, unavailable}
end
end
end
end.
-spec geocode(binary(), binary(), binary()) ->
{ok, map()} | {error, invalid_query | not_found | rate_limited | unavailable}.
geocode(Q, Lang, RlKey) ->
ensure(),
case normalize_q(Q) of
{error, invalid_query} = E -> E;
{ok, Qn} ->
case allow(RlKey, ?RL_WRITE) of
false -> {error, rate_limited};
true ->
LangN = lang(Lang),
CacheKey = {geocode, Qn, LangN},
case cache_get(CacheKey) of
{ok, Hit} -> {ok, Hit};
miss ->
case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
{<<"limit">>, <<"1">>}]) of
{ok, Body} ->
case features_to_hits(Body) of
[Hit | _] ->
Out = Hit#{<<"source">> => <<"photon">>},
cache_put(CacheKey, Out),
{ok, Out};
[] -> {error, not_found}
end;
{error, _} -> {error, unavailable}
end
end
end
end.
-spec reverse(number(), number(), binary(), binary()) ->
{ok, map()} | {error, invalid_query | rate_limited | unavailable}.
reverse(Lat, Lon, Lang, RlKey) when is_number(Lat), is_number(Lon) ->
ensure(),
case {valid_lat(Lat), valid_lon(Lon)} of
{true, true} ->
case allow(RlKey, ?RL_WRITE) of
false -> {error, rate_limited};
true ->
LangN = lang(Lang),
LatR = round_coord(Lat),
LonR = round_coord(Lon),
CacheKey = {reverse, LatR, LonR, LangN},
case cache_get(CacheKey) of
{ok, Hit} -> {ok, Hit};
miss ->
LatB = float_to_bin(LatR),
LonB = float_to_bin(LonR),
case photon_get(<<"/reverse">>, [{<<"lat">>, LatB}, {<<"lon">>, LonB},
{<<"lang">>, LangN}]) of
{ok, Body} ->
Hit = case features_to_hits(Body) of
[H | _] -> H#{<<"source">> => <<"photon">>};
[] ->
#{<<"address">> => fallback_addr(LatR, LonR),
<<"lat">> => LatR,
<<"lon">> => LonR,
<<"source">> => <<"photon">>}
end,
cache_put(CacheKey, Hit),
{ok, Hit};
{error, _} -> {error, unavailable}
end
end
end;
_ -> {error, invalid_query}
end;
reverse(_, _, _, _) ->
{error, invalid_query}.
%% ——— internals ———
normalize_q(Q) when is_binary(Q) ->
T = string:trim(Q),
case string:length(T) >= ?MIN_Q of
true -> {ok, T};
false -> {error, invalid_query}
end;
normalize_q(_) ->
{error, invalid_query}.
lang(<<>>) -> <<"ru">>;
lang(undefined) -> <<"ru">>;
lang(L) when is_binary(L), byte_size(L) >= 2 -> binary:part(L, 0, 2);
lang(_) -> <<"ru">>.
valid_lat(Lat) -> is_number(Lat) andalso Lat >= -90 andalso Lat =< 90.
valid_lon(Lon) -> is_number(Lon) andalso Lon >= -180 andalso Lon =< 180.
round_coord(N) when is_integer(N) -> float(N);
round_coord(N) when is_float(N) -> round(N * 100000) / 100000.
float_to_bin(N) when is_integer(N) -> integer_to_binary(N);
float_to_bin(N) when is_float(N) ->
iolist_to_binary(io_lib:format("~.6f", [N])).
fallback_addr(Lat, Lon) ->
iolist_to_binary(io_lib:format("~.5f, ~.5f", [float(Lat), float(Lon)])).
allow(Key, Limit) ->
Now = erlang:monotonic_time(second),
case ets:lookup(?RL, Key) of
[{Key, Count, Start}] when Now - Start < ?RL_WINDOW_SEC ->
case Count < Limit of
true ->
ets:update_counter(?RL, Key, {2, 1}),
true;
false -> false
end;
_ ->
ets:insert(?RL, {Key, 1, Now}),
true
end.
cache_get(Key) ->
Now = erlang:system_time(second),
case ets:lookup(?CACHE, Key) of
[{Key, Exp, Val}] when Exp > Now -> {ok, Val};
[_] -> ets:delete(?CACHE, Key), miss;
[] -> miss
end.
cache_put(Key, Val) ->
Exp = erlang:system_time(second) + ?CACHE_TTL_SEC,
ets:insert(?CACHE, {Key, Exp, Val}),
ok.
photon_url() ->
case os:getenv("PHOTON_URL") of
false -> <<>>;
"" -> <<>>;
Url -> list_to_binary(string:trim(Url))
end.
timeout_ms() ->
case os:getenv("PHOTON_TIMEOUT_MS") of
false -> 3000;
"" -> 3000;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> 3000
catch
_:_ -> 3000
end
end.
photon_get(Path, Qs) ->
case photon_url() of
<<>> -> {error, unavailable};
Base0 ->
Base = strip_slash(Base0),
Query = uri_string:compose_query(Qs),
Url = binary_to_list(<<Base/binary, Path/binary, $?, Query/binary>>),
http_get(Url)
end.
strip_slash(B) ->
case binary:last(B) of
$/ -> binary:part(B, 0, byte_size(B) - 1);
_ -> B
end.
http_get(Url) ->
Fun = case application:get_env(eventhub, geo_http_get) of
{ok, F} when is_function(F, 1) -> F;
_ -> fun default_http_get/1
end,
Fun(Url).
default_http_get(Url) ->
_ = application:ensure_all_started(inets),
Headers = [{"user-agent", "EventHub/1.0 (geo; +https://calentiq.com)"}],
HttpOpts = [{timeout, timeout_ms()}],
case httpc:request(get, {Url, Headers}, HttpOpts, [{body_format, binary}]) of
{ok, {{_, Code, _}, _, Body}} when Code >= 200, Code < 300, is_binary(Body) ->
{ok, Body};
{ok, {{_, Code, _}, _, _}} ->
{error, {http_status, Code}};
{error, Reason} ->
{error, Reason}
end.
features_to_hits(Body) when is_binary(Body) ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
Feats = maps:get(<<"features">>, Map, []),
lists:filtermap(fun feature_to_hit/1, Feats);
_ -> []
catch
_:_ -> []
end.
feature_to_hit(#{<<"geometry">> := #{<<"coordinates">> := [Lon, Lat | _]}} = F) ->
Props = maps:get(<<"properties">>, F, #{}),
Addr = format_address(Props),
{true, #{
<<"address">> => Addr,
<<"lat">> => to_num(Lat),
<<"lon">> => to_num(Lon)
}};
feature_to_hit(_) ->
false.
to_num(N) when is_integer(N) -> float(N);
to_num(N) when is_float(N) -> N;
to_num(_) -> 0.0.
-spec format_address(map()) -> binary().
format_address(Props) when is_map(Props) ->
Name = bin(maps:get(<<"name">>, Props, <<>>)),
House = bin(maps:get(<<"housenumber">>, Props, <<>>)),
Street = bin(maps:get(<<"street">>, Props, <<>>)),
StreetLine = case {House, Street} of
{<<>>, S} -> S;
{H, <<>>} -> H;
{H, S} -> <<H/binary, " ", S/binary>>
end,
City = first_nonempty([
maps:get(<<"city">>, Props, <<>>),
maps:get(<<"town">>, Props, <<>>),
maps:get(<<"village">>, Props, <<>>),
maps:get(<<"locality">>, Props, <<>>)
]),
Country = bin(maps:get(<<"country">>, Props, <<>>)),
Parts0 = [Name, StreetLine, City, Country],
Parts = [P || P <- Parts0, P =/= <<>>],
Unique = unique_keep(Parts),
case Unique of
[] -> <<"">>;
_ -> join_comma(Unique)
end.
bin(B) when is_binary(B) -> string:trim(B);
bin(I) when is_integer(I) -> integer_to_binary(I);
bin(_) -> <<>>.
first_nonempty([]) -> <<>>;
first_nonempty([H | T]) ->
B = bin(H),
case B of
<<>> -> first_nonempty(T);
_ -> B
end.
unique_keep(List) ->
lists:reverse(lists:foldl(fun(X, Acc) ->
case lists:member(X, Acc) of
true -> Acc;
false -> [X | Acc]
end
end, [], List)).
join_comma([H]) -> H;
join_comma([H | T]) ->
Rest = join_comma(T),
<<H/binary, ", ", Rest/binary>>.
+146 -37
View File
@@ -98,7 +98,7 @@ discovery_events(UserId, Params, Limit, Offset) ->
search_events(undefined, UserId, Params, Limit, Offset); search_events(undefined, UserId, Params, Limit, Offset);
Items -> Items ->
Total = length(Items), Total = length(Items),
{ok, Total, format_events(paginate(Items, Limit, Offset))} {ok, Total, format_events(paginate(Items, Limit, Offset), Params)}
end. end.
discovery_calendars(UserId, Params, Limit, Offset) -> discovery_calendars(UserId, Params, Limit, Offset) ->
@@ -110,7 +110,7 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
search_calendars(undefined, UserId, Params, Limit, Offset); search_calendars(undefined, UserId, Params, Limit, Offset);
Items -> Items ->
Total = length(Items), Total = length(Items),
{ok, Total, format_calendars(paginate(Items, Limit, Offset))} {ok, Total, format_calendars(paginate(Items, Limit, Offset), Params)}
end. end.
%% ============ Поиск событий ============ %% ============ Поиск событий ============
@@ -131,7 +131,7 @@ search_events(Query, UserId, Params, Limit, Offset) ->
%% Pagination is applied BEFORE formatting so only the page slice is %% Pagination is applied BEFORE formatting so only the page slice is
%% enriched with calendar data (see format_events). %% enriched with calendar data (see format_events).
Paginated = paginate(Sorted, Limit, Offset), Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_events(Paginated)}. {ok, length(Filtered), format_events(Paginated, Params)}.
%% ============ Поиск календарей ============ %% ============ Поиск календарей ============
@@ -145,8 +145,9 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
AllCalendars = get_all_calendars(), AllCalendars = get_all_calendars(),
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId), AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params), Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
Paginated = paginate(Filtered, Limit, Offset), Sorted = sort_calendars(Filtered, Params),
{ok, length(Filtered), format_calendars(Paginated)}. Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_calendars(Paginated, Params)}.
%% ============ Получение данных ============ %% ============ Получение данных ============
@@ -223,7 +224,8 @@ apply_event_filters(Events, Query, Params) ->
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}]. -spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
apply_calendar_filters(Calendars, Query, Params) -> apply_calendar_filters(Calendars, Query, Params) ->
Calendars1 = filter_by_text(Calendars, Query), Calendars1 = filter_by_text(Calendars, Query),
filter_by_tags(Calendars1, Params). Calendars2 = filter_by_tags(Calendars1, Params),
filter_calendars_by_location(Calendars2, Params).
%% --- Текстовый поиск --- %% --- Текстовый поиск ---
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}]. -spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
@@ -269,21 +271,63 @@ filter_by_date_range(Events, Params) ->
%% --- Гео-фильтр --- %% --- Гео-фильтр ---
-spec filter_by_location([#event{}], map()) -> [#event{}]. -spec filter_by_location([#event{}], map()) -> [#event{}].
filter_by_location(Events, Params) -> filter_by_location(Events, Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of case geo_origin(Params) of
{undefined, _} -> Events; none -> Events;
{_, undefined} -> Events;
{Lat, Lon} -> {Lat, Lon} ->
Radius = maps:get(radius, Params, 10), Radius = maps:get(radius, Params, undefined),
lists:filter(fun(Event) -> lists:filter(fun(Event) ->
case Event#event.location of within_radius(event_coords(Event), Lat, Lon, Radius)
#location{lat = EventLat, lon = EventLon} end, Events)
when is_number(EventLat), is_number(EventLon) ->
distance(Lat, Lon, EventLat, EventLon) =< Radius;
_ -> false
end
end, Events)
end. end.
-spec filter_calendars_by_location([#calendar{}], map()) -> [#calendar{}].
filter_calendars_by_location(Calendars, Params) ->
case geo_origin(Params) of
none -> Calendars;
{Lat, Lon} ->
Radius = maps:get(radius, Params, undefined),
lists:filter(fun(Cal) ->
within_radius(calendar_coords(Cal), Lat, Lon, Radius)
end, Calendars)
end.
geo_origin(Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
{Lat, Lon} when is_number(Lat), is_number(Lon) -> {Lat, Lon};
_ -> none
end.
within_radius(none, _, _, _) -> false;
within_radius({ok, ELat, ELon}, Lat, Lon, Radius) ->
D = distance(Lat, Lon, ELat, ELon),
Radius =:= undefined orelse D =< Radius.
event_coords(#event{location = Loc}) -> loc_coords(Loc);
event_coords(_) -> none.
calendar_coords(#calendar{settings = Settings}) when is_map(Settings) ->
Loc = maps:get(<<"default_location">>, Settings,
maps:get(default_location, Settings, undefined)),
loc_coords(Loc);
calendar_coords(_) -> none.
loc_coords(#location{lat = Lat, lon = Lon}) ->
case handler_utils:meaningful_coords(Lat, Lon) of
true -> {ok, to_float(Lat), to_float(Lon)};
false -> none
end;
loc_coords(Loc) when is_map(Loc) ->
Lat = maps:get(<<"lat">>, Loc, maps:get(lat, Loc, undefined)),
Lon = maps:get(<<"lon">>, Loc, maps:get(lon, Loc, undefined)),
case handler_utils:meaningful_coords(Lat, Lon) of
true -> {ok, to_float(Lat), to_float(Lon)};
false -> none
end;
loc_coords(_) -> none.
to_float(N) when is_integer(N) -> float(N);
to_float(N) when is_float(N) -> N.
%% ============ Вспомогательные функции ============ %% ============ Вспомогательные функции ============
-spec get_title(#event{} | #calendar{}) -> binary(). -spec get_title(#event{} | #calendar{}) -> binary().
@@ -322,19 +366,66 @@ deg_to_rad(Deg) -> Deg * math:pi() / 180.
-spec sort_events([#event{}], map()) -> [#event{}]. -spec sort_events([#event{}], map()) -> [#event{}].
sort_events(Events, Params) -> sort_events(Events, Params) ->
SortBy = maps:get(sort, Params, <<"start_time">>), Default = case geo_origin(Params) of
none -> <<"start_time">>;
_ -> <<"distance">>
end,
SortBy = maps:get(sort, Params, Default),
Order = maps:get(order, Params, <<"asc">>), Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of Sorted = case SortBy of
<<"distance">> -> sort_by_distance_events(Events, Params);
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events); <<"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); <<"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); <<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
<<"title">> -> lists:sort(fun(A, B) -> A#event.title =< B#event.title end, Events);
_ -> Events _ -> Events
end, end,
case {SortBy, Order} of
{<<"distance">>, <<"desc">>} -> lists:reverse(Sorted);
{<<"rating">>, _} -> Sorted;
{_, <<"desc">>} -> lists:reverse(Sorted);
_ -> Sorted
end.
sort_calendars(Calendars, Params) ->
Default = case geo_origin(Params) of
none -> <<"title">>;
_ -> <<"distance">>
end,
SortBy = maps:get(sort, Params, Default),
Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of
<<"distance">> -> sort_by_distance_calendars(Calendars, Params);
<<"created_at">> -> lists:sort(fun(A, B) -> A#calendar.created_at =< B#calendar.created_at end, Calendars);
<<"title">> -> lists:sort(fun(A, B) -> A#calendar.title =< B#calendar.title end, Calendars);
_ -> Calendars
end,
case Order of case Order of
<<"desc">> -> lists:reverse(Sorted); <<"desc">> -> lists:reverse(Sorted);
_ -> Sorted _ -> Sorted
end. end.
sort_by_distance_events(Events, Params) ->
case geo_origin(Params) of
none -> Events;
{Lat, Lon} ->
lists:sort(fun(A, B) ->
dist_or_inf(event_coords(A), Lat, Lon) =< dist_or_inf(event_coords(B), Lat, Lon)
end, Events)
end.
sort_by_distance_calendars(Cals, Params) ->
case geo_origin(Params) of
none -> Cals;
{Lat, Lon} ->
lists:sort(fun(A, B) ->
dist_or_inf(calendar_coords(A), Lat, Lon) =< dist_or_inf(calendar_coords(B), Lat, Lon)
end, Cals)
end.
dist_or_inf({ok, ELat, ELon}, Lat, Lon) -> distance(Lat, Lon, ELat, ELon);
dist_or_inf(none, _, _) -> 1.0e12.
%% ============ Пагинация ============ %% ============ Пагинация ============
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()]. -spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
@@ -343,28 +434,24 @@ paginate(List, Limit, Offset) ->
%% ============ Форматирование ответа ============ %% ============ Форматирование ответа ============
-spec format_events([#event{}]) -> [map()]. -spec format_events([#event{}], map()) -> [map()].
format_events(Events) -> format_events(Events, Params) ->
%% Optimization: batch-fetch calendars for the (already paginated) %% Optimization: batch-fetch calendars for the (already paginated)
%% subset only, avoiding an N+1 query per event during formatting. %% subset only, avoiding an N+1 query per event during formatting.
CalendarMap = build_calendar_map( CalendarMap = build_calendar_map(
lists:usort([E#event.calendar_id || E <- Events])), lists:usort([E#event.calendar_id || E <- Events])),
lists:map(fun(E) -> format_event(E, CalendarMap) end, Events). lists:map(fun(E) -> format_event(E, CalendarMap, Params) end, Events).
-spec format_event(#event{}, #{binary() => #calendar{}}) -> map(). -spec format_event(#event{}, #{binary() => #calendar{}}, map()) -> map().
format_event(Event, CalendarMap) -> format_event(Event, CalendarMap, Params) ->
Location = case Event#event.location of Location = handler_utils:location_to_json(Event#event.location),
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of {CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null}; {ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
{ok, #calendar{title = T, image_url = undefined}} -> {T, null}; {ok, #calendar{title = T, image_url = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url}; {ok, #calendar{title = T, image_url = Url}} -> {T, Url};
error -> {null, null} error -> {null, null}
end, end,
#{ Base = #{
id => Event#event.id, id => Event#event.id,
calendar_id => Event#event.calendar_id, calendar_id => Event#event.calendar_id,
calendar_title => CalendarTitle, calendar_title => CalendarTitle,
@@ -380,15 +467,25 @@ format_event(Event, CalendarMap) ->
rating_count => Event#event.rating_count, rating_count => Event#event.rating_count,
status => Event#event.status, status => Event#event.status,
image_url => ImageUrl image_url => ImageUrl
}. },
maybe_distance(Base, event_coords(Event), Params).
-spec format_calendars([#calendar{}]) -> [map()]. -spec format_calendars([#calendar{}], map()) -> [map()].
format_calendars(Calendars) -> format_calendars(Calendars, Params) ->
lists:map(fun format_calendar/1, Calendars). lists:map(fun(C) -> format_calendar(C, Params) end, Calendars).
-spec format_calendar(#calendar{}) -> map(). -spec format_calendar(#calendar{}, map()) -> map().
format_calendar(Calendar) -> format_calendar(Calendar, Params) ->
#{ Settings = case Calendar#calendar.settings of
M when is_map(M) -> M;
_ -> #{}
end,
LocJson = case maps:get(<<"default_location">>, Settings,
maps:get(default_location, Settings, undefined)) of
undefined -> null;
Loc -> Loc
end,
Base = #{
id => Calendar#calendar.id, id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id, owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title, title => Calendar#calendar.title,
@@ -404,7 +501,19 @@ format_calendar(Calendar) ->
rating_avg => Calendar#calendar.rating_avg, rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count, rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status status => Calendar#calendar.status
}. },
Base1 = case geo_origin(Params) of
none -> Base;
_ -> Base#{default_location => LocJson}
end,
maybe_distance(Base1, calendar_coords(Calendar), Params).
maybe_distance(Map, Coords, Params) ->
case {geo_origin(Params), Coords} of
{{OLat, OLon}, {ok, Lat, Lon}} ->
Map#{distance_km => round(distance(OLat, OLon, Lat, Lon) * 10) / 10};
_ -> Map
end.
-spec datetime_to_iso8601(calendar:datetime()) -> binary(). -spec datetime_to_iso8601(calendar:datetime()) -> binary().
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) -> datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
+1
View File
@@ -93,6 +93,7 @@ user() ->
handler_review_vote, handler_review_vote,
handler_reviews, handler_reviews,
handler_search, handler_search,
handler_geo,
handler_subscription, handler_subscription,
handler_ticket_by_id, handler_ticket_by_id,
handler_tickets, handler_tickets,
+78
View File
@@ -0,0 +1,78 @@
-module(logic_geo_tests).
-include_lib("eunit/include/eunit.hrl").
setup() ->
catch ets:delete(eventhub_geo_cache),
catch ets:delete(eventhub_geo_rl),
logic_geo:ensure(),
application:unset_env(eventhub, geo_http_get),
os:putenv("PHOTON_URL", "http://photon.test"),
ok.
cleanup(_) ->
application:unset_env(eventhub, geo_http_get),
os:unsetenv("PHOTON_URL"),
catch ets:delete(eventhub_geo_cache),
catch ets:delete(eventhub_geo_rl),
ok.
logic_geo_test_() ->
{foreach,
fun setup/0,
fun cleanup/1,
[
{"Suggest too short", fun test_suggest_short/0},
{"Suggest parses photon geojson", fun test_suggest_ok/0},
{"Geocode empty features is not_found", fun test_geocode_not_found/0},
{"Unavailable without photon url", fun test_unavailable/0},
{"Reverse fallback address", fun test_reverse_empty/0},
{"Format address from properties", fun test_format_address/0}
]}.
sample_geojson() ->
<<"{
\"features\": [{
\"geometry\": {\"coordinates\": [37.62, 55.75]},
\"properties\": {
\"name\": \"Red Square\",
\"city\": \"Moscow\",
\"country\": \"Russia\"
}
}]
}">>.
test_suggest_short() ->
?assertEqual({error, invalid_query}, logic_geo:suggest(<<"ab">>, <<"ru">>, <<"ip">>)).
test_suggest_ok() ->
application:set_env(eventhub, geo_http_get, fun(_) -> {ok, sample_geojson()} end),
{ok, Hits} = logic_geo:suggest(<<"красная площадь">>, <<"ru">>, <<"ip1">>),
?assertMatch([#{<<"lat">> := 55.75, <<"lon">> := 37.62}], Hits),
[#{<<"address">> := Addr}] = Hits,
?assertNotEqual(<<>>, Addr).
test_geocode_not_found() ->
application:set_env(eventhub, geo_http_get, fun(_) -> {ok, <<"{\"features\":[]}">>} end),
?assertEqual({error, not_found},
logic_geo:geocode(<<"nowherexyz">>, <<"ru">>, <<"u1">>)).
test_unavailable() ->
os:unsetenv("PHOTON_URL"),
application:unset_env(eventhub, geo_http_get),
?assertEqual({error, unavailable},
logic_geo:suggest(<<"москва центр">>, <<"ru">>, <<"ip2">>)).
test_reverse_empty() ->
application:set_env(eventhub, geo_http_get, fun(_) -> {ok, <<"{\"features\":[]}">>} end),
{ok, Hit} = logic_geo:reverse(55.75, 37.62, <<"ru">>, <<"u2">>),
?assertEqual(<<"photon">>, maps:get(<<"source">>, Hit)),
?assertEqual(55.75, maps:get(<<"lat">>, Hit)).
test_format_address() ->
Addr = logic_geo:format_address(#{
<<"housenumber">> => <<"1">>,
<<"street">> => <<"Tverskaya">>,
<<"city">> => <<"Moscow">>,
<<"country">> => <<"Russia">>
}),
?assertEqual(<<"1 Tverskaya, Moscow, Russia">>, Addr).
+56
View File
@@ -23,6 +23,9 @@ logic_search_test_() ->
{"Search events by tags", fun test_search_events_by_tags/0}, {"Search events by tags", fun test_search_events_by_tags/0},
{"Search events by date range", fun test_search_events_by_date/0}, {"Search events by date range", fun test_search_events_by_date/0},
{"Search events by location", fun test_search_events_by_location/0}, {"Search events by location", fun test_search_events_by_location/0},
{"Search calendars by location", fun test_search_calendars_by_location/0},
{"Geo without radius keeps distant hits", fun test_geo_no_radius/0},
{"Geo sort by distance", fun test_geo_sort_distance/0},
{"Combined search", fun test_combined_search/0}, {"Combined search", fun test_combined_search/0},
{"Search calendars", fun test_search_calendars/0}, {"Search calendars", fun test_search_calendars/0},
{"Search calendars include image_url", fun test_search_calendars_image_url/0}, {"Search calendars include image_url", fun test_search_calendars_image_url/0},
@@ -152,6 +155,59 @@ test_search_events_by_location() ->
#{lat => 59.9343, lon => 30.3351, radius => 10})), #{lat => 59.9343, lon => 30.3351, radius => 10})),
?assertEqual(1, Total2). ?assertEqual(1, Total2).
test_search_calendars_by_location() ->
OwnerId = create_test_user(user),
NearId = create_test_calendar(OwnerId, commercial, []),
FarId = create_test_calendar(OwnerId, commercial, []),
set_default_location(NearId, <<"Moscow">>, 55.7558, 37.6173),
set_default_location(FarId, <<"SPb">>, 59.9343, 30.3351),
{Total, Hits} = calendars_from(logic_search:search(<<"calendar">>, undefined, OwnerId,
#{lat => 55.7558, lon => 37.6173, radius => 10})),
?assertEqual(1, Total),
[Hit] = Hits,
?assertEqual(NearId, maps:get(id, Hit)),
?assert(maps:is_key(distance_km, Hit)).
test_geo_no_radius() ->
OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, commercial, []),
StartTime = eh_test_support:future_start(),
MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173},
SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351},
create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc),
create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc),
{Total, _} = events_from(logic_search:search(<<"event">>, undefined, OwnerId,
#{lat => 55.7558, lon => 37.6173})),
?assertEqual(2, Total).
test_geo_sort_distance() ->
OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, commercial, []),
StartTime = eh_test_support:future_start(),
MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173},
SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351},
create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc),
create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc),
{_, [First | _]} = events_from(logic_search:search(<<"event">>, undefined, OwnerId,
#{lat => 55.7558, lon => 37.6173})),
?assertMatch(#{title := <<"Moscow Event">>}, First),
?assert(maps:is_key(distance_km, First)).
set_default_location(CalendarId, Addr, Lat, Lon) ->
{ok, Cal} = core_calendar:get_by_id(CalendarId),
Settings0 = case Cal#calendar.settings of
M when is_map(M) -> M;
_ -> #{}
end,
Settings = Settings0#{
<<"default_location">> => #{
<<"address">> => Addr,
<<"lat">> => Lat,
<<"lon">> => Lon
}
},
core_calendar:update(CalendarId, [{settings, Settings}]).
test_combined_search() -> test_combined_search() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, commercial, []), CalendarId = create_test_calendar(OwnerId, commercial, []),