Архивирование с быстрым подключением и серверный рендеринг для веб-клиента #15
This commit is contained in:
101
src/archive/archive_controller.erl
Normal file
101
src/archive/archive_controller.erl
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
-module(archive_controller).
|
||||||
|
-compile([{nowarn_deprecated_function, [{slave, start, 3}, {slave, stop, 1}]}]).
|
||||||
|
-include("records.hrl").
|
||||||
|
-export([archive_day/1]).
|
||||||
|
|
||||||
|
archive_day(Day) ->
|
||||||
|
ArchiveNode = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
||||||
|
case start_archive_node(ArchiveNode) of
|
||||||
|
{ok, PeerOrSlave} ->
|
||||||
|
try
|
||||||
|
rpc:call(ArchiveNode, mnesia, create_schema, [[ArchiveNode]]),
|
||||||
|
rpc:call(ArchiveNode, mnesia, start, []),
|
||||||
|
rpc:call(ArchiveNode, code, ensure_loaded, [archive_fetcher]),
|
||||||
|
create_archive_table(ArchiveNode, event,
|
||||||
|
[id, calendar_id, start_time, end_time, event_type,
|
||||||
|
master_id, specialist_id, title, description,
|
||||||
|
attachments, edit_history, status, created_at, updated_at],
|
||||||
|
[calendar_id, start_time, event_type, master_id,
|
||||||
|
specialist_id, status]),
|
||||||
|
create_archive_table(ArchiveNode, booking,
|
||||||
|
[id, event_id, user_id, status, confirmed_at,
|
||||||
|
created_at, updated_at, notes, reminder_sent],
|
||||||
|
[event_id, user_id, status]),
|
||||||
|
create_archive_table(ArchiveNode, review,
|
||||||
|
[id, user_id, target_type, target_id, rating, comment,
|
||||||
|
status, reason, created_at, updated_at, likes,
|
||||||
|
dislikes, edited_at], []),
|
||||||
|
create_archive_table(ArchiveNode, report,
|
||||||
|
[id, reporter_id, target_type, target_id, reason,
|
||||||
|
status, created_at, resolved_at, resolved_by], []),
|
||||||
|
ok = transfer_data(ArchiveNode, Day),
|
||||||
|
io:format("Archived day ~s successfully.~n", [Day])
|
||||||
|
after
|
||||||
|
stop_archive_node(PeerOrSlave, ArchiveNode)
|
||||||
|
end;
|
||||||
|
{error, Reason} ->
|
||||||
|
io:format("Failed to start archive node: ~p~n", [Reason]),
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
start_archive_node(Node) ->
|
||||||
|
case os:getenv("CLUSTER_MODE") of
|
||||||
|
"true" ->
|
||||||
|
peer:start_link(#{name => Node, host => host()});
|
||||||
|
_ ->
|
||||||
|
CookieStr = atom_to_list(erlang:get_cookie()),
|
||||||
|
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
|
||||||
|
{ok, Slave} -> {ok, Slave};
|
||||||
|
Error -> Error
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
stop_archive_node(PeerOrSlave, Node) ->
|
||||||
|
case os:getenv("CLUSTER_MODE") of
|
||||||
|
"true" -> peer:stop(PeerOrSlave);
|
||||||
|
_ -> slave:stop(Node)
|
||||||
|
end.
|
||||||
|
|
||||||
|
create_archive_table(Node, Tab, Attributes, Indices) ->
|
||||||
|
Opts = [{disc_only_copies, [Node]},
|
||||||
|
{attributes, Attributes},
|
||||||
|
{type, set}] ++ case Indices of
|
||||||
|
[] -> [];
|
||||||
|
_ -> [{index, Indices}]
|
||||||
|
end,
|
||||||
|
rpc:call(Node, mnesia, create_table, [Tab, Opts]).
|
||||||
|
|
||||||
|
transfer_data(ArchiveNode, Day) ->
|
||||||
|
Tables = [event, booking, review, report],
|
||||||
|
lists:foreach(fun(Tab) ->
|
||||||
|
Records = fetch_records(Tab, Day),
|
||||||
|
rpc:call(ArchiveNode, mnesia, transaction, [
|
||||||
|
fun() -> [mnesia:write(Rec) || Rec <- Records] end
|
||||||
|
]),
|
||||||
|
mnesia:transaction(fun() ->
|
||||||
|
[mnesia:delete({Tab, element(2, Rec)}) || Rec <- Records]
|
||||||
|
end)
|
||||||
|
end, Tables).
|
||||||
|
|
||||||
|
fetch_records(event, Day) ->
|
||||||
|
Start = list_to_binary(Day ++ " 00:00:00"),
|
||||||
|
End = list_to_binary(Day ++ " 23:59:59"),
|
||||||
|
mnesia:dirty_select(event, [{#event{start_time = '$1', _ = '_'},
|
||||||
|
[{'>=','$1', Start},{'=<','$1', End}],
|
||||||
|
['$_']}]);
|
||||||
|
fetch_records(booking, Day) ->
|
||||||
|
mnesia:dirty_select(booking, [{#booking{created_at = '$1', _ = '_'},
|
||||||
|
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||||
|
['$_']}]);
|
||||||
|
fetch_records(review, Day) ->
|
||||||
|
mnesia:dirty_select(review, [{#review{created_at = '$1', _ = '_'},
|
||||||
|
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||||
|
['$_']}]);
|
||||||
|
fetch_records(report, Day) ->
|
||||||
|
mnesia:dirty_select(report, [{#report{created_at = '$1', _ = '_'},
|
||||||
|
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||||
|
['$_']}]).
|
||||||
|
|
||||||
|
host() ->
|
||||||
|
{ok, Name} = inet:gethostname(),
|
||||||
|
Name.
|
||||||
12
src/archive/archive_fetcher.erl
Normal file
12
src/archive/archive_fetcher.erl
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-module(archive_fetcher).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([fetch/3]).
|
||||||
|
|
||||||
|
fetch(CalendarId, Year, Month) ->
|
||||||
|
Start = {Year, Month, 1, 0, 0, 0},
|
||||||
|
End = {Year, Month, 31, 23, 59, 59},
|
||||||
|
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
|
||||||
|
start_time = '$1', _ = '_'},
|
||||||
|
[{'>=','$1', Start},{'=<','$1', End}],
|
||||||
|
['$_']}]).
|
||||||
71
src/archive/archive_manager.erl
Normal file
71
src/archive/archive_manager.erl
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-module(archive_manager).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
|
||||||
|
-export([start_link/0, get_archive_node/1]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
-define(TIMEOUT, 30000). % 30 секунд неактивности
|
||||||
|
|
||||||
|
start_link() ->
|
||||||
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||||
|
|
||||||
|
get_archive_node(Day) ->
|
||||||
|
gen_server:call(?MODULE, {get_node, Day}).
|
||||||
|
|
||||||
|
init([]) ->
|
||||||
|
{ok, #{}}.
|
||||||
|
|
||||||
|
handle_call({get_node, Day}, _From, State) ->
|
||||||
|
Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
||||||
|
case maps:find(Node, State) of
|
||||||
|
{ok, _Info} -> % переменная не используется, ок
|
||||||
|
{reply, {ok, Node}, update_access(State, Node)};
|
||||||
|
error ->
|
||||||
|
case start_archive_node(Day) of
|
||||||
|
{ok, Node} ->
|
||||||
|
Ref = erlang:send_after(?TIMEOUT, self(), {release, Node}),
|
||||||
|
NewState = State#{Node => #{timer => Ref, last_access => erlang:monotonic_time()}},
|
||||||
|
{reply, {ok, Node}, NewState};
|
||||||
|
{error, Reason} ->
|
||||||
|
{reply, {error, Reason}, State}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
handle_cast(_, State) -> {noreply, State}.
|
||||||
|
|
||||||
|
handle_info({release, Node}, State) ->
|
||||||
|
case maps:find(Node, State) of
|
||||||
|
{ok, #{last_access := Last}} ->
|
||||||
|
Now = erlang:monotonic_time(),
|
||||||
|
if Now - Last >= (?TIMEOUT * 1000) ->
|
||||||
|
stop_archive_node(Node),
|
||||||
|
{noreply, maps:remove(Node, State)};
|
||||||
|
true ->
|
||||||
|
Ref = erlang:send_after(?TIMEOUT, self(), {release, Node}),
|
||||||
|
{noreply, State#{Node => #{timer => Ref, last_access => Last}}}
|
||||||
|
end;
|
||||||
|
error ->
|
||||||
|
{noreply, State}
|
||||||
|
end;
|
||||||
|
handle_info(_, State) -> {noreply, State}.
|
||||||
|
|
||||||
|
update_access(State, Node) ->
|
||||||
|
Info = maps:get(Node, State),
|
||||||
|
Ref = erlang:send_after(?TIMEOUT, self(), {release, Node}),
|
||||||
|
State#{Node => Info#{timer => Ref, last_access => erlang:monotonic_time()}}.
|
||||||
|
|
||||||
|
start_archive_node(Day) ->
|
||||||
|
Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
||||||
|
case slave:start(host(), Node) of
|
||||||
|
{ok, _} ->
|
||||||
|
rpc:call(Node, mnesia, start, []),
|
||||||
|
{ok, Node};
|
||||||
|
Error -> Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
stop_archive_node(Node) ->
|
||||||
|
rpc:cast(Node, init, stop, []).
|
||||||
|
|
||||||
|
host() ->
|
||||||
|
{ok, Name} = inet:gethostname(),
|
||||||
|
Name.
|
||||||
57
src/archive/calendar_html_renderer.erl
Normal file
57
src/archive/calendar_html_renderer.erl
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-module(calendar_html_renderer).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([render_month/3, init_cache/0]).
|
||||||
|
|
||||||
|
init_cache() ->
|
||||||
|
case ets:info(archive_html_cache) of
|
||||||
|
undefined ->
|
||||||
|
ets:new(archive_html_cache, [set, public, named_table, {keypos, 1}]);
|
||||||
|
_ -> ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
render_month(Year, Month, Events) ->
|
||||||
|
Key = {Year, Month},
|
||||||
|
try ets:lookup(archive_html_cache, Key) of
|
||||||
|
[{Key, Html}] -> Html;
|
||||||
|
[] ->
|
||||||
|
Html = generate_html(Year, Month, Events),
|
||||||
|
ets:insert(archive_html_cache, {Key, Html}),
|
||||||
|
Html
|
||||||
|
catch
|
||||||
|
_:_ ->
|
||||||
|
generate_html(Year, Month, Events)
|
||||||
|
end.
|
||||||
|
|
||||||
|
generate_html(Year, Month, Events) ->
|
||||||
|
DaysInMonth = calendar:last_day_of_the_month(Year, Month),
|
||||||
|
EventsByDay = group_events_by_day(Events),
|
||||||
|
DayList = lists:seq(1, DaysInMonth),
|
||||||
|
DayCells = lists:map(fun(D) ->
|
||||||
|
DayEvents = maps:get(D, EventsByDay, []),
|
||||||
|
["<td>", integer_to_list(D), format_events(DayEvents), "</td>"]
|
||||||
|
end, DayList),
|
||||||
|
["<html><body><table>",
|
||||||
|
"<tr>", DayCells, "</tr>",
|
||||||
|
"</table></body></html>"].
|
||||||
|
|
||||||
|
group_events_by_day(Events) ->
|
||||||
|
lists:foldl(fun(Evt, Acc) ->
|
||||||
|
case Evt of
|
||||||
|
#event{start_time = {{_, _, Day}, {_, _, _}}} ->
|
||||||
|
maps:update_with(Day, fun(List) -> [Evt | List] end, [Evt], Acc);
|
||||||
|
_ -> Acc
|
||||||
|
end
|
||||||
|
end, #{}, Events).
|
||||||
|
|
||||||
|
format_events(Events) ->
|
||||||
|
case Events of
|
||||||
|
[] -> [];
|
||||||
|
_ ->
|
||||||
|
["<ul>",
|
||||||
|
lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
|
||||||
|
["<li>", Title, " (", integer_to_list(H), ":",
|
||||||
|
io_lib:format("~2..0B", [M]), ")</li>"]
|
||||||
|
end, Events),
|
||||||
|
"</ul>"]
|
||||||
|
end.
|
||||||
@@ -42,6 +42,7 @@ start(_StartType, _StartArgs) ->
|
|||||||
end,
|
end,
|
||||||
ok = infra_mnesia:init_tables(),
|
ok = infra_mnesia:init_tables(),
|
||||||
ok = infra_mnesia:wait_for_tables(),
|
ok = infra_mnesia:wait_for_tables(),
|
||||||
|
calendar_html_renderer:init_cache(),
|
||||||
start_http(), % Пользовательский API (8080)
|
start_http(), % Пользовательский API (8080)
|
||||||
start_admin_http(), % Административный API (8445)
|
start_admin_http(), % Административный API (8445)
|
||||||
application:ensure_all_started(prometheus),
|
application:ensure_all_started(prometheus),
|
||||||
@@ -72,6 +73,7 @@ start_http() ->
|
|||||||
{"/v1/search", handler_search, []},
|
{"/v1/search", handler_search, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
|
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||||
{"/v1/events/:id", handler_event_by_id, []},
|
{"/v1/events/:id", handler_event_by_id, []},
|
||||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||||
|
|||||||
110
src/handlers/handler_calendar_view.erl
Normal file
110
src/handlers/handler_calendar_view.erl
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
-module(handler_calendar_view).
|
||||||
|
-include("records.hrl").
|
||||||
|
-export([init/2]).
|
||||||
|
|
||||||
|
init(Req, Opts) ->
|
||||||
|
CalendarId = cowboy_req:binding(calendar_id, Req),
|
||||||
|
case verify_token(Req) of
|
||||||
|
{ok, UserId} ->
|
||||||
|
case is_owner(UserId, CalendarId) of
|
||||||
|
true ->
|
||||||
|
process_view(Req, CalendarId, Opts);
|
||||||
|
false ->
|
||||||
|
cowboy_req:reply(403,
|
||||||
|
#{<<"content-type">> => <<"application/json">>},
|
||||||
|
jsx:encode(#{error => <<"Access denied">>}),
|
||||||
|
Req),
|
||||||
|
{ok, Req, Opts}
|
||||||
|
end;
|
||||||
|
{error, _Reason} ->
|
||||||
|
cowboy_req:reply(401,
|
||||||
|
#{<<"content-type">> => <<"application/json">>},
|
||||||
|
jsx:encode(#{error => <<"Unauthorized">>}),
|
||||||
|
Req),
|
||||||
|
{ok, Req, Opts}
|
||||||
|
end.
|
||||||
|
|
||||||
|
verify_token(Req) ->
|
||||||
|
case cowboy_req:header(<<"authorization">>, Req) of
|
||||||
|
undefined ->
|
||||||
|
{error, no_token};
|
||||||
|
<<"Bearer ", Token/binary>> ->
|
||||||
|
case eventhub_auth:verify_user_token(Token) of
|
||||||
|
{ok, UserId, _Role} -> {ok, UserId};
|
||||||
|
{error, _} -> {error, invalid_token}
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
{error, invalid_header}
|
||||||
|
end.
|
||||||
|
|
||||||
|
is_owner(UserId, CalendarId) ->
|
||||||
|
case mnesia:dirty_read({calendar, CalendarId}) of
|
||||||
|
[#calendar{owner_id = UserId}] -> true;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
process_view(Req, CalendarId, Opts) ->
|
||||||
|
Qs = cowboy_req:parse_qs(Req),
|
||||||
|
MonthBin = case lists:keyfind(<<"month">>, 1, Qs) of
|
||||||
|
{<<"month">>, Value} -> Value;
|
||||||
|
false -> undefined
|
||||||
|
end,
|
||||||
|
case MonthBin of
|
||||||
|
undefined ->
|
||||||
|
cowboy_req:reply(400,
|
||||||
|
#{<<"content-type">> => <<"application/json">>},
|
||||||
|
jsx:encode(#{error => <<"Missing 'month' parameter">>}),
|
||||||
|
Req),
|
||||||
|
{ok, Req, Opts};
|
||||||
|
_ ->
|
||||||
|
case binary:split(MonthBin, <<"-">>) of
|
||||||
|
[YearStr, MonthStr] ->
|
||||||
|
Year = binary_to_integer(YearStr),
|
||||||
|
Month = binary_to_integer(MonthStr),
|
||||||
|
Events = fetch_events(CalendarId, Year, Month),
|
||||||
|
Html = calendar_html_renderer:render_month(Year, Month, Events),
|
||||||
|
Req2 = cowboy_req:reply(200,
|
||||||
|
#{<<"content-type">> => <<"text/html">>,
|
||||||
|
<<"cache-control">> => <<"public, max-age=86400">>},
|
||||||
|
Html,
|
||||||
|
Req),
|
||||||
|
{ok, Req2, Opts};
|
||||||
|
_ ->
|
||||||
|
cowboy_req:reply(400,
|
||||||
|
#{<<"content-type">> => <<"application/json">>},
|
||||||
|
jsx:encode(#{error => <<"Invalid 'month' format. Use YYYY-MM">>}),
|
||||||
|
Req),
|
||||||
|
{ok, Req, Opts}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
fetch_events(CalendarId, Year, Month) ->
|
||||||
|
IsHot = is_hot(Year, Month),
|
||||||
|
if IsHot ->
|
||||||
|
fetch_hot_events(CalendarId, Year, Month);
|
||||||
|
true ->
|
||||||
|
fetch_archive_events(CalendarId, Year, Month)
|
||||||
|
end.
|
||||||
|
|
||||||
|
is_hot(Year, Month) ->
|
||||||
|
Current = calendar:local_time(),
|
||||||
|
Target = {{Year, Month, 1}, {0,0,0}},
|
||||||
|
calendar:datetime_to_gregorian_seconds(Current)
|
||||||
|
- calendar:datetime_to_gregorian_seconds(Target) < 30*86400.
|
||||||
|
|
||||||
|
fetch_hot_events(CalendarId, Year, Month) ->
|
||||||
|
Start = {{Year, Month, 1}, {0,0,0}},
|
||||||
|
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23,59,59}},
|
||||||
|
mnesia:dirty_select(event,
|
||||||
|
[{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
|
||||||
|
[{'>=','$1', {const, Start}}, {'=<','$1', {const, End}}],
|
||||||
|
['$_']}]).
|
||||||
|
|
||||||
|
fetch_archive_events(CalendarId, Year, Month) ->
|
||||||
|
DayStr = io_lib:format("~4.10.0B~2.10.0B", [Year, Month]),
|
||||||
|
case archive_manager:get_archive_node(lists:flatten(DayStr)) of
|
||||||
|
{ok, Node} ->
|
||||||
|
rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]);
|
||||||
|
{error, _} ->
|
||||||
|
[]
|
||||||
|
end.
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
%% Супервизор верхнего уровня
|
%% ===================================================================
|
||||||
|
%% EventHub – инфраструктурный супервизор (с archive_manager)
|
||||||
|
%% ===================================================================
|
||||||
-module(infra_sup).
|
-module(infra_sup).
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
|
|
||||||
@@ -9,20 +11,21 @@ start_link() ->
|
|||||||
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
|
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
|
||||||
|
|
||||||
init([]) ->
|
init([]) ->
|
||||||
SupFlags = #{strategy => one_for_one, intensity => 5, period => 10},
|
SupFlags = #{strategy => one_for_one,
|
||||||
|
intensity => 5,
|
||||||
Mnesia = #{
|
period => 10},
|
||||||
id => infra_mnesia,
|
Children = [
|
||||||
start => {infra_mnesia, start_link, []},
|
#{id => infra_mnesia,
|
||||||
restart => permanent,
|
start => {infra_mnesia, start_link, []},
|
||||||
shutdown => 5000,
|
restart => permanent,
|
||||||
type => worker,
|
shutdown => 5000,
|
||||||
modules => [infra_mnesia]
|
type => worker,
|
||||||
},
|
modules => [infra_mnesia]},
|
||||||
|
#{id => archive_manager,
|
||||||
% Временная заглушка для HTTP-сервера (будет добавлен позже)
|
start => {archive_manager, start_link, []},
|
||||||
% Cowboy = #{...}
|
restart => permanent,
|
||||||
|
shutdown => 5000,
|
||||||
ChildSpecs = [Mnesia],
|
type => worker,
|
||||||
|
modules => [archive_manager]}
|
||||||
{ok, {SupFlags, ChildSpecs}}.
|
],
|
||||||
|
{ok, {SupFlags, Children}}.
|
||||||
Reference in New Issue
Block a user