From 574d0d2e4356f8e30ff345027824a54f32c27441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Mon, 4 May 2026 15:19:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D0=B2=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=20=D0=B1=D1=8B?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=8B=D0=BC=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B2=D0=B5=D0=B1-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20https://git.sabilin.com/EventHub/EventHubBack/issues/1?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/archive/archive_controller.erl | 101 +++++++++++++++++++++++ src/archive/archive_fetcher.erl | 12 +++ src/archive/archive_manager.erl | 71 ++++++++++++++++ src/archive/calendar_html_renderer.erl | 57 +++++++++++++ src/eventhub_app.erl | 2 + src/handlers/handler_calendar_view.erl | 110 +++++++++++++++++++++++++ src/infra/infra_sup.erl | 39 +++++---- 7 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 src/archive/archive_controller.erl create mode 100644 src/archive/archive_fetcher.erl create mode 100644 src/archive/archive_manager.erl create mode 100644 src/archive/calendar_html_renderer.erl create mode 100644 src/handlers/handler_calendar_view.erl diff --git a/src/archive/archive_controller.erl b/src/archive/archive_controller.erl new file mode 100644 index 0000000..83d0be4 --- /dev/null +++ b/src/archive/archive_controller.erl @@ -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. \ No newline at end of file diff --git a/src/archive/archive_fetcher.erl b/src/archive/archive_fetcher.erl new file mode 100644 index 0000000..184099b --- /dev/null +++ b/src/archive/archive_fetcher.erl @@ -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}], + ['$_']}]). \ No newline at end of file diff --git a/src/archive/archive_manager.erl b/src/archive/archive_manager.erl new file mode 100644 index 0000000..c3e68a0 --- /dev/null +++ b/src/archive/archive_manager.erl @@ -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. \ No newline at end of file diff --git a/src/archive/calendar_html_renderer.erl b/src/archive/calendar_html_renderer.erl new file mode 100644 index 0000000..e3f0cb4 --- /dev/null +++ b/src/archive/calendar_html_renderer.erl @@ -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, []), + ["", integer_to_list(D), format_events(DayEvents), ""] + end, DayList), + ["", + "", DayCells, "", + "
"]. + +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 + [] -> []; + _ -> + [""] + end. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 7ee391a..3f9c8bd 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -42,6 +42,7 @@ start(_StartType, _StartArgs) -> end, ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), + calendar_html_renderer:init_cache(), start_http(), % Пользовательский API (8080) start_admin_http(), % Административный API (8445) application:ensure_all_started(prometheus), @@ -72,6 +73,7 @@ start_http() -> {"/v1/search", handler_search, []}, {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, + {"/v1/calendars/:calendar_id/view", handler_calendar_view, []}, {"/v1/calendars/:calendar_id/events", handler_events, []}, {"/v1/events/:id", handler_event_by_id, []}, {"/v1/events/:id/occurrences", handler_event_occurrences, []}, diff --git a/src/handlers/handler_calendar_view.erl b/src/handlers/handler_calendar_view.erl new file mode 100644 index 0000000..fede895 --- /dev/null +++ b/src/handlers/handler_calendar_view.erl @@ -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. \ No newline at end of file diff --git a/src/infra/infra_sup.erl b/src/infra/infra_sup.erl index 36571b4..0dca659 100644 --- a/src/infra/infra_sup.erl +++ b/src/infra/infra_sup.erl @@ -1,4 +1,6 @@ -%% Супервизор верхнего уровня +%% =================================================================== +%% EventHub – инфраструктурный супервизор (с archive_manager) +%% =================================================================== -module(infra_sup). -behaviour(supervisor). @@ -9,20 +11,21 @@ start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). init([]) -> - SupFlags = #{strategy => one_for_one, intensity => 5, period => 10}, - - Mnesia = #{ - id => infra_mnesia, - start => {infra_mnesia, start_link, []}, - restart => permanent, - shutdown => 5000, - type => worker, - modules => [infra_mnesia] - }, - - % Временная заглушка для HTTP-сервера (будет добавлен позже) - % Cowboy = #{...} - - ChildSpecs = [Mnesia], - - {ok, {SupFlags, ChildSpecs}}. \ No newline at end of file + SupFlags = #{strategy => one_for_one, + intensity => 5, + period => 10}, + Children = [ + #{id => infra_mnesia, + start => {infra_mnesia, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [infra_mnesia]}, + #{id => archive_manager, + start => {archive_manager, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [archive_manager]} + ], + {ok, {SupFlags, Children}}. \ No newline at end of file