feat(geo): switch geocoder from self-host Photon to OpenCage API
logic_geo проксирует OpenCage (suggest/geocode/reverse): env OPENCAGE_API_KEY, OPENCAGE_URL, OPENCAGE_TIMEOUT_MS, OPENCAGE_COUNTRYCODE, OPENCAGE_THROTTLE_RPS (1 rps под free-тариф, 0 - выключить). HTTP 402/403/429 апстрима -> 503 geo_unavailable; source=opencage. Сервис photon и volume photon-data удалены из swarm compose; eunit-фикстуры под формат OpenCage. Refs EventHub/EventHubBack#77
This commit is contained in:
@@ -15,7 +15,7 @@ trails() ->
|
||||
#{
|
||||
path => <<"/v1/geo/suggest">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Address typeahead (Photon). Public + IP rate-limit.">>,
|
||||
description => <<"Address typeahead (OpenCage). Public + IP rate-limit.">>,
|
||||
tags => [<<"Geo">>],
|
||||
parameters => [
|
||||
#{name => <<"q">>, in => <<"query">>, required => true, schema => #{type => string}},
|
||||
@@ -31,7 +31,7 @@ trails() ->
|
||||
#{
|
||||
path => <<"/v1/geo/geocode">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Forward geocode (Photon). Bearer required.">>,
|
||||
description => <<"Forward geocode (OpenCage). Bearer required.">>,
|
||||
tags => [<<"Geo">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"OK">>},
|
||||
@@ -43,7 +43,7 @@ trails() ->
|
||||
#{
|
||||
path => <<"/v1/geo/reverse">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Reverse geocode (Photon). Bearer required.">>,
|
||||
description => <<"Reverse geocode (OpenCage). Bearer required.">>,
|
||||
tags => [<<"Geo">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"OK">>},
|
||||
|
||||
+122
-34
@@ -1,6 +1,7 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Прокси к self-host Photon: suggest / geocode / reverse.
|
||||
%%% Кэш и rate-limit в ETS. Без PHOTON_URL — {error, unavailable}.
|
||||
%%% @doc Прокси к OpenCage API: suggest / geocode / reverse.
|
||||
%%% Кэш и rate-limit в ETS. Без OPENCAGE_API_KEY — {error, unavailable}.
|
||||
%%% Глобальный троттлинг апстрима (free-тариф: 1 rps, 2500/сутки).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_geo).
|
||||
@@ -9,11 +10,13 @@
|
||||
|
||||
-define(CACHE, eventhub_geo_cache).
|
||||
-define(RL, eventhub_geo_rl).
|
||||
-define(UP, eventhub_geo_upstream).
|
||||
-define(MIN_Q, 3).
|
||||
-define(CACHE_TTL_SEC, 604800).
|
||||
-define(RL_WINDOW_SEC, 60).
|
||||
-define(RL_SUGGEST, 40).
|
||||
-define(RL_WRITE, 20).
|
||||
-define(DEFAULT_URL, <<"https://api.opencagedata.com/geocode/v1/json">>).
|
||||
|
||||
-spec ensure() -> ok.
|
||||
ensure() ->
|
||||
@@ -27,6 +30,11 @@ ensure() ->
|
||||
ets:new(?RL, [named_table, public, set, {write_concurrency, true}]);
|
||||
_ -> ok
|
||||
end,
|
||||
case ets:info(?UP) of
|
||||
undefined ->
|
||||
ets:new(?UP, [named_table, public, set]);
|
||||
_ -> ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
-spec suggest(binary(), binary(), binary()) ->
|
||||
@@ -44,12 +52,12 @@ suggest(Q, Lang, RlKey) ->
|
||||
case cache_get(CacheKey) of
|
||||
{ok, Hits} -> {ok, Hits};
|
||||
miss ->
|
||||
case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
|
||||
{<<"limit">>, <<"8">>}]) of
|
||||
case opencage_get(forward_q(Qn, LangN, <<"8">>)) of
|
||||
{ok, Body} ->
|
||||
Hits = features_to_hits(Body),
|
||||
Hits = results_to_hits(Body),
|
||||
cache_put(CacheKey, Hits),
|
||||
{ok, Hits};
|
||||
{error, rate_limited} -> {error, rate_limited};
|
||||
{error, _} -> {error, unavailable}
|
||||
end
|
||||
end
|
||||
@@ -71,16 +79,16 @@ geocode(Q, Lang, RlKey) ->
|
||||
case cache_get(CacheKey) of
|
||||
{ok, Hit} -> {ok, Hit};
|
||||
miss ->
|
||||
case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
|
||||
{<<"limit">>, <<"1">>}]) of
|
||||
case opencage_get(forward_q(Qn, LangN, <<"1">>)) of
|
||||
{ok, Body} ->
|
||||
case features_to_hits(Body) of
|
||||
case results_to_hits(Body) of
|
||||
[Hit | _] ->
|
||||
Out = Hit#{<<"source">> => <<"photon">>},
|
||||
Out = Hit#{<<"source">> => <<"opencage">>},
|
||||
cache_put(CacheKey, Out),
|
||||
{ok, Out};
|
||||
[] -> {error, not_found}
|
||||
end;
|
||||
{error, rate_limited} -> {error, rate_limited};
|
||||
{error, _} -> {error, unavailable}
|
||||
end
|
||||
end
|
||||
@@ -105,19 +113,21 @@ reverse(Lat, Lon, Lang, RlKey) when is_number(Lat), is_number(Lon) ->
|
||||
miss ->
|
||||
LatB = float_to_bin(LatR),
|
||||
LonB = float_to_bin(LonR),
|
||||
case photon_get(<<"/reverse">>, [{<<"lat">>, LatB}, {<<"lon">>, LonB},
|
||||
{<<"lang">>, LangN}]) of
|
||||
Qs = [{<<"q">>, <<LatB/binary, ",", LonB/binary>>},
|
||||
{<<"language">>, LangN}],
|
||||
case opencage_get(Qs) of
|
||||
{ok, Body} ->
|
||||
Hit = case features_to_hits(Body) of
|
||||
[H | _] -> H#{<<"source">> => <<"photon">>};
|
||||
Hit = case results_to_hits(Body) of
|
||||
[H | _] -> H#{<<"source">> => <<"opencage">>};
|
||||
[] ->
|
||||
#{<<"address">> => fallback_addr(LatR, LonR),
|
||||
<<"lat">> => LatR,
|
||||
<<"lon">> => LonR,
|
||||
<<"source">> => <<"photon">>}
|
||||
<<"source">> => <<"opencage">>}
|
||||
end,
|
||||
cache_put(CacheKey, Hit),
|
||||
{ok, Hit};
|
||||
{error, rate_limited} -> {error, rate_limited};
|
||||
{error, _} -> {error, unavailable}
|
||||
end
|
||||
end
|
||||
@@ -184,15 +194,37 @@ cache_put(Key, Val) ->
|
||||
ets:insert(?CACHE, {Key, Exp, Val}),
|
||||
ok.
|
||||
|
||||
photon_url() ->
|
||||
case os:getenv("PHOTON_URL") of
|
||||
%% Forward-запрос: q + language + limit (+ countrycode, если задан).
|
||||
forward_q(Q, LangN, Limit) ->
|
||||
Qs = [{<<"q">>, Q}, {<<"language">>, LangN}, {<<"limit">>, Limit}],
|
||||
case countrycode() of
|
||||
<<>> -> Qs;
|
||||
CC -> [{<<"countrycode">>, CC} | Qs]
|
||||
end.
|
||||
|
||||
api_key() ->
|
||||
case os:getenv("OPENCAGE_API_KEY") of
|
||||
false -> <<>>;
|
||||
"" -> <<>>;
|
||||
K -> list_to_binary(string:trim(K))
|
||||
end.
|
||||
|
||||
base_url() ->
|
||||
case os:getenv("OPENCAGE_URL") of
|
||||
false -> ?DEFAULT_URL;
|
||||
"" -> ?DEFAULT_URL;
|
||||
Url -> list_to_binary(string:trim(Url))
|
||||
end.
|
||||
|
||||
countrycode() ->
|
||||
case os:getenv("OPENCAGE_COUNTRYCODE") of
|
||||
false -> <<>>;
|
||||
"" -> <<>>;
|
||||
CC -> list_to_binary(string:trim(CC))
|
||||
end.
|
||||
|
||||
timeout_ms() ->
|
||||
case os:getenv("PHOTON_TIMEOUT_MS") of
|
||||
case os:getenv("OPENCAGE_TIMEOUT_MS") of
|
||||
false -> 3000;
|
||||
"" -> 3000;
|
||||
S ->
|
||||
@@ -204,14 +236,47 @@ timeout_ms() ->
|
||||
end
|
||||
end.
|
||||
|
||||
photon_get(Path, Qs) ->
|
||||
case photon_url() of
|
||||
%% 0 — троттлинг выключен (платный тариф); по умолчанию 1 rps (free).
|
||||
throttle_rps() ->
|
||||
case os:getenv("OPENCAGE_THROTTLE_RPS") of
|
||||
false -> 1;
|
||||
"" -> 1;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N when N >= 0 -> N;
|
||||
_ -> 1
|
||||
catch
|
||||
_:_ -> 1
|
||||
end
|
||||
end.
|
||||
|
||||
throttle_allow() ->
|
||||
case throttle_rps() of
|
||||
0 -> true;
|
||||
Rps when Rps > 0 ->
|
||||
NowMs = erlang:monotonic_time(millisecond),
|
||||
MinGap = 1000 div Rps,
|
||||
case ets:lookup(?UP, last) of
|
||||
[{last, Prev}] when NowMs - Prev < MinGap -> false;
|
||||
_ ->
|
||||
ets:insert(?UP, {last, NowMs}),
|
||||
true
|
||||
end
|
||||
end.
|
||||
|
||||
opencage_get(Qs) ->
|
||||
case api_key() 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)
|
||||
Key ->
|
||||
case throttle_allow() of
|
||||
false -> {error, rate_limited};
|
||||
true ->
|
||||
Full = [{<<"key">>, Key}, {<<"no_annotations">>, <<"1">>} | Qs],
|
||||
Query = uri_string:compose_query(Full),
|
||||
Url = binary_to_list(<<(strip_slash(base_url()))/binary,
|
||||
$?, Query/binary>>),
|
||||
http_get(Url)
|
||||
end
|
||||
end.
|
||||
|
||||
strip_slash(B) ->
|
||||
@@ -240,27 +305,50 @@ default_http_get(Url) ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
features_to_hits(Body) when is_binary(Body) ->
|
||||
results_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);
|
||||
#{<<"results">> := Results} when is_list(Results) ->
|
||||
lists:filtermap(fun result_to_hit/1, Results);
|
||||
_ -> []
|
||||
catch
|
||||
_:_ -> []
|
||||
end.
|
||||
|
||||
feature_to_hit(#{<<"geometry">> := #{<<"coordinates">> := [Lon, Lat | _]}} = F) ->
|
||||
Props = maps:get(<<"properties">>, F, #{}),
|
||||
Addr = format_address(Props),
|
||||
result_to_hit(#{<<"geometry">> := #{<<"lat">> := Lat, <<"lng">> := Lng}} = R) ->
|
||||
{true, #{
|
||||
<<"address">> => Addr,
|
||||
<<"address">> => address_of(R),
|
||||
<<"lat">> => to_num(Lat),
|
||||
<<"lon">> => to_num(Lon)
|
||||
<<"lon">> => to_num(Lng)
|
||||
}};
|
||||
feature_to_hit(_) ->
|
||||
result_to_hit(_) ->
|
||||
false.
|
||||
|
||||
address_of(R) ->
|
||||
case bin(maps:get(<<"formatted">>, R, <<>>)) of
|
||||
<<>> ->
|
||||
C = maps:get(<<"components">>, R, #{}),
|
||||
Name = bin(maps:get(<<"name">>, C, <<>>)),
|
||||
House = bin(maps:get(<<"house_number">>, C, <<>>)),
|
||||
Road = bin(maps:get(<<"road">>, C, <<>>)),
|
||||
StreetLine = case {House, Road} of
|
||||
{<<>>, Rd} -> Rd;
|
||||
{H, <<>>} -> H;
|
||||
{H, Rd} -> <<H/binary, " ", Rd/binary>>
|
||||
end,
|
||||
City = first_nonempty([
|
||||
maps:get(<<"city">>, C, <<>>),
|
||||
maps:get(<<"town">>, C, <<>>),
|
||||
maps:get(<<"village">>, C, <<>>)
|
||||
]),
|
||||
Country = bin(maps:get(<<"country">>, C, <<>>)),
|
||||
Parts = [P || P <- [Name, StreetLine, City, Country], P =/= <<>>],
|
||||
case unique_keep(Parts) of
|
||||
[] -> <<"—">>;
|
||||
Ps -> join_comma(Ps)
|
||||
end;
|
||||
Addr -> Addr
|
||||
end.
|
||||
|
||||
to_num(N) when is_integer(N) -> float(N);
|
||||
to_num(N) when is_float(N) -> N;
|
||||
to_num(_) -> 0.0.
|
||||
|
||||
Reference in New Issue
Block a user