From 01dbc6d6cb57383bf9bc037e4d9ca3ef4a91abdc 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, 25 May 2026 21:30:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=20https://git.sabilin.com/EventHub/EventHubBack/issues/22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/core_banned_words.erl | 65 +++-- src/eventhub_app.erl | 1 + .../admin/admin_handler_banned_words.erl | 235 +++++++++++++----- test/api/admins/admin_banned_words_tests.erl | 91 ++++++- 4 files changed, 309 insertions(+), 83 deletions(-) diff --git a/src/core/core_banned_words.erl b/src/core/core_banned_words.erl index 66b7bbe..30a51c8 100644 --- a/src/core/core_banned_words.erl +++ b/src/core/core_banned_words.erl @@ -24,28 +24,51 @@ add_banned_word(Word, AddedBy) -> {aborted, Reason} -> {error, Reason} end. -remove_banned_word(Word) -> - case mnesia:transaction(fun() -> - case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of - [Rec] -> mnesia:delete_object(Rec), {ok, deleted}; - [] -> mnesia:abort(not_found) - end - end) of - {atomic, {ok, deleted}} -> {ok, deleted}; - {aborted, not_found} -> {error, not_found} +%% Поиск запрещённого слова по его тексту +-spec get_by_word(binary()) -> {ok, #banned_word{}} | {error, not_found}. +get_by_word(Word) -> + case mnesia:dirty_match_object(#banned_word{word = Word, _ = '_'}) of + [Record] -> {ok, Record}; + [] -> {error, not_found} end. +%% Проверка существования слова (по тексту) +word_exists(Word) -> + case get_by_word(Word) of + {ok, _} -> true; + _ -> false + end. + +%% Обновление запрещённого слова update_banned_word(OldWord, NewWord) -> - case mnesia:transaction(fun() -> - case mnesia:match_object(#banned_word{word = OldWord, _ = '_'}) of - [Rec] -> - Updated = Rec#banned_word{word = NewWord}, - mnesia:write(Updated), - {ok, Updated}; - [] -> - mnesia:abort(not_found) - end - end) of - {atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec}; - {aborted, not_found} -> {error, not_found} + case get_by_word(OldWord) of + {ok, Record} -> + case NewWord =:= OldWord orelse not word_exists(NewWord) of + true -> + NewRecord = Record#banned_word{word = NewWord}, + F = fun() -> + %% Если первичный ключ — id, просто обновляем запись + mnesia:write(NewRecord), + ok + end, + case mnesia:transaction(F) of + {atomic, ok} -> {ok, NewRecord}; + {aborted, Reason} -> {error, Reason} + end; + false -> + {error, already_exists} + end; + {error, _} = Error -> Error + end. + +%% Удаление слова (используем get_by_word для надёжности) +remove_banned_word(Word) -> + case get_by_word(Word) of + {ok, Record} -> + F = fun() -> mnesia:delete({banned_word, Record#banned_word.id}), ok end, + case mnesia:transaction(F) of + {atomic, ok} -> {ok, Record}; + {aborted, Reason} -> {error, Reason} + end; + {error, _} = Error -> Error end. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 7b216f7..c3c4a50 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -126,6 +126,7 @@ start_admin_http() -> {"/v1/admin/reviews", admin_handler_reviews, []}, {"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []}, % ================== БАН-СЛОВА ================== + {"/v1/admin/banned-words/batch", admin_handler_banned_words, []}, {"/v1/admin/banned-words", admin_handler_banned_words, []}, {"/v1/admin/banned-words/:word", admin_handler_banned_words, []}, % ================== ТИКЕТЫ ================== diff --git a/src/handlers/admin/admin_handler_banned_words.erl b/src/handlers/admin/admin_handler_banned_words.erl index 17cd098..e9ee57c 100644 --- a/src/handlers/admin/admin_handler_banned_words.erl +++ b/src/handlers/admin/admin_handler_banned_words.erl @@ -2,12 +2,13 @@ %%% @doc Административный обработчик бан-слов. %%% GET – список всех слов с пагинацией. %%% POST – добавить новое слово. +%%% PUT – редактировать существующее слово. %%% DELETE – удалить слово по :word. +%%% POST /batch – загрузить список слов (добавляются только новые). %%% @end %%%------------------------------------------------------------------- -module(admin_handler_banned_words). -behaviour(cowboy_handler). - -export([init/2]). -export([trails/0]). @@ -16,47 +17,53 @@ -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> list_words(Req); - <<"POST">> -> add_word(Req); + <<"GET">> -> list_words(Req); + <<"POST">> -> + case cowboy_req:path(Req) of + <<"/v1/admin/banned-words/batch">> -> batch_add_words(Req); + _ -> add_word(Req) + end; + <<"PUT">> -> update_word(Req); <<"DELETE">> -> delete_word(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata -spec trails() -> [map()]. trails() -> [ - #{ % GET list - path => <<"/v1/admin/banned-words">>, - method => <<"GET">>, + % GET list + #{ + path => <<"/v1/admin/banned-words">>, + method => <<"GET">>, description => <<"List all banned words (admin)">>, - tags => [<<"Banned Words">>], - parameters => [ - #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + tags => [<<"Banned Words">>], + parameters => [ + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} ], responses => #{ 200 => #{ description => <<"Array of banned words">>, content => #{<<"application/json">> => #{schema => #{ - type => array, + type => array, items => banned_word_schema() }}} } } }, - #{ % POST add - path => <<"/v1/admin/banned-words">>, - method => <<"POST">>, + % POST add + #{ + path => <<"/v1/admin/banned-words">>, + method => <<"POST">>, description => <<"Add a new banned word">>, - tags => [<<"Banned Words">>], + tags => [<<"Banned Words">>], requestBody => #{ required => true, - content => #{<<"application/json">> => #{schema => #{ - type => object, - required => [<<"word">>], - properties => #{ - word => #{type => string} - } + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"word">>], + properties => #{word => #{type => string}} }}} }, responses => #{ @@ -64,51 +71,89 @@ trails() -> 409 => #{description => <<"Word already exists">>} } }, - #{ % DELETE by word - path => <<"/v1/admin/banned-words/:word">>, - method => <<"DELETE">>, + % PUT update + #{ + path => <<"/v1/admin/banned-words/{word}">>, + method => <<"PUT">>, + description => <<"Update an existing banned word">>, + tags => [<<"Banned Words">>], + parameters => [ + #{name => <<"word">>, in => <<"path">>, description => <<"Current word">>, required => true, schema => #{type => string}} + ], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"word">>], + properties => #{word => #{type => string}} + }}} + }, + responses => #{ + 200 => #{description => <<"Word updated">>}, + 404 => #{description => <<"Word not found">>}, + 409 => #{description => <<"New word already exists">>} + } + }, + % DELETE by word + #{ + path => <<"/v1/admin/banned-words/{word}">>, + method => <<"DELETE">>, description => <<"Remove a banned word">>, - tags => [<<"Banned Words">>], - parameters => [ - #{ - name => <<"word">>, - in => <<"path">>, - description => <<"The word to remove">>, - required => true, - schema => #{type => string} - } + tags => [<<"Banned Words">>], + parameters => [ + #{name => <<"word">>, in => <<"path">>, description => <<"The word to remove">>, required => true, schema => #{type => string}} ], responses => #{ 200 => #{description => <<"Word removed">>}, 404 => #{description => <<"Word not found">>} } + }, + % POST batch + #{ + path => <<"/v1/admin/banned-words/batch">>, + method => <<"POST">>, + description => <<"Batch add banned words (only new words are added)">>, + tags => [<<"Banned Words">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"words">>], + properties => #{words => #{type => array, items => #{type => string}}} + }}} + }, + responses => #{ + 200 => #{description => <<"Batch operation completed">>}, + 400 => #{description => <<"Invalid request body">>} + } } ]. banned_word_schema() -> #{ - type => object, + type => object, properties => #{ - id => #{type => string}, - word => #{type => string}, - added_by => #{type => string, nullable => true}, - added_at => #{type => string, format => <<"date-time">>, nullable => true} + id => #{type => string}, + word => #{type => string}, + added_by => #{type => string, nullable => true}, + added_at => #{type => string, format => <<"date-time">>, nullable => true} } }. -%%% Internal functions +%%%=================================================================== +%%% HTTP-методы +%%%=================================================================== list_words(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> Pagination = handler_utils:parse_pagination_params(Req1), - %% core_banned_words:list_banned_words() возвращает список, а не {ok, List} AllWords = core_banned_words:list_banned_words(), BannedWords = lists:sort(AllWords), Total = length(BannedWords), Page = lists:sublist(BannedWords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Json = [word_to_map(W) || W <- Page], - ExtraHeaders = pagination_headers(Pagination, Total), + ExtraHeaders = handler_utils:pagination_headers(Pagination, Total), handler_utils:send_json(Req1, 200, Json, ExtraHeaders); {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) @@ -122,14 +167,41 @@ add_word(Req) -> #{<<"word">> := Word} -> case core_banned_words:add_banned_word(Word, AdminId) of {ok, _} -> + log_admin_action(AdminId, <<"add">>, <<"banned_word">>, Word, Req2), handler_utils:send_json(Req2, 201, #{status => <<"added">>}); {error, already_exists} -> handler_utils:send_error(Req2, 409, <<"Word already exists">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; - _ -> - handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>) + _ -> handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +update_word(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + OldWord = cowboy_req:binding(word, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"word">> := NewWord} -> + case core_banned_words:update_banned_word(OldWord, NewWord) of + {ok, _Updated} -> + log_admin_action(AdminId, <<"update">>, <<"banned_word">>, + #{old_word => OldWord, new_word => NewWord}, Req2), + handler_utils:send_json(Req2, 200, #{status => <<"updated">>}); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Word not found">>); + {error, already_exists} -> + handler_utils:send_error(Req2, 409, <<"New word already exists">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>) catch _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) end; @@ -139,10 +211,11 @@ add_word(Req) -> delete_word(Req) -> case handler_utils:auth_admin(Req) of - {ok, _AdminId, Req1} -> + {ok, AdminId, Req1} -> Word = cowboy_req:binding(word, Req1), case core_banned_words:remove_banned_word(Word) of {ok, _} -> + log_admin_action(AdminId, <<"delete">>, <<"banned_word">>, Word, Req1), handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Word not found">>); @@ -153,25 +226,71 @@ delete_word(Req) -> handler_utils:send_error(Req1, Code, Msg) end. -%% @private Преобразование записи banned_word в JSON-совместимую карту. -word_to_map(W) -> - #{ - id => W#banned_word.id, - word => W#banned_word.word, - added_by => W#banned_word.added_by, - added_at => datetime_to_iso8601(W#banned_word.added_at) - }. +batch_add_words(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"words">> := Words} when is_list(Words) -> + ValidWords = [W || W <- Words, is_binary(W)], + InvalidCount = length(Words) - length(ValidWords), + Results = lists:map( + fun(Word) -> + case core_banned_words:add_banned_word(Word, AdminId) of + {ok, _} -> #{word => Word, status => <<"added">>}; + {error, already_exists} -> #{word => Word, status => <<"skipped">>}; + {error, _} -> #{word => Word, status => <<"error">>} + end + end, + ValidWords + ), + Added = length([R || R <- Results, maps:get(status, R) =:= <<"added">>]), + Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]), + log_admin_action(AdminId, <<"batch_add">>, <<"banned_words">>, + #{count => length(Words), added => Added, skipped => Skipped}, Req2), + handler_utils:send_json(Req2, 200, #{ + results => Results, + added => Added, + skipped => Skipped, + invalid_count => InvalidCount + }); + _ -> handler_utils:send_error(Req2, 400, <<"Missing 'words' array">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +%%%=================================================================== +%%% Вспомогательные функции +%%%=================================================================== + +log_admin_action(AdminId, Action, EntityType, EntityId, Req) -> + case core_admin:get_by_id(AdminId) of + {ok, Admin} -> + Email = Admin#admin.email, + Role = atom_to_binary(Admin#admin.role, utf8), + Ip = ip_to_binary(cowboy_req:peer(Req)), + core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip), + ok; + _ -> ok + end. + +ip_to_binary({A, B, C, D}) -> + list_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D])); +ip_to_binary(_) -> + <<"unknown">>. -%% @private Форматирование datetime в ISO8601 строку. datetime_to_iso8601(undefined) -> undefined; datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Year, Month, Day, Hour, Minute, Second])). -pagination_headers(#{limit := Limit, offset := Offset}, Total) -> - RangeEnd = min(Offset + Limit - 1, Total - 1), +word_to_map(W) -> #{ - <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), - <<"x-total-count">> => integer_to_binary(Total), - <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + id => W#banned_word.id, + word => W#banned_word.word, + added_by => W#banned_word.added_by, + added_at => datetime_to_iso8601(W#banned_word.added_at) }. \ No newline at end of file diff --git a/test/api/admins/admin_banned_words_tests.erl b/test/api/admins/admin_banned_words_tests.erl index a6049b6..6cfe6be 100644 --- a/test/api/admins/admin_banned_words_tests.erl +++ b/test/api/admins/admin_banned_words_tests.erl @@ -4,24 +4,26 @@ %%% Покрывает эндпоинты: %%% GET /v1/admin/banned-words %%% POST /v1/admin/banned-words +%%% PUT /v1/admin/banned-words/:word %%% DELETE /v1/admin/banned-words/:word +%%% POST /v1/admin/banned-words/batch %%% %%% Проверяет: %%% - получение списка слов %%% - добавление нового слова +%%% - редактирование существующего слова %%% - удаление слова +%%% - массовую загрузку списком %%% - пагинацию %%% @end %%%------------------------------------------------------------------- -module(admin_banned_words_tests). -include_lib("eunit/include/eunit.hrl"). - -export([test/0]). %%%=================================================================== %%% Главная тестовая функция %%%=================================================================== - -spec test() -> ok. test() -> ct:pal("=== Admin Banned Words Tests ==="), @@ -36,9 +38,13 @@ test() -> test_list_words(Token, Word1, Word2), test_add_word(Token), + test_update_word(Token, Word1), + test_batch_add_words(Token), test_words_pagination(Token), - test_delete_word(Token, Word1), test_delete_word(Token, Word2), + test_update_word_conflict(Token), + test_batch_add_invalid(Token), + test_batch_add_duplicates(Token), ct:pal("=== All admin banned words tests passed ==="), ok. @@ -69,6 +75,39 @@ test_add_word(Token) -> ?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)), ct:pal(" OK"). +%% @doc PUT /v1/admin/banned-words/:word – редактирование слова. +-spec test_update_word(binary(), binary()) -> ok. +test_update_word(Token, Word) -> + ct:pal(" TEST: Update a banned word"), + NewWord = <>, + Result = api_test_runner:admin_put(<<"/v1/admin/banned-words/", Word/binary>>, Token, #{<<"word">> => NewWord}), + ?assertEqual(<<"updated">>, maps:get(<<"status">>, Result)), + % Проверяем, что старое слово отсутствует, а новое присутствует + Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token), + ?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)), + ?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)), + ct:pal(" OK"). + +%% @doc POST /v1/admin/banned-words/batch – массовая загрузка. +-spec test_batch_add_words(binary()) -> ok. +test_batch_add_words(Token) -> + ct:pal(" TEST: Batch add banned words"), + Unique = integer_to_binary(erlang:system_time()), + Words = [ + <<"batch1_", Unique/binary>>, + <<"batch2_", Unique/binary>>, + <<"batch3_", Unique/binary>> + ], + Body = jsx:encode(#{<<"words">> => Words}), + {ok, 200, _, RespBody} = api_test_runner:admin_request(post, <<"/v1/admin/banned-words/batch">>, Token, Body), + Result = jsx:decode(list_to_binary(RespBody), [return_maps]), + ?assertEqual(3, maps:get(<<"added">>, Result)), + ?assertEqual(0, maps:get(<<"skipped">>, Result)), + % Проверяем наличие всех слов + AllWords = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token), + [?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, AllWords)) || Word <- Words], + ct:pal(" OK"). + %% @doc DELETE /v1/admin/banned-words/:word – удаление слова. -spec test_delete_word(binary(), binary()) -> ok. test_delete_word(Token, Word) -> @@ -91,4 +130,48 @@ test_words_pagination(Token) -> Id1 = maps:get(<<"id">>, hd(Page1)), Id2 = maps:get(<<"id">>, hd(Page2)), ?assertNotEqual(Id1, Id2), - ct:pal(" OK"). \ No newline at end of file + ct:pal(" OK"). + +%% @doc PUT /v1/admin/banned-words/:word – попытка переименовать в уже существующее слово. +test_update_word_conflict(Token) -> + ct:pal(" TEST: Update banned word to existing word"), + Unique = integer_to_binary(erlang:system_time()), + WordA = <<"conflictA_", Unique/binary>>, + WordB = <<"conflictB_", Unique/binary>>, + api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => WordA}), + api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => WordB}), + Path = <<"/v1/admin/banned-words/", WordA/binary>>, + {ok, 409, _, _} = api_test_runner:admin_request(put, Path, Token, jsx:encode(#{<<"word">> => WordB})), + ct:pal(" OK: got 409 conflict"). + +%% @doc POST /v1/admin/banned-words/batch – с невалидными элементами. +test_batch_add_invalid(Token) -> + ct:pal(" TEST: Batch add with invalid items"), + Unique = integer_to_binary(erlang:system_time()), + Words = [ + <<"valid_", Unique/binary>>, + null, + 12345, + <<"valid2_", Unique/binary>> + ], + Body = jsx:encode(#{<<"words">> => Words}), + {ok, 200, _, RespBody} = api_test_runner:admin_request(post, <<"/v1/admin/banned-words/batch">>, Token, Body), + Result = jsx:decode(list_to_binary(RespBody), [return_maps]), + ?assertEqual(2, maps:get(<<"added">>, Result)), + ?assertEqual(0, maps:get(<<"skipped">>, Result)), + ?assertEqual(2, maps:get(<<"invalid_count">>, Result)), + ct:pal(" OK: invalid items filtered"). + +%% @doc POST /v1/admin/banned-words/batch – с дубликатами. +test_batch_add_duplicates(Token) -> + ct:pal(" TEST: Batch add with duplicates"), + Unique = integer_to_binary(erlang:system_time()), + Word1 = <<"dup1_", Unique/binary>>, + Word2 = <<"dup2_", Unique/binary>>, + Words = [Word1, Word2, Word1, Word2], + Body = jsx:encode(#{<<"words">> => Words}), + {ok, 200, _, RespBody} = api_test_runner:admin_request(post, <<"/v1/admin/banned-words/batch">>, Token, Body), + Result = jsx:decode(list_to_binary(RespBody), [return_maps]), + ?assertEqual(2, maps:get(<<"added">>, Result)), + ?assertEqual(2, maps:get(<<"skipped">>, Result)), + ct:pal(" OK: duplicates handled"). \ No newline at end of file