diff --git a/.img/ewm-main-event-service.png b/.img/ewm-main-event-service.png deleted file mode 100644 index a600c6f..0000000 Binary files a/.img/ewm-main-event-service.png and /dev/null differ diff --git a/.img/ewm-main-rating-service.png b/.img/ewm-main-rating-service.png deleted file mode 100644 index f889525..0000000 Binary files a/.img/ewm-main-rating-service.png and /dev/null differ diff --git a/.img/ewm-main-request-service.png b/.img/ewm-main-request-service.png deleted file mode 100644 index d35f401..0000000 Binary files a/.img/ewm-main-request-service.png and /dev/null differ diff --git a/.img/ewm-main-user-service.png b/.img/ewm-main-user-service.png deleted file mode 100644 index 2c4e1cd..0000000 Binary files a/.img/ewm-main-user-service.png and /dev/null differ diff --git a/.img/ewm-stats-stats-server.png b/.img/ewm-stats-stats-server.png deleted file mode 100644 index 5fd9297..0000000 Binary files a/.img/ewm-stats-stats-server.png and /dev/null differ diff --git a/.img/external-api-2.png b/.img/external-api-2.png deleted file mode 100644 index 1c13636..0000000 Binary files a/.img/external-api-2.png and /dev/null differ diff --git a/.img/external-api.png b/.img/external-api.png deleted file mode 100644 index bfb4bfe..0000000 Binary files a/.img/external-api.png and /dev/null differ diff --git a/.img/internal-api.png b/.img/internal-api.png deleted file mode 100644 index 32ab3b5..0000000 Binary files a/.img/internal-api.png and /dev/null differ diff --git a/.img/microservices.png b/.img/microservices.png deleted file mode 100644 index 7db5bf5..0000000 Binary files a/.img/microservices.png and /dev/null differ diff --git a/.img/microservices.svg b/.img/microservices.svg new file mode 100644 index 0000000..a7bd477 --- /dev/null +++ b/.img/microservices.svg @@ -0,0 +1,4 @@ +Kafkastats.user-actions.v1stats.events-similarity.v1AggregatorCollectorAnalyzerStatsPROTOBUFAVROCoreevent-serviceuser-serviceRESTgRPCgRPCgRPC AVROExploreWithMeewm-eventsewm-analyzerJDBCJDBCewm-requestsewm-usersREST FEIGNJDBCgateway-serverInfraconfig-serverdiscovery-serverrequest-serviceRESTREST ASYNCJDBCdockerewm-stats-dbewm-main-dbUserAPIgRPCPROTOBUFgRPCAVROgRPCAVROFrame \ No newline at end of file diff --git a/.img/similarity.png b/.img/similarity.png new file mode 100644 index 0000000..7078c88 Binary files /dev/null and b/.img/similarity.png differ diff --git a/.postman/ewm-main-service.json b/.postman/ewm-main-service.json index 62b600e..fd1dce6 100644 --- a/.postman/ewm-main-service.json +++ b/.postman/ewm-main-service.json @@ -1,10 +1,9 @@ { "info": { - "_postman_id": "4f622f31-328a-4506-95bd-66359cfbe749", - "name": "Test Explore With Me - Main service", + "_postman_id": "55956863-00a9-448f-8bd0-78c02e615277", + "name": "Test Explore With Me Plus - Main - Recommendations", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "23073145", - "_collection_link": "https://universal-shadow-295426.postman.co/workspace/My-Workspace~4200f6aa-0504-44b1-8a1d-707d0dcbd5ce/collection/13708500-4f622f31-328a-4506-95bd-66359cfbe749?action=share&source=collection_link&creator=23073145" + "_exporter_id": "2036415" }, "item": [ { @@ -1364,6 +1363,7 @@ " const user = await api.addUser(rnd.getUser());\r", " const category = await api.addCategory(rnd.getCategory());\r", " let event = await api.addEvent(user.id, rnd.getEvent(category.id));\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", " pm.collectionVariables.set(\"eid\", event.id)\r", " pm.collectionVariables.set('response', event);\r", " } catch(err) {\r", @@ -1408,6 +1408,11 @@ "request": { "method": "GET", "header": [ + { + "key": "X-EWM-USER-ID", + "value": "{{userId}}", + "type": "text" + }, { "key": "Accept", "value": "application/json" @@ -1503,7 +1508,7 @@ "const source = pm.collectionVariables.get('response');\r", "const target = pm.response.json()[0];\r", "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, views, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", + "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", "pm.expect(target).to.have.property('id');\r", "pm.expect(target).to.have.property('title');\r", "pm.expect(target).to.have.property('annotation');\r", @@ -1511,7 +1516,6 @@ "pm.expect(target).to.have.property('paid');\r", "pm.expect(target).to.have.property('eventDate');\r", "pm.expect(target).to.have.property('initiator');\r", - "pm.expect(target).to.have.property('views');\r", "pm.expect(target).to.have.property('confirmedRequests');\r", "pm.expect(target).to.have.property('description');\r", "pm.expect(target).to.have.property('participantLimit');\r", @@ -1716,119 +1720,6 @@ }, "response": [] }, - { - "name": "Проверка работы поля views", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const user = await api.addUser(rnd.getUser());\r", - " const category = await api.addCategory(rnd.getCategory());\r", - " let event = await api.addEvent(user.id, rnd.getEvent(category.id));\r", - " event = await api.publishEvent(event.id);\r", - " await api.findEvent(event.id)\r", - " await api.findEvent(event.id)\r", - " pm.collectionVariables.set(\"eid\", event.id)\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " // выполняем наш скрипт\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "\r", - "const target = pm.response.json();\r", - "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, views, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", - "pm.expect(target).to.have.property('id');\r", - "pm.expect(target).to.have.property('title');\r", - "pm.expect(target).to.have.property('annotation');\r", - "pm.expect(target).to.have.property('category');\r", - "pm.expect(target).to.have.property('paid');\r", - "pm.expect(target).to.have.property('eventDate');\r", - "pm.expect(target).to.have.property('initiator');\r", - "pm.expect(target).to.have.property('views');\r", - "pm.expect(target).to.have.property('confirmedRequests');\r", - "pm.expect(target).to.have.property('description');\r", - "pm.expect(target).to.have.property('participantLimit');\r", - "pm.expect(target).to.have.property('state');\r", - "pm.expect(target).to.have.property('createdOn');\r", - "pm.expect(target).to.have.property('publishedOn');\r", - "pm.expect(target).to.have.property('location');\r", - "pm.expect(target).to.have.property('requestModeration');\r", - "});\r", - "\r", - "pm.test(\"Значение поля views должно увеличится на 1 после выполнения GET запроса с уникального IP к событию\", function () {\r", - " pm.expect(target.views).equal(1);\r", - " \r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/events/:id", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "events", - ":id" - ], - "variable": [ - { - "key": "id", - "value": "{{eid}}", - "description": "(Required) id события" - } - ] - }, - "description": "Обратите внимание:\n- событие должно быть опубликовано\n- информация о событии должна включать в себя количество просмотров и количество подтвержденных запросов\n- информацию о том, что по этому эндпоинту был осуществлен и обработан запрос, нужно сохранить в сервисе статистики" - }, - "response": [] - }, { "name": "Добавление запроса на участие при participantLimit == 0", "event": [ @@ -6711,7 +6602,7 @@ " let user;\r", " try {\r", " user = rnd.getUser();\r", - " user.email = rnd.getWord(1) + '@a.ru';\r", + " user.email = rnd.getWord(15) + '@a.ru';\r", " } catch(err) {\r", " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", " }\r", @@ -6738,7 +6629,8 @@ " 100 \r", ");" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -6752,7 +6644,8 @@ "});\r", "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -10856,7 +10749,7 @@ " pm.request.body.update({\r", " mode: 'raw',\r", " raw: JSON.stringify({\r", - " eventDate : rnd.getFutureDateTime(6)\r", + " eventDate : \"2124-10-11 23:10:05\"\r", " }),\r", " options: { raw: { language: 'json' } }\r", " });\r", @@ -11297,6 +11190,42 @@ { "name": "Получение категорий", "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", + " pm.response.to.be.ok; \r", + " pm.response.to.be.withBody;\r", + " pm.response.to.be.json;\r", + "});\r", + "\r", + "const source = pm.collectionVariables.get('response');\r", + "const target = pm.response.json();\r", + "let founded;\r", + "target.forEach(function(element){\r", + " if (element.id == source.id) founded = element\r", + "});\r", + "\r", + "pm.test(\"Категория должна содержать поля: id, name\", function () {\r", + " pm.expect(target[0]).to.have.property('id');\r", + " pm.expect(target[0]).to.have.property('name');\r", + "});\r", + "\r", + "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", + " if (founded) {\r", + " pm.expect(source.id).equal(founded.id, 'Идентификатор категории должен соответствовать идентификатору категории добавленной ранее');\r", + " pm.expect(source.name).equal(founded.name, 'Название категории должно соответствовать названию категории добавленной ранее');\r", + " } else {\r", + " pm.expect.fail(\"No matching category found in the response\");\r", + " }\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, { "listen": "prerequest", "script": { @@ -11328,35 +11257,8 @@ " 100 \r", ");" ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const source = pm.collectionVariables.get('response');\r", - "const target = pm.response.json();\r", - "let founded;\r", - "target.forEach(function(element){if (element.id == source.id) founded = element});\r", - "\r", - "pm.test(\"Категория должна содержать поля: id, name\", function () {\r", - "pm.expect(target[0]).to.have.property('id');\r", - "pm.expect(target[0]).to.have.property('name');\r", - "});\r", - "\r", - "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", - " pm.expect(source.id).equal(founded.id, 'Идентификатор категории должен соответствовать идентификатору категории добавленной ранее');\r", - " pm.expect(source.name).equal(founded.name, 'Название категории должно соответствовать названию категории добавленной ранее');\r", - "});" - ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -12317,7 +12219,7 @@ "const source = pm.collectionVariables.get('response');\r", "const target = pm.response.json()[0];\r", "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, views, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", + "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", "pm.expect(target).to.have.property('id');\r", "pm.expect(target).to.have.property('title');\r", "pm.expect(target).to.have.property('annotation');\r", @@ -12325,7 +12227,6 @@ "pm.expect(target).to.have.property('paid');\r", "pm.expect(target).to.have.property('eventDate');\r", "pm.expect(target).to.have.property('initiator');\r", - "pm.expect(target).to.have.property('views');\r", "pm.expect(target).to.have.property('confirmedRequests');\r", "pm.expect(target).to.have.property('description');\r", "pm.expect(target).to.have.property('participantLimit');\r", @@ -12569,7 +12470,7 @@ "const source = pm.collectionVariables.get('response');\r", "const target = pm.response.json()[0];\r", "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, views, confirmedRequests\", function () {\r", + "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, confirmedRequests\", function () {\r", "pm.expect(target).to.have.property('id');\r", "pm.expect(target).to.have.property('title');\r", "pm.expect(target).to.have.property('annotation');\r", @@ -12577,7 +12478,6 @@ "pm.expect(target).to.have.property('paid');\r", "pm.expect(target).to.have.property('eventDate');\r", "pm.expect(target).to.have.property('initiator');\r", - "pm.expect(target).to.have.property('views');\r", "pm.expect(target).to.have.property('confirmedRequests');\r", "});\r", "\r", @@ -12677,6 +12577,7 @@ " const category = await api.addCategory(rnd.getCategory());\r", " let event = await api.addEvent(user.id, rnd.getEvent(category.id));\r", " event = await api.publishEvent(event.id);\r", + " pm.collectionVariables.set(\"userId\", user.id);\r", " pm.collectionVariables.set(\"eid\", event.id)\r", " pm.collectionVariables.set('response', event);\r", " } catch(err) {\r", @@ -12716,7 +12617,7 @@ "const source = pm.collectionVariables.get('response');\r", "const target = pm.response.json();\r", "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, views, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", + "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, initiator, confirmedRequests, description, participantLimit, state, createdOn, publishedOn, location, requestModeration\", function () {\r", "pm.expect(target).to.have.property('id');\r", "pm.expect(target).to.have.property('title');\r", "pm.expect(target).to.have.property('annotation');\r", @@ -12724,7 +12625,6 @@ "pm.expect(target).to.have.property('paid');\r", "pm.expect(target).to.have.property('eventDate');\r", "pm.expect(target).to.have.property('initiator');\r", - "pm.expect(target).to.have.property('views');\r", "pm.expect(target).to.have.property('confirmedRequests');\r", "pm.expect(target).to.have.property('description');\r", "pm.expect(target).to.have.property('participantLimit');\r", @@ -12752,6 +12652,11 @@ "request": { "method": "GET", "header": [ + { + "key": "X-EWM-USER-ID", + "value": "{{userId}}", + "type": "text" + }, { "key": "Accept", "value": "application/json" @@ -12933,7 +12838,8 @@ " 100 \r", ");" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -12958,13 +12864,18 @@ "});\r", "\r", "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", + " const sourceCreated = new Date(source.created);\r", + " const targetCreated = new Date(target.created);\r", " pm.expect(source.id).equal(target.id, 'Идентификатор запроса на участие в событии должен соответствовать идентификатору запроса, созданного ранее');\r", " pm.expect(source.requester).equal(target.requester, 'Пользователя, запрашивающий доступ на участие в событии, должен соответствовать указанному пользователю');\r", " pm.expect(source.event).equal(target.event, 'Событие, доступ к которому запрашивает пользователь, должно соответствовать событию, доступ к которому пользователь запрашивал доступ ранее');\r", - " pm.expect(source.created).equal(target.created, 'Время создания запроса на участие в событии должно соответствовать времени создания запроса, созданного ранее указанным пользователем');\r", + " pm.test('Время создания запроса на участие в событии должно соответствовать времени создания запроса, созданного ранее указанным пользователем', function () {\r", + " pm.expect(sourceCreated.getTime()).to.equal(targetCreated.getTime(), 'Времена не совпадают');\r", + "});\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -13042,7 +12953,8 @@ " 100 \r", ");" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -13067,13 +12979,18 @@ "});\r", "\r", "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", + " const sourceCreated = new Date(source.created);\r", + " const targetCreated = new Date(target.created);\r", " pm.expect(source.id).equal(target.id, 'Идентификатор запроса на участие в событии должен соответствовать идентификатору запроса на участие в событии указанного пользователя');\r", " pm.expect(source.requester).equal(target.requester, 'Автор запроса на участие в событии должен соответствовать указанному пользователю');\r", " pm.expect(source.event).equal(target.event, 'Событие в ответе должно соответствовать событию с запросом на участие от указанного пользователя');\r", - " pm.expect(source.created).equal(target.created, 'Время создания запроса на участие в событии должно соответствовать времени создания запроса на участие в событии указанного пользователя');\r", + " pm.test('Время создания запроса на участие в событии должно соответствовать времени создания запроса на участие в событии указанного пользователя', function () {\r", + " pm.expect(sourceCreated.getTime()).to.equal(targetCreated.getTime(), 'Времена не совпадают');\r", + "});\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -13430,7 +13347,8 @@ " 100 \r", ");" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -13460,13 +13378,18 @@ "});\r", "\r", "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", + " const sourceCreated = new Date(source.created);\r", + " const targetCreated = new Date(target.created);\r", " pm.expect(source.id).equal(target.id, 'Идентификатор отменённого запроса на участие в событии должен соответствовать идентификатору запроса до отмены');\r", " pm.expect(source.requester).equal(target.requester, 'Пользователь, отменяющий запрос на участие в событии, должен соответствовать текущему пользователю');\r", " pm.expect(source.event).equal(target.event, 'Событие отменённого запроса на участие должно соответствовать запросу на участие в событии до отмены');\r", - " pm.expect(source.created).equal(target.created, 'Дата создания отменённого запроса на участие в событии должна соответствовать дате создания запроса до отмены');\r", + " pm.test('Дата создания отменённого запроса на участие в событии должна соответствовать дате создания запроса до отмены', function () {\r", + " pm.expect(sourceCreated.getTime()).to.equal(targetCreated.getTime(), 'Время не совпадает');\r", + "});\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -13556,7 +13479,8 @@ " 100 \r", ");" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -13581,10 +13505,14 @@ "});\r", "\r", "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", + " const sourceCreated = new Date(source.created);\r", + " const targetCreated = new Date(target.created);\r", " pm.expect(source.id).equal(target.id, 'Идентификатор запроса на участие в событии должен соответствовать идентификатору запроса на участие в событии указанного пользователя');\r", " pm.expect(source.requester).equal(target.requester, 'Пользователь, запрашивающий доступ на участие в событии должен пользователю, отправившему запрос на участие в событии указанного пользователя ранее');\r", " pm.expect(source.event).equal(target.event, 'Событие, запрос на участие в котором надо подтвердить, должно соответствовать событию указанного пользователя');\r", - " pm.expect(source.created).equal(target.created, 'Время создания запроса на участие в событии после подтверждения должно соответствовать времени создания запроса на участие в событии указанного пользователя до подтверждения');\r", + " pm.test('Время создания запроса на участие в событии после подтверждения должно соответствовать времени создания запроса на участие в событии указанного пользователя до подтверждения', function () {\r", + " pm.expect(sourceCreated.getTime()).to.equal(targetCreated.getTime(), 'Время не совпадают');\r", + "});\r", "});\r", "\r", "pm.test(\"Запрос на участие должен иметь статус PENDING при создании и статус REJECTED после выполнения запроса\", function () {\r", @@ -13592,7 +13520,8 @@ " pm.expect(target.status).equal(\"REJECTED\");\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -13696,7 +13625,8 @@ " 100 \r", ");" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -13721,10 +13651,14 @@ "});\r", "\r", "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", + " const sourceCreated = new Date(source.created);\r", + " const targetCreated = new Date(target.created);\r", " pm.expect(source.id).equal(target.id, 'Идентификатор запроса на участие в событии должен соответствовать идентификатору запроса на участие в событии указанного пользователя');\r", " pm.expect(source.requester).equal(target.requester, 'Пользователь, запрашивающий доступ на участие в событии должен пользователю, отправившему запрос на участие в событии указанного пользователя ранее');\r", " pm.expect(source.event).equal(target.event, 'Событие, запрос на участие в котором надо подтвердить, должно соответствовать событию указанного пользователя');\r", - " pm.expect(source.created).equal(target.created, 'Время создания запроса на участие в событии после подтверждения должно соответствовать времени создания запроса на участие в событии указанного пользователя до подтверждения');\r", + " pm.test('Время создания запроса на участие в событии после подтверждения должно соответствовать времени создания запроса на участие в событии указанного пользователя до подтверждения', function () {\r", + " pm.expect(sourceCreated.getTime()).to.equal(targetCreated.getTime(), 'Времена не совпадают');\r", + "});\r", "});\r", "\r", "pm.test(\"Запрос на участие должен иметь статус PENDING при создании и статус CONFIRMED после выполнения запроса\", function () {\r", @@ -13732,7 +13666,8 @@ " pm.expect(target.status).equal(\"CONFIRMED\");\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -14351,79 +14286,99 @@ " }", "", " async addUser(user, verbose=null) {", - " return this.post(\"/admin/users\", user, \"Ошибка при добавлении нового пользователя: \", verbose);", + " return this.post(\"/admin/users\", user, 0, \"Ошибка при добавлении нового пользователя: \", verbose);", " }", "", " async addCategory(category, verbose=null) {", - " return this.post(\"/admin/categories\", category, \"Ошибка при добавлении новой категории: \", verbose);", + " return this.post(\"/admin/categories\", category, 0, \"Ошибка при добавлении новой категории: \", verbose);", " }", "", " async addEvent(userId, event, verbose=null) {", - " return this.post(\"/users/\" + userId + \"/events\", event, \"Ошибка при добавлении нового события: \", verbose);", + " return this.post(\"/users/\" + userId + \"/events\", event, 0, \"Ошибка при добавлении нового события: \", verbose);", " }", "", " async addCompilation(compilation, verbose=null) {", - " return this.post(\"/admin/compilations\", compilation, \"Ошибка при добавлении новой подборки: \", verbose);", + " return this.post(\"/admin/compilations\", compilation, 0, \"Ошибка при добавлении новой подборки: \", verbose);", " }", "", " async publishParticipationRequest(eventId, userId, verbose=null) {", - " return this.post('/users/' + userId + '/requests?eventId=' + eventId, null, \"Ошибка при добавлении нового запроса на участие в событии\", verbose);", + " return this.post('/users/' + userId + '/requests?eventId=' + eventId, null, 0, \"Ошибка при добавлении нового запроса на участие в событии\", verbose);", " }", "", " async publishEvent(eventId, verbose=null) {", - " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"}, \"Ошибка при публикации события\", verbose);", + " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"}, 0, \"Ошибка при публикации события\", verbose);", " }", " ", " async rejectEvent(eventId, verbose=null) {", - " return this.patch('/admin/events/' + eventId, {stateAction: \"REJECT_EVENT\"}, \"Ошибка при отмене события\", verbose);", + " return this.patch('/admin/events/' + eventId, {stateAction: \"REJECT_EVENT\"}, 0, \"Ошибка при отмене события\", verbose);", " }", "", " async acceptParticipationRequest(eventId, userId, reqId, verbose=null) {", - " return this.patch('/users/' + userId + '/events/' + eventId + '/requests', {requestIds:[reqId], status: \"CONFIRMED\"}, \"Ошибка при принятии заявки на участие в событии\", verbose);", + " return this.patch('/users/' + userId + '/events/' + eventId + '/requests', {requestIds:[reqId], status: \"CONFIRMED\"}, 0, \"Ошибка при принятии заявки на участие в событии\", verbose);", " }", "", " async findCategory(catId, verbose=null) {", - " return this.get('/categories/' + catId, null, \"Ошибка при поиске категории по id\", verbose);", + " return this.get('/categories/' + catId, null, 0, \"Ошибка при поиске категории по id\", verbose);", " }", "", " async findCompilation(compId, verbose=null) {", - " return this.get('/compilations/' + compId, null, \"Ошибка при поиске подборки по id\", verbose);", + " return this.get('/compilations/' + compId, null, 0, \"Ошибка при поиске подборки по id\", verbose);", " }", "", - " async findEvent(eventId, verbose=null) {", - " return this.get('/events/' + eventId, null, \"Ошибка при поиске события по id\", verbose);", + " async findEvent(eventId, id=0, verbose=null) {", + " return this.get('/events/' + eventId, null, id, \"Ошибка при поиске события по id\", verbose);", " }", "", " async findUser(userId, verbose=null) {", - " return this.get('/admin/users?ids=' + userId, null, \"Ошибка при поиске пользователя по id\", verbose);", + " return this.get('/admin/users?ids=' + userId, null, 0, \"Ошибка при поиске пользователя по id\", verbose);", " }", "", - " async post(path, body, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {", - " return this.sendRequest(\"POST\", path, body, errorText, verbose);", + " async post(path, body, id=0, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {", + " return this.sendRequest(\"POST\", path, body, id, errorText, verbose);", " }", "", - " async patch(path, body = null, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {", - " return this.sendRequest(\"PATCH\", path, body, errorText, verbose);", + " async patch(path, body = null, id=0, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {", + " return this.sendRequest(\"PATCH\", path, body, id, errorText, verbose);", " }", "", - " async get(path, body = null, errorText = \"Ошибка при выполнении get-запроса: \", verbose=null) {", - " return this.sendRequest(\"GET\", path, body, errorText, verbose);", + " async get(path, body = null, id=0, errorText = \"Ошибка при выполнении get-запроса: \", verbose=null) {", + " return this.sendRequest(\"GET\", path, body, id, errorText, verbose);", " }", - " async sendRequest(method, path, body=null, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {", + " async sendRequest(method, path, body=null, id=0, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {", " return new Promise((resolve, reject) => {", " verbose = verbose == null ? this._verbose : verbose;", - " const request = {", - " url: this.baseUrl + path,", - " method: method,", - " body: body == null ? \"\" : JSON.stringify(body),", - " header: { \"Content-Type\": \"application/json\" },", - " };", + " var req = {};", + " if (id == 0){", + " req = {", + " url: this.baseUrl + path,", + " method: method,", + " body: body == null ? \"\" : JSON.stringify(body),", + " header: { \"Content-Type\": \"application/json\"},", + " };", + " }else{", + " req = {", + " url: this.baseUrl + path,", + " method: method,", + " body: body == null ? \"\" : JSON.stringify(body),", + " header: [{", + " \"key\": \"X-Sharer-User-Id\",", + " \"value\": id,", + " \"type\": \"text\",", + " },", + " {", + " \"key\": \"Content-Type\",", + " \"name\": \"Content-Type\",", + " \"value\": \"application/json\",", + " \"type\": \"text\"", + " }]", + " };", + " }", " if(verbose) {", - " console.log(\"Отправляю запрос: \", request);", + " console.log(\"Отправляю запрос: \", req);", " }", "", " try {", - " this.pm.sendRequest(request, (error, response) => {", + " this.pm.sendRequest(req, (error, response) => {", " if(error || (response.code >= 400 && response.code <= 599)) {", " let err = error ? error : JSON.stringify(response.json());", " console.error(\"При выполнении запроса к серверу возникла ошика.\\n\", err,", @@ -14625,6 +14580,10 @@ { "key": "source", "value": "" + }, + { + "key": "userId", + "value": "" } ] -} +} \ No newline at end of file diff --git a/.postman/ewm-rating-service.json b/.postman/ewm-rating-service.json deleted file mode 100644 index 459a353..0000000 --- a/.postman/ewm-rating-service.json +++ /dev/null @@ -1,1057 +0,0 @@ -{ - "info": { - "_postman_id": "3f1bf201-4169-477e-89f3-801890a0f950", - "name": "feature_rating_events", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "50204807", - "_collection_link": "https://aleksobm-7059599.postman.co/workspace/Team's-Workspace~91ebb0d8-1476-4e40-85ec-9b22813a2ce5/collection/50204807-3f1bf201-4169-477e-89f3-801890a0f950?action=share&source=collection_link&creator=50204807" - }, - "item": [ - { - "name": "Поставить лайк событию", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " event = await api.publishEvent(event.id);\r", - "\r", - " const user = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set(\"uid\", user.id);\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 201 и данные в формате json\", function () {\r", - " pm.response.to.have.status(201); \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const source = JSON.parse(pm.request.body.raw);\r", - "const target = pm.response.json();\r", - "\r", - "pm.test(\"Ответ должен содержать поля: id, userId, eventId, reaction\", function () {\r", - " pm.expect(target).to.have.property('id');\r", - " pm.expect(target).to.have.property('userId');\r", - " pm.expect(target).to.have.property('eventId');\r", - " pm.expect(target).to.have.property('reaction');\r", - "});\r", - "\r", - "pm.test(\"Проверка данных в полях ответа\", function () {\r", - " pm.expect(target.id).to.not.be.null;\r", - " pm.expect(target.userId).to.equal(pm.collectionVariables.get('uid'));\r", - " pm.expect(target.eventId).to.equal(pm.collectionVariables.get('eid'));\r", - " pm.expect(target.reaction).to.equal('LIKE');\r", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Accept", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"reaction\" : \"LIKE\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId/likes", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId", - "likes" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - }, - "description": "Обратите внимание: дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента" - }, - "response": [] - }, - { - "name": "Поставить дизлайк событию", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " event = await api.publishEvent(event.id);\r", - "\r", - " const user = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set(\"uid\", user.id);\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 201 и данные в формате json\", function () {\r", - " pm.response.to.have.status(201); \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const source = JSON.parse(pm.request.body.raw);\r", - "const target = pm.response.json();\r", - "\r", - "pm.test(\"Ответ должен содержать поля: id, userId, eventId, reaction\", function () {\r", - " pm.expect(target).to.have.property('id');\r", - " pm.expect(target).to.have.property('userId');\r", - " pm.expect(target).to.have.property('eventId');\r", - " pm.expect(target).to.have.property('reaction');\r", - "});\r", - "\r", - "pm.test(\"Проверка данных в полях ответа\", function () {\r", - " pm.expect(target.id).to.not.be.null;\r", - " pm.expect(target.userId).to.equal(pm.collectionVariables.get('uid'));\r", - " pm.expect(target.eventId).to.equal(pm.collectionVariables.get('eid'));\r", - " pm.expect(target.reaction).to.equal('DISLIKE');\r", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Accept", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"reaction\" : \"DISLIKE\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId/likes", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId", - "likes" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - }, - "description": "Обратите внимание: дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента" - }, - "response": [] - }, - { - "name": "Получение события после добавления лайка", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set(\"uid\", initiator.id)\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " event = await api.publishEvent(event.id);\r", - " \r", - " const user = await api.addUser(rnd.getUser());\r", - " const like = await api.addLike(user.id, event.id);\r", - "\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const target = pm.response.json();\r", - "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, 'initiator', 'location', 'confirmedRequests', 'requestModeration', 'publishedOn', 'state', 'views', 'rate'\", function () {\r", - " pm.expect(target).to.contain.keys('id', 'title', 'annotation', 'category', 'paid', 'eventDate', 'initiator', 'location', 'confirmedRequests', 'requestModeration', 'publishedOn', 'state', 'views', 'rate');\r", - "});\r", - "\r", - "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", - " pm.expect(target.id).to.not.be.null;\r", - "});\r", - "\r", - "pm.test(\"Поле rate должно быть равно 1\", function () {\r", - " pm.expect(target.rate).to.equal(1);\r", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - } - }, - "response": [] - }, - { - "name": "Получение события после добавления дизлайка", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set(\"uid\", initiator.id)\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " event = await api.publishEvent(event.id);\r", - " \r", - " const user = await api.addUser(rnd.getUser());\r", - " const dislike = await api.addDislike(user.id, event.id);\r", - "\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const target = pm.response.json();\r", - "\r", - "pm.test(\"Событие должно содержать поля: id, title, annotation, category, paid, eventDate, 'initiator', 'location', 'confirmedRequests', 'requestModeration', 'publishedOn', 'state', 'views', 'rate'\", function () {\r", - " pm.expect(target).to.contain.keys('id', 'title', 'annotation', 'category', 'paid', 'eventDate', 'initiator', 'location', 'confirmedRequests', 'requestModeration', 'publishedOn', 'state', 'views', 'rate');\r", - "});\r", - "\r", - "pm.test(\"Данные в ответе должны соответствовать данным в запросе\", function () {\r", - " pm.expect(target.id).to.not.be.null;\r", - "});\r", - "\r", - "pm.test(\"Поле rate должно быть равно -1\", function () {\r", - " pm.expect(target.rate).to.equal(-1);\r", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - } - }, - "response": [] - }, - { - "name": "Удаление лайка", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " event = await api.publishEvent(event.id);\r", - "\r", - " const user = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set('uid', user.id);\r", - "\r", - " await api.addLike(user.id, event.id);\r", - " const likedEvent = await api.findEvent(event.id);\r", - " if (likedEvent.rate !== 1) {\r", - " throw \"Ошибка: Ожидается, что лайк увеличит рейтинг события до 1, но фактически рейтинг не изменился.\"\r", - " }\r", - "\r", - " } catch (err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => { }, 1000);\r", - "\r", - "setTimeout(async () => {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - "},\r", - " 100\r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.success;\r", - "});\r", - "\r", - "\r", - "\r", - "\r", - "" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "DELETE", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId/likes", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId", - "likes" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - } - }, - "response": [] - }, - { - "name": "Попытка поставить лайк своему событию", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set(\"uid\", initiator.id);\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " event = await api.publishEvent(event.id);\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 409 и данные в формате json\", function () {\r", - " pm.response.to.have.status(400);\r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Accept", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"reaction\" : \"LIKE\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId/likes", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId", - "likes" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - }, - "description": "Обратите внимание: дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента" - }, - "response": [] - }, - { - "name": "Попытка поставить лайк не опубликованному событию", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try {\r", - " const initiator = await api.addUser(rnd.getUser());\r", - "\r", - " const category = await api.addCategory(rnd.getCategory());\r", - "\r", - " let event = await api.addEvent(initiator.id, rnd.getEvent(category.id));\r", - " pm.collectionVariables.set(\"eid\", event.id);\r", - "\r", - " const user = await api.addUser(rnd.getUser());\r", - " pm.collectionVariables.set(\"uid\", user.id);\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 404 и данные в формате json\", function () {\r", - " pm.response.to.have.status(404);\r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Accept", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"reaction\" : \"LIKE\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/users/:userId/events/:eventId/likes", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "users", - ":userId", - "events", - ":eventId", - "likes" - ], - "variable": [ - { - "key": "userId", - "value": "{{uid}}", - "description": "(Required) id текущего пользователя" - }, - { - "key": "eventId", - "value": "{{eid}}", - "description": "(Required) id текущего события" - } - ] - }, - "description": "Обратите внимание: дата и время на которые намечено событие не может быть раньше, чем через два часа от текущего момента" - }, - "response": [] - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "API = class {", - " constructor(postman, verbose = false, baseUrl = \"http://localhost:8080\") {", - " this.baseUrl = baseUrl;", - " this.pm = postman;", - " this._verbose = verbose;", - " }", - "", - " async addLike(userId, eventId, verbose=null) {", - " let reaction = {\"reaction\" : \"LIKE\"};", - " return this.post(\"/users/\" + userId + \"/events/\" + eventId + \"/likes\", reaction, \"Ошибка при добавлении лайка: \", verbose)", - " }", - "", - " async addDislike(userId, eventId, verbose=null) {", - " let reaction = {\"reaction\" : \"DISLIKE\"};", - " return this.post(\"/users/\" + userId + \"/events/\" + eventId + \"/likes\", reaction, \"Ошибка при добавлении лайка: \", verbose)", - " }", - "", - " async addUser(user, verbose=null) {", - " return this.post(\"/admin/users\", user, \"Ошибка при добавлении нового пользователя: \", verbose);", - " }", - "", - " async addCategory(category, verbose=null) {", - " return this.post(\"/admin/categories\", category, \"Ошибка при добавлении новой категории: \", verbose);", - " }", - "", - " async addEvent(userId, event, verbose=null) {", - " return this.post(\"/users/\" + userId + \"/events\", event, \"Ошибка при добавлении нового события: \", verbose);", - " }", - "", - " async addCompilation(compilation, verbose=null) {", - " return this.post(\"/admin/compilations\", compilation, \"Ошибка при добавлении новой подборки: \", verbose);", - " }", - "", - " async publishParticipationRequest(eventId, userId, verbose=null) {", - " return this.post('/users/' + userId + '/requests?eventId=' + eventId, null, \"Ошибка при добавлении нового запроса на участие в событии\", verbose);", - " }", - "", - " async publishEvent(eventId, verbose=null) {", - " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"}, \"Ошибка при публикации события\", verbose);", - " }", - " ", - " async rejectEvent(eventId, verbose=null) {", - " return this.patch('/admin/events/' + eventId, {stateAction: \"REJECT_EVENT\"}, \"Ошибка при отмене события\", verbose);", - " }", - "", - " async acceptParticipationRequest(eventId, userId, reqId, verbose=null) {", - " return this.patch('/users/' + userId + '/events/' + eventId + '/requests', {requestIds:[reqId], status: \"CONFIRMED\"}, \"Ошибка при принятии заявки на участие в событии\", verbose);", - " }", - "", - " async findCategory(catId, verbose=null) {", - " return this.get('/categories/' + catId, null, \"Ошибка при поиске категории по id\", verbose);", - " }", - "", - " async findCompilation(compId, verbose=null) {", - " return this.get('/compilations/' + compId, null, \"Ошибка при поиске подборки по id\", verbose);", - " }", - "", - " async findEvent(eventId, verbose=null) {", - " return this.get('/events/' + eventId, null, \"Ошибка при поиске события по id\", verbose);", - " }", - "", - " async findUser(userId, verbose=null) {", - " return this.get('/admin/users?ids=' + userId, null, \"Ошибка при поиске пользователя по id\", verbose);", - " }", - "", - " async post(path, body, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {", - " return this.sendRequest(\"POST\", path, body, errorText, verbose);", - " }", - "", - " async patch(path, body = null, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {", - " return this.sendRequest(\"PATCH\", path, body, errorText, verbose);", - " }", - "", - " async get(path, body = null, errorText = \"Ошибка при выполнении get-запроса: \", verbose=null) {", - " return this.sendRequest(\"GET\", path, body, errorText, verbose);", - " }", - " async sendRequest(method, path, body=null, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {", - " return new Promise((resolve, reject) => {", - " verbose = verbose == null ? this._verbose : verbose;", - " const request = {", - " url: this.baseUrl + path,", - " method: method,", - " body: body == null ? \"\" : JSON.stringify(body),", - " header: { \"Content-Type\": \"application/json\" },", - " };", - " if(verbose) {", - " console.log(\"Отправляю запрос: \", request);", - " }", - "", - " try {", - " this.pm.sendRequest(request, (error, response) => {", - " if(error || (response.code >= 400 && response.code <= 599)) {", - " let err = error ? error : JSON.stringify(response.json());", - " console.error(\"При выполнении запроса к серверу возникла ошика.\\n\", err,", - " \"\\nДля отладки проблемы повторите такой же запрос к вашей программе \" + ", - " \"на локальном компьютере. Данные запроса:\\n\", JSON.stringify(request));", - "", - " reject(new Error(errorText + err));", - " }", - " if(verbose) {", - " console.log(\"Результат обработки запроса: код состояния - \", response.code, \", тело: \", response.json());", - " }", - " if (response.stream.length === 0){", - " reject(new Error('Отправлено пустое тело ответа'))", - " }else{", - " resolve(response.json());", - " }", - " });", - " ", - " } catch(err) {", - " if(verbose) {", - " console.error(errorText, err);", - " }", - " return Promise.reject(err);", - " }", - " });", - " }", - "};", - "", - "RandomUtils = class {", - " constructor() {}", - "", - " getUser() {", - " return {", - " name: pm.variables.replaceIn('{{$randomFullName}}'),", - " email: pm.variables.replaceIn('{{$randomEmail}}')", - " };", - " }", - "", - " getCategory() {", - " return {", - " name: pm.variables.replaceIn('{{$randomWord}}') + Math.floor(Math.random() * 10000 * Math.random()).toString()", - " };", - " }", - "", - " getEvent(categoryId) {", - " return {", - " annotation: pm.variables.replaceIn('{{$randomLoremParagraph}}'),", - " category: categoryId,", - " description: pm.variables.replaceIn('{{$randomLoremParagraphs}}'),", - " eventDate: this.getFutureDateTime(),", - " location: {", - " lat: parseFloat(pm.variables.replaceIn('{{$randomLatitude}}')),", - " lon: parseFloat(pm.variables.replaceIn('{{$randomLongitude}}')),", - " },", - " paid: pm.variables.replaceIn('{{$randomBoolean}}'),", - " participantLimit: pm.variables.replaceIn('{{$randomInt}}'),", - " requestModeration: pm.variables.replaceIn('{{$randomBoolean}}'),", - " title: pm.variables.replaceIn('{{$randomLoremSentence}}'),", - " }", - " }", - "", - " getCompilation(...eventIds) {", - " return {", - " title: pm.variables.replaceIn('{{$randomLoremSentence}}').slice(0, 50),", - " pinned: pm.variables.replaceIn('{{$randomBoolean}}'),", - " events: eventIds", - " };", - " }", - "", - "", - " getFutureDateTime(hourShift = 5, minuteShift=0, yearShift=0) {", - " let moment = require('moment');", - "", - " let m = moment();", - " m.add(hourShift, 'hour');", - " m.add(minuteShift, 'minute');", - " m.add(yearShift, 'year');", - "", - " return m.format('YYYY-MM-DD HH:mm:ss');", - " }", - "", - " getWord(length = 1) {", - " let result = '';", - " const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';", - " const charactersLength = characters.length;", - " let counter = 0;", - " while (counter < length) {", - " result += characters.charAt(Math.floor(Math.random() * charactersLength));", - " counter += 1;", - " }", - " return result;", - " }", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "" - ] - } - } - ], - "variable": [ - { - "key": "baseUrl", - "value": "" - }, - { - "key": "uid", - "value": "" - }, - { - "key": "eid", - "value": "" - } - ] -} \ No newline at end of file diff --git a/.postman/ewm-stat-service.json b/.postman/ewm-stat-service.json deleted file mode 100644 index ba35432..0000000 --- a/.postman/ewm-stat-service.json +++ /dev/null @@ -1,1027 +0,0 @@ -{ - "info": { - "_postman_id": "1b12f39d-a514-42d9-99a1-2d2f4026b8e8", - "name": "\"Explore with me\" API статистика", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "23073145", - "_collection_link": "https://universal-shadow-295426.postman.co/workspace/My-Workspace~4200f6aa-0504-44b1-8a1d-707d0dcbd5ce/collection/23073145-1b12f39d-a514-42d9-99a1-2d2f4026b8e8?action=share&creator=23073145&source=collection_link" - }, - "item": [ - { - "name": "Сохранение информации о том, что к эндпоинту был запрос", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " let post;\r", - " try {\r", - " post = rnd.getPost();\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "\r", - " pm.request.body.update({\r", - " mode: 'raw',\r", - " raw: JSON.stringify(post),\r", - " options: { raw: { language: 'json' } }\r", - " });\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 201 и данные в формате json\", function () {\r", - " pm.response.to.have.status(201);\r", - "});\r", - "" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/hit", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "hit" - ] - }, - "description": "Сохранение информации о том, что на uri конкретного сервиса был отправлен запрос пользователем. Название сервиса, uri и ip пользователя указаны в теле запроса." - }, - "response": [] - }, - { - "name": "Получение статистики по посещениям. Обратите внимание: значение даты и времени нужно закодировать (например используя java.net.URLEncoder.encode)(Тест на /events/{id})", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try { \r", - " const user = await api.addUser(rnd.getUser());\r", - " const category = await api.addCategory(rnd.getCategory());\r", - " let event = await api.addEvent(user.id, rnd.getEvent(category.id));\r", - " event = await api.publishEvent(event.id);\r", - " pm.collectionVariables.set(\"uri\", '/events/' + event.id);\r", - "\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events/\" + event.id,\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.sendRequest({\r", - " url : \"http://localhost:9090/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris=/events/\" + event.id.toString() + \"&unique=false\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.collectionVariables.set('source', response.json());\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events/\" + event.id,\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " });\r", - " });\r", - " });\r", - " \r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const target = pm.response.json()[0];\r", - "\r", - "pm.test(\"Посты должны содержать поля: app, uri, hits\", function () {\r", - " pm.expect(target).to.have.all.keys('app', 'uri', 'hits');\r", - "});\r", - "\r", - "const source = pm.collectionVariables.get(\"source\")[0];\r", - "\r", - "\r", - "pm.test(\"После выполнения запроса GET /events/{id} должно увеличиться количество хитов.\", function(){\r", - " pm.expect(source.hits + 1).equal(target.hits, 'Количество хитов после выполнения запроса GET /events/{id} должно быть больше на 1.');\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris={{uri}}&unique=false", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "end", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)" - } - ] - } - }, - "response": [] - }, - { - "name": "Тест взаимодействия сервисов", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try { \r", - " const user = await api.addUser(rnd.getUser());\r", - " const category = await api.addCategory(rnd.getCategory());\r", - " let event1 = await api.addEvent(user.id, rnd.getEvent(category.id));\r", - " event1 = await api.publishEvent(event1.id);\r", - " let event2 = await api.addEvent(user.id, rnd.getEvent(category.id));\r", - " event2 = await api.publishEvent(event2.id);\r", - " pm.collectionVariables.set(\"uri\", '/events/' + event1.id + '&uris=/events/' + event2.id);\r", - "\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events/\" + event1.id,\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events/\" + event2.id,\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events/\" + event2.id,\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " });\r", - " });\r", - " });\r", - " \r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const target = pm.response.json();\r", - "\r", - "pm.test(\"Посты должны содержать поля: app, uri, hits\", function () {\r", - " pm.expect(target[0]).to.have.all.keys('app', 'uri', 'hits');\r", - " pm.expect(target[1]).to.have.all.keys('app', 'uri', 'hits');\r", - "});\r", - "\r", - "pm.test(\"В теле ответа должна соблюдаться сортировка по убыванию количества просмотров\", function(){\r", - " pm.expect(target[0].hits).to.be.above(target[1].hits);\r", - "});\r", - "\r", - "pm.test(\"Проверка соответствия реального количества просмотров событий и сохраненных хитов\", function(){\r", - " pm.expect(target[0].hits).equal(2);\r", - " pm.expect(target[1].hits).equal(1);\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris={{uri}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "end", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "uris", - "value": "aliqua o", - "description": "Список uri для которых нужно выгрузить статистику", - "disabled": true - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "Получение статистики по посещениям. Обратите внимание: значение даты и времени нужно закодировать (например используя java.net.URLEncoder.encode)(Тест на /events)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try { \r", - " pm.collectionVariables.set(\"uri\", '/events'); \r", - " \r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " \r", - " pm.sendRequest({\r", - " url : \"http://localhost:9090/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris=/events&unique=false\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.collectionVariables.set('source', response.json());\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " });\r", - " });\r", - " });\r", - " \r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const target = pm.response.json()[0];\r", - "\r", - "pm.test(\"Посты должны содержать поля: app, uri, hits\", function () {\r", - " pm.expect(target).to.have.all.keys('app', 'uri', 'hits');\r", - "});\r", - "\r", - "const source = pm.collectionVariables.get(\"source\")[0];\r", - "\r", - "\r", - "pm.test(\"После выполнения запроса GET /events должно увеличиться количество хитов.\", function(){\r", - " pm.expect(source.hits + 1).equal(target.hits, 'Количество хитов после выполнения запроса GET /events должно быть больше на 1.');\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris={{uri}}&unique=false", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "end", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)" - } - ] - } - }, - "response": [] - }, - { - "name": "Получение статистики по посещениям. (Тест на опциональность и работу параметра unique)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "const main = async () => {\r", - " const api = new API(pm);\r", - " const rnd = new RandomUtils();\r", - "\r", - " try { \r", - " pm.collectionVariables.set(\"uri\", '/events'); \r", - " \r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.sendRequest({\r", - " url : \"http://localhost:8080/events\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " \r", - " pm.sendRequest({\r", - " url : \"http://localhost:9090/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris=/events&unique=true\",\r", - " method : \"GET\",\r", - " header: { \"Content-Type\": \"application/json\" }\r", - " }, (error, response) => {\r", - " pm.collectionVariables.set('source', response.json());\r", - " });\r", - " });\r", - " });\r", - " } catch(err) {\r", - " console.error(\"Ошибка при подготовке тестовых данных.\", err);\r", - " }\r", - "};\r", - "\r", - "const interval = setInterval(() => {}, 1000);\r", - "\r", - "setTimeout(async () => \r", - " {\r", - " try {\r", - " await main();\r", - " } catch (e) {\r", - " console.error(e);\r", - " } finally {\r", - " clearInterval(interval);\r", - " }\r", - " }, \r", - " 100 \r", - ");" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 200 и данные в формате json при запросе без опционального параметра unique\", function () {\r", - " pm.response.to.be.ok; \r", - " pm.response.to.be.withBody;\r", - " pm.response.to.be.json;\r", - "});\r", - "\r", - "const target = pm.response.json()[0];\r", - "\r", - "pm.test(\"Посты должны содержать поля: app, uri, hits\", function () {\r", - " pm.expect(target).to.have.all.keys('app', 'uri', 'hits');\r", - "});\r", - "\r", - "const source = pm.collectionVariables.get(\"source\")[0];\r", - "\r", - "pm.test(\"Количество уникальных просмотров с одного ip должно равняться 1\", function () {\r", - " pm.expect(source.hits == 1);\r", - "})\r", - "\r", - "pm.test(\"Количество просмотров с одного ip должно быть больше 1\", function () {\r", - " pm.expect(target.hits > 1);\r", - "})" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?start=2020-05-05 00:00:00&end=2035-05-05 00:00:00&uris={{uri}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "end", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "uris", - "value": "aliqua o", - "description": "Список uri для которых нужно выгрузить статистику", - "disabled": true - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "Получение статистики по посещениям. (Тест на верную обработку запроса с неверными датами начала и конца диапазона времени)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 400\", function () {\r", - " pm.response.to.be.badRequest;\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?start=2035-05-05 00:00:00&end=2020-05-05 00:00:00&uris={{uri}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "end", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "Тест на верную обработку запроса без даты начала", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 400\", function () {\r", - " pm.response.to.be.badRequest;\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?end=2020-05-05 00:00:00&uris={{uri}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")", - "disabled": true - }, - { - "key": "end", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)", - "disabled": true - } - ] - } - }, - "response": [] - }, - { - "name": "Тест на верную обработку запроса без даты конца", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Ответ должен содержать код статуса 400\", function () {\r", - " pm.response.to.be.badRequest;\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{baseUrl}}/stats?start=2035-05-05 00:00:00&uris={{uri}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "stats" - ], - "query": [ - { - "key": "start", - "value": "2035-05-05 00:00:00", - "description": "(Required) Дата и время начала диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")" - }, - { - "key": "end", - "value": "2020-05-05 00:00:00", - "description": "(Required) Дата и время конца диапазона за который нужно выгрузить статистику (в формате \"yyyy-MM-dd HH:mm:ss\")", - "disabled": true - }, - { - "key": "uris", - "value": "{{uri}}", - "description": "Список uri для которых нужно выгрузить статистику" - }, - { - "key": "unique", - "value": "false", - "description": "Нужно ли учитывать только уникальные посещения (только с уникальным ip)", - "disabled": true - } - ] - } - }, - "response": [] - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "API = class {", - " constructor(postman, verbose = false, baseUrl = \"http://localhost:9090\") {", - " this.baseUrl = baseUrl;", - " this.pm = postman;", - " this._verbose = verbose;", - " }", - "", - " async addPost(post, verbose=null) {", - " return this.post(\"/hit\", post, \"Ошибка при сохранении информации о запросе к эндпойнту: \", verbose);", - " }", - "", - " async addUser(user, verbose=null) {", - " return this.post(\"/admin/users\", user, \"http://localhost:8080\", \"Ошибка при добавлении нового пользователя: \", verbose);", - " }", - "", - " async addCategory(category, verbose=null) {", - " return this.post(\"/admin/categories\", category, \"http://localhost:8080\", \"Ошибка при добавлении новой категории: \", verbose);", - " }", - "", - " async addEvent(userId, event, verbose=null) {", - " return this.post(\"/users/\" + userId + \"/events\", event, \"http://localhost:8080\", \"Ошибка при добавлении нового события: \", verbose);", - " }", - "", - " async publishEvent(eventId, verbose=null) {", - " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"},\"Ошибка при публикации события\", verbose);", - " }", - "", - " async patch(path, body = null, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {", - " return this.sendRequest(\"PATCH\", path, \"http://localhost:8080\", body, errorText);", - " }", - "", - " async post(path, body, newBaseUrl=null, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {", - " return this.sendRequest(\"POST\", path, newBaseUrl, body, errorText);", - " }", - "", - " async sendRequest(method, path, newBaseUrl=null, body=null, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {", - " return new Promise((resolve, reject) => {", - " verbose = verbose == null ? this._verbose : verbose;", - " let request;", - " if (newBaseUrl==null)", - " request = {", - " url: this.baseUrl + path,", - " method: method,", - " body: body == null ? \"\" : JSON.stringify(body),", - " header: { \"Content-Type\": \"application/json\" },", - " };", - " else", - " request = {", - " url: newBaseUrl + path,", - " method: method,", - " body: body == null ? \"\" : JSON.stringify(body),", - " header: { \"Content-Type\": \"application/json\" },", - " };", - "", - " if(verbose) {", - " console.log(\"Отправляю запрос: \", request);", - " }", - "", - " try {", - " this.pm.sendRequest(request, (error, response) => {", - " if(error || (response.code >= 400 && response.code <= 599)) {", - " let err = error ? error : JSON.stringify(response.json());", - " console.error(\"При выполнении запроса к серверу возникла ошика.\\n\", err,", - " \"\\nДля отладки проблемы повторите такой же запрос к вашей программе \" + ", - " \"на локальном компьютере. Данные запроса:\\n\", JSON.stringify(request));", - "", - " reject(new Error(errorText + err));", - " }", - "", - " if(verbose) {", - " console.log(\"Результат обработки запроса: код состояния - \", response.code, \", тело: \", response.json());", - " }", - " try{", - " resolve(response.json());", - " } catch(err){", - " resolve(response);", - " }", - " ", - " });", - " } catch(err) {", - " if(verbose) {", - " console.error(errorText, err);", - " }", - " return Promise.reject(err);", - " }", - " });", - " }", - "};", - "", - "RandomUtils = class {", - " constructor() {}", - "", - " getPost() {", - " return {", - " app: \"ewm-main-service\",", - " uri: \"/events/\" + pm.variables.replaceIn('{{$randomInt}}'),", - " ip: pm.variables.replaceIn('{{$randomIP}}'),", - " timestamp: this.getPastDateTime()", - " }", - " }", - "", - " getUser() {", - " return {", - " name: pm.variables.replaceIn('{{$randomFullName}}'),", - " email: pm.variables.replaceIn('{{$randomEmail}}')", - " };", - " }", - "", - " getCategory() {", - " return {", - " name: pm.variables.replaceIn('{{$randomWord}}') + Math.floor(Math.random() * 100).toString()", - " };", - " }", - "", - " getEvent(categoryId) {", - " return {", - " annotation: pm.variables.replaceIn('{{$randomLoremParagraph}}'),", - " category: categoryId,", - " description: pm.variables.replaceIn('{{$randomLoremParagraphs}}'),", - " eventDate: this.getFutureDateTime(),", - " location: {", - " lat: parseFloat(pm.variables.replaceIn('{{$randomLatitude}}')),", - " lon: parseFloat(pm.variables.replaceIn('{{$randomLongitude}}')),", - " },", - " paid: pm.variables.replaceIn('{{$randomBoolean}}'),", - " participantLimit: pm.variables.replaceIn('{{$randomInt}}'),", - " requestModeration: pm.variables.replaceIn('{{$randomBoolean}}'),", - " title: pm.variables.replaceIn('{{$randomLoremSentence}}'),", - " }", - " }", - " ", - " getCompilation(...eventIds) { ", - " return { ", - " title: pm.variables.replaceIn('{{$randomLoremSentence}}'), ", - " pinned: pm.variables.replaceIn('{{$randomBoolean}}'), ", - " events: eventIds ", - " }; ", - " }", - "", - " getPastDateTime(hourShift = 5, minuteShift=0, yearShift=0) {", - " let moment = require('moment');", - "", - " let m = moment();", - " m.subtract(hourShift, 'hour');", - " m.subtract(minuteShift, 'minute');", - " m.subtract(yearShift, 'year');", - "", - " return m.format('YYYY-MM-DD HH:mm:ss');", - " }", - "", - " getFutureDateTime(hourShift = 5, minuteShift=0, yearShift=0) {", - " let moment = require('moment');", - "", - " let m = moment();", - " m.add(hourShift, 'hour');", - " m.add(minuteShift, 'minute');", - " m.add(yearShift, 'year');", - "", - " return m.format('YYYY-MM-DD HH:mm:ss');", - " }", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "" - ] - } - } - ], - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:9090", - "type": "string" - }, - { - "key": "uri", - "value": "1" - }, - { - "key": "source", - "value": "" - } - ] -} diff --git a/core/aggregation-api/pom.xml b/core/aggregation-api/pom.xml index 3d1e4eb..205e35e 100644 --- a/core/aggregation-api/pom.xml +++ b/core/aggregation-api/pom.xml @@ -14,12 +14,6 @@ jar - - ru.practicum - stat-dto - 0.0.1-SNAPSHOT - - jakarta.servlet jakarta.servlet-api diff --git a/core/aggregation-api/src/main/java/ru/practicum/aggregation/client/stats/StatsClient.java b/core/aggregation-api/src/main/java/ru/practicum/aggregation/client/stats/StatsClient.java deleted file mode 100644 index 77dee9b..0000000 --- a/core/aggregation-api/src/main/java/ru/practicum/aggregation/client/stats/StatsClient.java +++ /dev/null @@ -1,31 +0,0 @@ -package ru.practicum.aggregation.client.stats; - -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import ru.practicum.stat.dto.EndpointHitDto; -import ru.practicum.stat.dto.ViewStatsDto; - -import java.time.LocalDateTime; -import java.util.List; - -@FeignClient( - name = "stats-server", - fallbackFactory = StatsFallbackFactory.class -) -public interface StatsClient { - - @PostMapping("/hit") - void hit(@RequestBody EndpointHitDto endpointHitDto); - - @GetMapping("/stats") - List getStats( - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end, - @RequestParam List uris, - @RequestParam boolean unique); - -} diff --git a/core/aggregation-api/src/main/java/ru/practicum/aggregation/client/stats/StatsFallbackFactory.java b/core/aggregation-api/src/main/java/ru/practicum/aggregation/client/stats/StatsFallbackFactory.java deleted file mode 100644 index 5b1afe3..0000000 --- a/core/aggregation-api/src/main/java/ru/practicum/aggregation/client/stats/StatsFallbackFactory.java +++ /dev/null @@ -1,36 +0,0 @@ -package ru.practicum.aggregation.client.stats; - -import org.springframework.cloud.openfeign.FallbackFactory; -import org.springframework.stereotype.Component; -import ru.practicum.aggregation.error.exception.unavailable.StatsServerUnavailableException; -import ru.practicum.stat.dto.EndpointHitDto; -import ru.practicum.stat.dto.ViewStatsDto; - -import java.time.LocalDateTime; -import java.util.List; - -@Component -public class StatsFallbackFactory implements FallbackFactory { - - @Override - public StatsClient create(Throwable cause) { - - return new StatsClient() { - - @Override - public void hit(EndpointHitDto endpointHitDto) { - throw new StatsServerUnavailableException("", cause); - } - - @Override - public List getStats(LocalDateTime start, - LocalDateTime end, - List uris, - boolean unique) { - - throw new StatsServerUnavailableException("", cause); - } - }; - } - -} diff --git a/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventFullDto.java b/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventFullDto.java index 492adf3..ec210c3 100644 --- a/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventFullDto.java +++ b/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventFullDto.java @@ -26,8 +26,7 @@ * boolean requestModeration
* EventState state
* String title
- * Long views
- * long rate + * double rating */ @Builder public record EventFullDto( @@ -68,8 +67,6 @@ public record EventFullDto( @NotBlank String title, - Long views, - - long rate + double rating ) { } \ No newline at end of file diff --git a/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventShortDto.java b/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventShortDto.java index d074fe0..cef28c8 100644 --- a/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventShortDto.java +++ b/core/aggregation-api/src/main/java/ru/practicum/aggregation/dto/event/output/EventShortDto.java @@ -16,8 +16,7 @@ * {@link UserShortDto} initiator
* boolean paid
* {@link String} title
- * long views
- * long rate + * double rateing */ @Builder public record EventShortDto( @@ -29,7 +28,6 @@ public record EventShortDto( UserShortDto initiator, boolean paid, String title, - long views, - long rate + double rating ) { } diff --git a/core/aggregation-api/src/main/java/ru/practicum/aggregation/repository/StatsFeignRepository.java b/core/aggregation-api/src/main/java/ru/practicum/aggregation/repository/StatsFeignRepository.java deleted file mode 100644 index 0ff5776..0000000 --- a/core/aggregation-api/src/main/java/ru/practicum/aggregation/repository/StatsFeignRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package ru.practicum.aggregation.repository; - -import jakarta.servlet.http.HttpServletRequest; -import ru.practicum.aggregation.model.data.StatsRequestData; -import ru.practicum.stat.dto.ViewStatsDto; - -import java.util.List; -import java.util.Optional; - -public interface StatsFeignRepository { - - void sendHitRequest(HttpServletRequest request); - - Optional> getStatList(StatsRequestData request); - -} diff --git a/core/aggregation-api/src/main/java/ru/practicum/aggregation/repository/StatsFeignRepositoryImpl.java b/core/aggregation-api/src/main/java/ru/practicum/aggregation/repository/StatsFeignRepositoryImpl.java deleted file mode 100644 index 6f090e7..0000000 --- a/core/aggregation-api/src/main/java/ru/practicum/aggregation/repository/StatsFeignRepositoryImpl.java +++ /dev/null @@ -1,96 +0,0 @@ -package ru.practicum.aggregation.repository; - -import jakarta.servlet.http.HttpServletRequest; -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.env.Environment; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; -import ru.practicum.aggregation.client.RemoteCallExecutor; -import ru.practicum.aggregation.client.RemoteCallResult; -import ru.practicum.aggregation.client.stats.StatsClient; -import ru.practicum.aggregation.error.exception.stats.StatsResponseException; -import ru.practicum.stat.dto.EndpointHitDto; -import ru.practicum.aggregation.model.data.StatsRequestData; -import ru.practicum.stat.dto.ViewStatsDto; - -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; - -@Slf4j -@Component -@RequiredArgsConstructor -@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class StatsFeignRepositoryImpl implements StatsFeignRepository { - - Environment environment; - StatsClient statsClient; - - @Override - public void sendHitRequest(@NonNull HttpServletRequest request) { - hit(EndpointHitDto.builder() - .ip(request.getRemoteAddr()) - .uri(request.getRequestURI()) - .app(environment.getProperty("spring.application.name")) - .timestamp(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS)) - .build()); - } - - @Override - public Optional> getStatList(@NonNull StatsRequestData request) { - - var statUris = request.getUris(); - if (statUris.isEmpty()) { - throw new StatsResponseException(); - } - - return getStatistic(request.getStart(), request.getEnd(), statUris, request.getUnique()); - } - - @SuppressWarnings("unused") - private void hit(EndpointHitDto endpointHitDto) { - log.info(""" - PLEASE WAITING - Система регистрирует статистику - {}""", endpointHitDto); - switch (RemoteCallExecutor.executeVoid(() -> statsClient.hit(endpointHitDto))) { - - case RemoteCallResult.Success(var nullable) -> { - } - case RemoteCallResult.Failure(var exeptionNullable) -> { - } - case RemoteCallResult.Degraded(var cause) -> log.warn(""" - Деградация - Не удалось зарегистрировать статистику - {}""", endpointHitDto, cause); - - } - } - - @NonNull - private Optional> getStatistic(LocalDateTime start, - LocalDateTime end, - List uris, - boolean unique) { - log.info(""" - PLEASE WAITING - Система получает статистику - uris: {}""", uris); - return switch (RemoteCallExecutor.execute(() -> statsClient.getStats(start, end, uris, unique))) { - case RemoteCallResult.Success(var response) -> Optional.of(response); - case RemoteCallResult.Failure(var exeptionNullable) -> throw exeptionNullable; - case RemoteCallResult.Degraded(var cause) -> { - log.warn(""" - Деградация - Не удалось получить статистику - uris: {}""", uris, cause); - - yield Optional.empty(); - } - }; - } -} diff --git a/core/event-service/pom.xml b/core/event-service/pom.xml index 86abe85..a42f875 100644 --- a/core/event-service/pom.xml +++ b/core/event-service/pom.xml @@ -16,12 +16,12 @@ ru.practicum - stat-dto + aggregation-api 0.0.1-SNAPSHOT ru.practicum - aggregation-api + stats-client 0.0.1-SNAPSHOT diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/EventServiceApp.java b/core/event-service/src/main/java/ru/practicum/ewm/events/EventServiceApp.java index d7cdb4d..0b228fc 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/EventServiceApp.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/EventServiceApp.java @@ -7,7 +7,6 @@ import org.springframework.context.annotation.ComponentScan; import ru.practicum.aggregation.client.event.EventClient; import ru.practicum.aggregation.client.request.RequestClient; -import ru.practicum.aggregation.client.stats.StatsClient; import ru.practicum.aggregation.client.user.UserClient; import ru.practicum.aggregation.config.JacksonConfiguration; @@ -15,15 +14,15 @@ @EnableFeignClients(clients = { UserClient.class, EventClient.class, - RequestClient.class, - StatsClient.class + RequestClient.class }) @ComponentScan(value = { "ru.practicum.ewm.events", "ru.practicum.aggregation.config", "ru.practicum.aggregation.client", "ru.practicum.aggregation.error", - "ru.practicum.aggregation.repository" + "ru.practicum.aggregation.repository", + "ru.practicum.stats.client.grpc" }) @ConfigurationPropertiesScan(basePackageClasses = JacksonConfiguration.class) public class EventServiceApp { diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/free/FreeEventController.java b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/free/FreeEventController.java index 728027a..c51499f 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/free/FreeEventController.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/free/FreeEventController.java @@ -11,7 +11,6 @@ import ru.practicum.aggregation.dto.event.output.EventFullDto; import ru.practicum.aggregation.dto.event.output.EventShortDto; import ru.practicum.aggregation.model.data.FreeGetData; -import ru.practicum.aggregation.repository.StatsFeignRepository; import ru.practicum.ewm.events.service.event.EventService; import java.time.LocalDateTime; @@ -25,7 +24,6 @@ public class FreeEventController { EventService eventService; - StatsFeignRepository statsFeignRepository; /** * Обратите внимание:
@@ -90,10 +88,7 @@ public List getFreeEvents( Получение событий с возможностью фильтрации {} {}""", request.getMethod(), request.getRequestURI()); - var result = eventService.getFreeEvents(freeGetData, request); - statsFeignRepository.sendHitRequest(request); - - return result; + return eventService.getFreeEvents(freeGetData, request); } @GetMapping(value = "/{eventId}") @@ -105,10 +100,7 @@ public EventFullDto getFreeEventById(@PathVariable Long eventId, Получение подробной информации об опубликованном событии по его идентификатору. {} {}""", request.getMethod(), request.getRequestURI()); - var result = eventService.getFreeEventById(eventId, request); - statsFeignRepository.sendHitRequest(request); - - return result; + return eventService.getFreeEventById(eventId, request); } } diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/system/SystemRatingController.java b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/system/SystemRatingController.java deleted file mode 100644 index 6bd9ceb..0000000 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/system/SystemRatingController.java +++ /dev/null @@ -1,28 +0,0 @@ -package ru.practicum.ewm.events.controller.system; - -import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import ru.practicum.aggregation.dto.rating.come.update.RatingUpdateRequest; -import ru.practicum.ewm.events.service.rating.EventRatingService; - -@Slf4j -@RestController -@RequestMapping(path = "/system/events/ratings") -@RequiredArgsConstructor -@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class SystemRatingController { - - EventRatingService eventRatingService; - - @PatchMapping - public void updateRating(@RequestBody @NotNull RatingUpdateRequest request) { - eventRatingService.updateRating(request); - } -} diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserEventController.java b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserEventController.java index 591c2da..c0e6665 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserEventController.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserEventController.java @@ -5,8 +5,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import lombok.AccessLevel; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @@ -19,23 +21,29 @@ import ru.practicum.aggregation.dto.participation.output.ParticipationRequestDto; import ru.practicum.ewm.events.service.event.EventService; import ru.practicum.ewm.events.service.request.EventRequestService; +import ru.practicum.stats.client.dto.ActionType; +import ru.practicum.stats.client.dto.UserActionDto; +import ru.practicum.stats.client.grpc.CollectorClient; +import java.time.LocalDateTime; import java.util.List; @Slf4j @RestController @RequestMapping(path = "/users/{userId}/events") @RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class UserEventController { - private final EventService eventService; - private final EventRequestService eventRequestService; + EventService eventService; + EventRequestService eventRequestService; + CollectorClient collectorClient; /** - * @param from количество элементов, которые нужно пропустить для формирования текущего набора
- * Default value : 0 - * @param size количество элементов в наборе
- * Default value : 10 + * @param from количество элементов, которые нужно пропустить для формирования текущего набора
+ * Default value : 0 + * @param size количество элементов в наборе
+ * Default value : 10 */ @GetMapping public List findEventsByUserId( @@ -52,7 +60,7 @@ public List findEventsByUserId( return eventService.findByUserId(userId, from, size); } - @GetMapping("/{eventId}") + @GetMapping(value = "/{eventId}") public EventFullDto findEventById(@PathVariable @Positive Long userId, @PathVariable @Positive Long eventId, @NonNull HttpServletRequest request) { @@ -60,7 +68,19 @@ public EventFullDto findEventById(@PathVariable @Positive Long userId, ENDPOINT Получение полной информации о событии добавленном текущим пользователем {} {}""", request.getMethod(), request.getRequestURI()); - return eventService.findEventByUserIdAndEventId(userId, eventId); + + var result = eventService.findEventByUserIdAndEventId(userId, eventId); + + log.info("Регистрация просмотра события {} пользователем {}", eventId, userId); + collectorClient.collectUserAction( + UserActionDto.builder() + .userId(userId) + .eventId(eventId) + .actionType(ActionType.VIEWS) + .timestamp(LocalDateTime.now()) + .build()); + + return result; } /** diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserRatingController.java b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserRatingController.java new file mode 100644 index 0000000..3cb40f1 --- /dev/null +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserRatingController.java @@ -0,0 +1,33 @@ +package ru.practicum.ewm.events.controller.user; + +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import ru.practicum.stats.client.dto.ActionType; +import ru.practicum.stats.client.dto.UserActionDto; +import ru.practicum.stats.client.grpc.CollectorClient; + +import java.time.LocalDateTime; + +@Slf4j +@RestController +@RequestMapping("/events") +@RequiredArgsConstructor +public class UserRatingController { + + private final CollectorClient collectorClient; + + @PutMapping("/{eventId}/like") + public void addLike(@PathVariable @Positive Long eventId, @RequestHeader("X-EWM-USER-ID") Long userId) { + log.info("Пользователь {} поставил лайк событию {}", userId, eventId); + collectorClient.collectUserAction( + UserActionDto.builder() + .userId(userId) + .eventId(eventId) + .actionType(ActionType.LIKE) + .timestamp(LocalDateTime.now()) + .build() + ); + } +} diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserRecomendationsController.java b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserRecomendationsController.java new file mode 100644 index 0000000..00d57c9 --- /dev/null +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/controller/user/UserRecomendationsController.java @@ -0,0 +1,35 @@ +package ru.practicum.ewm.events.controller.user; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import ru.practicum.aggregation.dto.event.output.EventShortDto; +import ru.practicum.ewm.events.service.event.EventService; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping(path = "/events/recommendations") +@RequiredArgsConstructor +public class UserRecomendationsController { + + private final EventService eventService; + + @GetMapping + public List getRecommendationsForUser(@RequestHeader("X-EWM-USER-ID") + long userId, + @RequestParam(defaultValue = "10", required = false) + Integer maxResults, + @NonNull HttpServletRequest request) { + log.info(""" + ENDPOINT + Получение рекомендации мероприятий для пользователя. + {} {}""", request.getMethod(), request.getRequestURI()); + + return eventService.getRecommendationsForUser(userId, maxResults); + } + +} diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/entity/Event.java b/core/event-service/src/main/java/ru/practicum/ewm/events/entity/Event.java index 4d09417..c8dcd2c 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/entity/Event.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/entity/Event.java @@ -103,8 +103,4 @@ public class Event extends BaseEntity { /// В каких компиляциях состоит событие @ManyToMany(mappedBy = "events") List compilations; - - /// Рейтинг события, вычисляемый как количество лайков и дизлайков - @Column(nullable = false) - private long rate; } diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/CompilationMapper.java b/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/CompilationMapper.java index 3078c24..d339b8f 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/CompilationMapper.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/CompilationMapper.java @@ -19,7 +19,7 @@ public class CompilationMapper { public CompilationDto toCompilationDto(@NonNull Compilation compilation, @NonNull Map eventIdToInitiator, @NonNull Map confirmedRequests, - @NonNull Map views) { + @NonNull Map ratings) { return CompilationDto.builder() .id(compilation.getId()) .pinned(compilation.isPinned()) @@ -31,7 +31,7 @@ public CompilationDto toCompilationDto(@NonNull Compilation compilation, .initiator(eventIdToInitiator.get(event.getId())) .confirmedRequests(confirmedRequests .getOrDefault(event.getId(), 0L)) - .views(views.getOrDefault(event.getId(), 0L)) + .rating(ratings.getOrDefault(event.getId(), 0.0)) .build() )) .toList()) diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/EventMapper.java b/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/EventMapper.java index a6d885e..609f47a 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/EventMapper.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/mapper/EventMapper.java @@ -29,8 +29,7 @@ public EventShortDto toEventShortDto(@NonNull Event event, .initiator(eventData.initiator()) .paid(event.isPaid()) .title(event.getTitle()) - .views(eventData.views()) - .rate(event.getRate()) + .rating(eventData.rating()) .build(); } @@ -52,8 +51,7 @@ public EventFullDto toEventFullDto(@NonNull Event event, .requestModeration(event.isRequestModeration()) .state(event.getState()) .title(event.getTitle()) - .views(eventData.views()) - .rate(event.getRate()) + .rating(eventData.rating()) .build(); } @@ -79,7 +77,6 @@ public Event toEntity(@NonNull NewEventDto newEventDto, || newEventDto.requestModeration()) .state(state) .title(newEventDto.title()) - .rate(0) .build(); } diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/model/EventData.java b/core/event-service/src/main/java/ru/practicum/ewm/events/model/EventData.java index 49c8111..271fe93 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/model/EventData.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/model/EventData.java @@ -5,14 +5,14 @@ /** * @apiNote - * UserShortDto initiator
- * long confirmedRequests
- * long views + * UserShortDto initiator
+ * long confirmedRequests
+ * double rating */ @Builder public record EventData( UserShortDto initiator, long confirmedRequests, - long views + double rating ) { } \ No newline at end of file diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/service/compilation/CompilationServiceImpl.java b/core/event-service/src/main/java/ru/practicum/ewm/events/service/compilation/CompilationServiceImpl.java index 9ae7429..c8a664f 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/service/compilation/CompilationServiceImpl.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/service/compilation/CompilationServiceImpl.java @@ -17,15 +17,14 @@ import ru.practicum.aggregation.model.entity.BaseEntity; import ru.practicum.aggregation.model.repository.EventRequestCount; import ru.practicum.aggregation.repository.RequestFeignRepository; -import ru.practicum.aggregation.repository.StatsFeignRepository; import ru.practicum.aggregation.repository.UserFeignRepository; import ru.practicum.ewm.events.entity.Compilation; import ru.practicum.ewm.events.entity.Event; import ru.practicum.ewm.events.mapper.CompilationMapper; import ru.practicum.ewm.events.repository.CompilationRepository; import ru.practicum.ewm.events.repository.EventRepository; -import ru.practicum.aggregation.model.data.StatsRequestData; -import ru.practicum.stat.dto.ViewStatsDto; +import ru.practicum.stats.client.dto.ActionsWeightsSum; +import ru.practicum.stats.client.grpc.AnalyzerClient; import java.util.*; import java.util.function.Function; @@ -39,18 +38,16 @@ public class CompilationServiceImpl implements CompilationService { EventRepository eventRepository; CompilationRepository compilationRepository; - StatsFeignRepository statsFeignRepository; - UserFeignRepository userFeignRepository; RequestFeignRepository requestFeignRepository; + AnalyzerClient analyzerClient; + @Override public CompilationDto getById(Long compilationId, HttpServletRequest request) { - statsFeignRepository.sendHitRequest(request); - var compilation = getCompilationById(compilationId); var confirmedRequests = getConfirmedRequests(List.of(compilation)); - var views = getViews(List.of(compilation)); + var views = getRatingMap(List.of(compilation)); var events = compilation.getEvents(); var eventIdToInitiator = eventIdToInitiatorMap(events); @@ -80,13 +77,13 @@ public CompilationDto addCompilation(@NonNull NewCompilationDto compilationDto) Map confirmedRequests = new HashMap<>(); savedCompilation.getEvents().forEach(event -> confirmedRequests.put(event.getId(), 0L)); - Map views = new HashMap<>(); - savedCompilation.getEvents().forEach(event -> views.put(event.getId(), 0L)); + Map ratings = new HashMap<>(); + savedCompilation.getEvents().forEach(event -> ratings.put(event.getId(), 0.0)); var eventIdToInitiator = eventIdToInitiatorMap(events); return CompilationMapper.toCompilationDto( - savedCompilation, eventIdToInitiator, confirmedRequests, views); + savedCompilation, eventIdToInitiator, confirmedRequests, ratings); } @Override @@ -115,13 +112,13 @@ public CompilationDto updateCompilation(Long compilationId, } var confirmedRequests = getConfirmedRequests(List.of(compilationInDb)); - var views = getViews(List.of(compilationInDb)); + var ratings = getRatingMap(List.of(compilationInDb)); var compilation = getCompilationById(compilationId); var events = compilation.getEvents(); var eventIdToInitiator = eventIdToInitiatorMap(events); return CompilationMapper.toCompilationDto( - compilationInDb, eventIdToInitiator, confirmedRequests, views); + compilationInDb, eventIdToInitiator, confirmedRequests, ratings); } @NonNull @@ -157,7 +154,7 @@ public List getByFilter(@NonNull CompilationSearchFilter filter, } Map allConfirmedRequests = getConfirmedRequests(compilations); - Map allViews = getViews(compilations); + Map allRatings = getRatingMap(compilations); var result = new ArrayList(); for (Compilation compilation : compilations) { @@ -166,7 +163,7 @@ public List getByFilter(@NonNull CompilationSearchFilter filter, var eventIdToInitiator = eventIdToInitiatorMap(events); result.add(CompilationMapper.toCompilationDto( - compilation, eventIdToInitiator, allConfirmedRequests, allViews)); + compilation, eventIdToInitiator, allConfirmedRequests, allRatings)); } return result; @@ -200,36 +197,28 @@ private Map getConfirmedRequests(@NonNull Collection co ); } - /// Map + /// Map @NonNull - private Map getViews(@NonNull Collection compilations) { + private Map getRatingMap(@NonNull Collection compilations) { // Все уникальные события - List allEvents = compilations.stream() + List events = compilations.stream() .flatMap(c -> c.getEvents().stream()) .distinct() .toList(); - if (allEvents.isEmpty()) return Collections.emptyMap(); + if (events.isEmpty()) return Collections.emptyMap(); - // Все URIs - List uris = allEvents.stream().map(e -> "/events/" + e.getId()).toList(); + return getRatingMap(events.stream().map(Event::getId).toList()); + } - var statsOptional = statsFeignRepository.getStatList( - StatsRequestData.builder() - .uris(uris) - .unique(false) - .build() - ); + private Map getRatingMap(@NonNull List eventIds) { + if (eventIds.isEmpty()) { + return Collections.emptyMap(); + } - // Map - return statsOptional.map(viewStatsDtos -> viewStatsDtos.stream() - .collect(Collectors.toMap(statsDto -> - Long.parseLong(statsDto.getUri().replace("/events/", "")), - ViewStatsDto::getHits, - (a, b) -> a - )) - ).orElse(Collections.emptyMap()); + return analyzerClient.getInteractionsCount(eventIds).stream() + .collect(Collectors.toMap(ActionsWeightsSum::eventId, ActionsWeightsSum::score)); } } diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventService.java b/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventService.java index eac622a..12f9c2e 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventService.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventService.java @@ -49,5 +49,7 @@ public interface EventService { @Transactional EventFullDto patchEvent(Long userId, Long eventId, UpdateEventUserRequest request); + List getRecommendationsForUser(long userId, int maxResults); + long getInitiatorIfPublished(@Positive Long eventId); } diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventServiceImpl.java b/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventServiceImpl.java index 0f1a1e5..9d657d3 100644 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventServiceImpl.java +++ b/core/event-service/src/main/java/ru/practicum/ewm/events/service/event/EventServiceImpl.java @@ -27,10 +27,8 @@ import ru.practicum.aggregation.error.exception.bussines.cause.NotFoundException; import ru.practicum.aggregation.model.data.AdminGetData; import ru.practicum.aggregation.model.data.FreeGetData; -import ru.practicum.aggregation.model.data.StatsRequestData; import ru.practicum.aggregation.model.repository.EventRequestCount; import ru.practicum.aggregation.repository.RequestFeignRepositoryImpl; -import ru.practicum.aggregation.repository.StatsFeignRepository; import ru.practicum.aggregation.repository.UserFeignRepositoryImpl; import ru.practicum.ewm.events.entity.Category; import ru.practicum.ewm.events.entity.Event; @@ -42,7 +40,9 @@ import ru.practicum.ewm.events.repository.EventRepository; import ru.practicum.ewm.events.specification.EventSpecifications; import ru.practicum.ewm.events.specification.SpecBuilder; -import ru.practicum.stat.dto.ViewStatsDto; +import ru.practicum.stats.client.dto.ActionsWeightsSum; +import ru.practicum.stats.client.dto.PredictedScore; +import ru.practicum.stats.client.grpc.AnalyzerClient; import java.time.Instant; import java.time.LocalDateTime; @@ -50,7 +50,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; @Slf4j @@ -59,14 +58,14 @@ @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class EventServiceImpl implements EventService { - StatsFeignRepository statsFeignRepository; EventRepository eventRepository; CategoryRepository categoryRepository; UserFeignRepositoryImpl userFeignRepository; RequestFeignRepositoryImpl requestFeignRepository; - private static final String EVENTS_PATH = "/events/"; + AnalyzerClient analyzerClient; + private static final int HOURS_BEFORE_START = 2; @Override @@ -109,7 +108,7 @@ public List getFreeEvents(@NonNull FreeGetData dto, HttpServletRe if (dto.sort() != null) { switch (dto.sort()) { case EVENT_DATE -> sort = Sort.by("eventDate").ascending(); - case VIEWS -> sort = Sort.by("views").descending(); + case VIEWS -> sort = Sort.by("rating").descending(); } } @@ -122,33 +121,19 @@ public List getFreeEvents(@NonNull FreeGetData dto, HttpServletRe var confirmedRequestsCounts = fetchConfirmedRequestsCount( events.stream().map(Event::getId).toList()); - - Map eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); - - var statsData = StatsRequestData.builder() - .uris(getUris(events)) - .start(dto.rangeStart()) - .end(dto.rangeEnd()) - .unique(false) - .build(); - - var statsOptional = statsFeignRepository.getStatList(statsData); - - Map userIdToInitiator = getUserShortDtoMap(events); + var eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); + var eventIdToRating = getRatingMap(events.stream().map(Event::getId).toList()); + var userIdToInitiator = getUserShortDtoMap(events); return events.stream() .map(event -> { var userId = event.getInitiatorId(); var confirmets = eventIdToRequestCount.getOrDefault(userId, 0L); - AtomicLong views = new AtomicLong(); - statsOptional.ifPresentOrElse(stats -> - views.set(getViewsFromStatsList(stats, event.getId())), - () -> views.set(1L) - ); + double rating = eventIdToRating.getOrDefault(event.getId(), 0.0); var eventData = EventData.builder() .initiator(userIdToInitiator.get(userId)) .confirmedRequests(confirmets) - .views(views.get()) + .rating(rating) .build(); return EventMapper.toEventShortDto(event, eventData); }) @@ -161,27 +146,13 @@ public EventFullDto getFreeEventById(Long eventId, @NonNull HttpServletRequest r var event = getEventById(eventId); var initiator = UserMapper.toUserShortDto(getUserById(event.getInitiatorId())); - var uris = List.of(request.getRequestURI()); - var statsOptional = statsFeignRepository.getStatList( - StatsRequestData.builder() - .uris(uris) - .unique(true) - .build() - ); - - long views = statsOptional.map(viewStatsDtos -> viewStatsDtos.stream() - .findFirst() - .map(ViewStatsDto::getHits) - .orElse(0L) - ) - .orElse(1L); - + var rating = getRating(eventId); var confirmedRequestsCount = getConfirmedRequestsCountByEventId(eventId); var eventData = EventData.builder() .initiator(initiator) .confirmedRequests(confirmedRequestsCount) - .views(views) + .rating(rating) .build(); return EventMapper.toEventFullDto(event, eventData); @@ -218,7 +189,7 @@ public EventFullDto userAddNewEvent(Long userId, @NonNull NewEventDto newEventDt var eventData = EventData.builder() .initiator(initiator) .confirmedRequests(0) - .views(0) + .rating(0) .build(); return EventMapper.toEventFullDto(event, eventData); @@ -252,16 +223,9 @@ public List adminGetEvents(@NonNull AdminGetData dto) { List confirmedRequestsCounts = fetchConfirmedRequestsCount( events.stream().map(Event::getId).toList()); - Map eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); + var eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); - var statsData = StatsRequestData.builder() - .uris(getUris(events)) - .start(dto.rangeStart()) - .end(dto.rangeEnd()) - .unique(false) - .build(); - - var statsOptional = statsFeignRepository.getStatList(statsData); + var eventIdToRating = getRatingMap(events.stream().map(Event::getId).toList()); Map initiators = getUserShortDtoMap(events); @@ -269,15 +233,11 @@ public List adminGetEvents(@NonNull AdminGetData dto) { .map(event -> { var userId = event.getInitiatorId(); var count = getConfirmedRequestsCountFromMap(eventIdToRequestCount, event.getId()); - AtomicLong views = new AtomicLong(); - statsOptional.ifPresentOrElse(stats -> - views.set(getViewsFromStatsList(stats, event.getId())), - () -> views.set(1L) - ); + double rating = eventIdToRating.getOrDefault(event.getId(), 0.0); var eventData = EventData.builder() .initiator(initiators.get(userId)) .confirmedRequests(count) - .views(views.get()) + .rating(rating) .build(); return EventMapper.toEventFullDto(event, eventData); }) @@ -358,30 +318,20 @@ public List findByUserId(Long userId, Integer from, Integer size) List confirmedRequestsCounts = fetchConfirmedRequestsCount( events.stream().map(Event::getId).toList()); - Map eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); + var eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); - List uris = getUris(events); - var statsOptional = statsFeignRepository.getStatList( - StatsRequestData.builder() - .uris(uris) - .unique(true) - .build() - ); + var eventIdToRating = getRatingMap(events.stream().map(Event::getId).toList()); Map initiators = getUserShortDtoMap(events); return events.stream() .map(event -> { var count = getConfirmedRequestsCountFromMap(eventIdToRequestCount, event.getId()); - AtomicLong views = new AtomicLong(); - statsOptional.ifPresentOrElse(stats -> - views.set(getViewsFromStatsList(stats, event.getId())), - () -> views.set(1L) - ); + double rating = eventIdToRating.getOrDefault(event.getId(), 0.0); var eventData = EventData.builder() .initiator(initiators.get(userId)) .confirmedRequests(count) - .views(views.get()) + .rating(rating) .build(); return EventMapper.toEventShortDto(event, eventData); }) @@ -408,7 +358,7 @@ public EventFullDto findEventById(long eventId, long confirmets) { var eventData = EventData.builder() .initiator(UserMapper.toUserShortDto(getUserById(event.getInitiatorId()))) .confirmedRequests(confirmets) - .views(getViews(event.getId())) + .rating(getRating(eventId)) .build(); return EventMapper.toEventFullDto(event, eventData); @@ -425,6 +375,42 @@ public EventFullDto patchEvent(Long userId, Long eventId, @NonNull UpdateEventUs return patchEvent(eventId, request, false); } + @Override + public List getRecommendationsForUser(long userId, int maxResults) { + + // Получаем предсказания + List predicteds = analyzerClient.getRecomendationsForUser(userId, maxResults); + if (predicteds.isEmpty()) { + return Collections.emptyList(); + } + + var eventIds = predicteds.stream() + .map(PredictedScore::eventId) + .toList(); + + var events = eventRepository.findAllById(eventIds); + + var confirmedRequestsCounts = fetchConfirmedRequestsCount( + events.stream().map(Event::getId).toList()); + var eventIdToRequestCount = getRequestCountMap(confirmedRequestsCounts); + var eventIdToRating = getRatingMap(events.stream().map(Event::getId).toList()); + var userIdToInitiator = getUserShortDtoMap(events); + + return events.stream() + .map(event -> { + var currentUserId = event.getInitiatorId(); + var confirmets = eventIdToRequestCount.getOrDefault(currentUserId, 0L); + double rating = eventIdToRating.getOrDefault(event.getId(), 0.0); + var eventData = EventData.builder() + .initiator(userIdToInitiator.get(currentUserId)) + .confirmedRequests(confirmets) + .rating(rating) + .build(); + return EventMapper.toEventShortDto(event, eventData); + }) + .toList(); + } + @Override public long getInitiatorIfPublished(Long eventId) { checkEvent(eventId); @@ -503,11 +489,6 @@ private List fetchConfirmedRequestsCount(List events) { return requestFeignRepository.getConfirmedRequestsCount(events); } - @NonNull - private List getUris(@NonNull List events) { - return events.stream().map(event -> EVENTS_PATH + event.getId()).toList(); - } - private Map getRequestCountMap(@NonNull List eventRequestCountList) { return eventRequestCountList.stream() .collect(Collectors.toMap(EventRequestCount::eventId, EventRequestCount::count, @@ -526,10 +507,18 @@ private EventData getEventData(long eventId, long initiatorId) { return EventData.builder() .initiator(UserMapper.toUserShortDto(getUserById(initiatorId))) .confirmedRequests(getConfirmedRequestsCountByEventId(eventId)) - .views(getViews(eventId)) + .rating(getRating(eventId)) .build(); } + private double getRating(long eventId) { + return analyzerClient.getInteractionsCount( + List.of(eventId)).stream() + .map(ActionsWeightsSum::score) + .findAny() + .orElse(0.0); + } + @NonNull private UserDto getUserById(long userId) { return userFeignRepository.getUserDtoById(userId); @@ -558,27 +547,6 @@ private long getConfirmedRequestsCountByEventId(long eventId) { return counts.getFirst().count(); } - private long getViews(long eventId) { - var uris = List.of(EVENTS_PATH + eventId); - var statsOptional = statsFeignRepository.getStatList( - StatsRequestData.builder() - .uris(uris) - .unique(true) - .build() - ); - - if (statsOptional.isEmpty()) { - return 1; - } - - List stats = statsOptional.get(); - - if (stats.isEmpty()) { - return 0; - } - return stats.getFirst().getHits(); - } - private long getConfirmedRequestsCountFromMap(@NonNull Map requestCountMap, long eventId) { @@ -591,12 +559,13 @@ private long getConfirmedRequestsCountFromMap(@NonNull return 0; } - private long getViewsFromStatsList(@NonNull List viewStatsMap, long eventId) { - return viewStatsMap.stream() - .filter(statsDto -> statsDto.getUri().equals(EVENTS_PATH + eventId)) - .map(ViewStatsDto::getHits) - .findAny() - .orElse(0L); + private Map getRatingMap(@NonNull List eventIds) { + if (eventIds.isEmpty()) { + return Collections.emptyMap(); + } + + return analyzerClient.getInteractionsCount(eventIds).stream() + .collect(Collectors.toMap(ActionsWeightsSum::eventId, ActionsWeightsSum::score)); } } diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/service/rating/EventRatingService.java b/core/event-service/src/main/java/ru/practicum/ewm/events/service/rating/EventRatingService.java deleted file mode 100644 index aecad37..0000000 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/service/rating/EventRatingService.java +++ /dev/null @@ -1,10 +0,0 @@ -package ru.practicum.ewm.events.service.rating; - -import org.springframework.transaction.annotation.Transactional; -import ru.practicum.aggregation.dto.rating.come.update.RatingUpdateRequest; - -@Transactional -public interface EventRatingService { - - void updateRating(RatingUpdateRequest request); -} diff --git a/core/event-service/src/main/java/ru/practicum/ewm/events/service/rating/EventRatingServiceImpl.java b/core/event-service/src/main/java/ru/practicum/ewm/events/service/rating/EventRatingServiceImpl.java deleted file mode 100644 index a31ca86..0000000 --- a/core/event-service/src/main/java/ru/practicum/ewm/events/service/rating/EventRatingServiceImpl.java +++ /dev/null @@ -1,27 +0,0 @@ -package ru.practicum.ewm.events.service.rating; - -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Service; -import ru.practicum.aggregation.dto.rating.come.update.RatingUpdateRequest; -import ru.practicum.aggregation.error.exception.bussines.cause.NotFoundException; -import ru.practicum.ewm.events.entity.Event; -import ru.practicum.ewm.events.repository.EventRepository; - -@Service -@RequiredArgsConstructor -@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class EventRatingServiceImpl implements EventRatingService { - - EventRepository eventRepository; - - @Override - public void updateRating(@NonNull RatingUpdateRequest request) { - Event event = eventRepository.findById(request.eventId()).orElseThrow(() -> - new NotFoundException("Событие с id=%s не найдено".formatted(request.eventId()))); - event.setRate(request.rate()); - eventRepository.save(event); - } -} diff --git a/core/event-service/src/main/resources/application-test.yaml.bak b/core/event-service/src/main/resources/application-test.yaml.bak deleted file mode 100644 index b8c881d..0000000 --- a/core/event-service/src/main/resources/application-test.yaml.bak +++ /dev/null @@ -1,10 +0,0 @@ -spring: - jpa: - hibernate: - ddl-auto: none - sql: - init: - schema-locations: classpath:schema-h2.sql - datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:ewm-main diff --git a/core/event-service/src/main/resources/schema-h2.sql.bak b/core/event-service/src/main/resources/schema-h2.sql.bak deleted file mode 100644 index 06d81b3..0000000 --- a/core/event-service/src/main/resources/schema-h2.sql.bak +++ /dev/null @@ -1,93 +0,0 @@ --- generated by gpt -CREATE TABLE IF NOT EXISTS users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (email); - -CREATE TABLE IF NOT EXISTS categories ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(50) NOT NULL -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_name ON categories (name); - -CREATE TABLE IF NOT EXISTS events ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - annotation VARCHAR(2000) NOT NULL, - created_on TIMESTAMP NOT NULL, - description VARCHAR(7000) NOT NULL, - event_date TIMESTAMP NOT NULL, - location CLOB, - paid BOOLEAN NOT NULL, - participant_limit INT NOT NULL, - published_on TIMESTAMP, - request_moderation BOOLEAN NOT NULL, - state VARCHAR(255) NOT NULL, - title VARCHAR(120) NOT NULL, - category_id BIGINT, - initiator_id BIGINT, - rate BIGINT NOT NULL DEFAULT 0, - - CONSTRAINT fk_events_users - FOREIGN KEY (initiator_id) - REFERENCES users(id), - - CONSTRAINT fk_events_categories - FOREIGN KEY (category_id) - REFERENCES categories(id), - - CONSTRAINT chk_events_state - CHECK (state IN ('PENDING','PUBLISHED','CANCELED')) -); - -CREATE TABLE IF NOT EXISTS requests ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - created TIMESTAMP, - status VARCHAR(255), - - event_id BIGINT, - requester_id BIGINT, - - CONSTRAINT fk_requests_users - FOREIGN KEY (requester_id) - REFERENCES users(id), - - CONSTRAINT fk_requests_events - FOREIGN KEY (event_id) - REFERENCES events(id), - - CONSTRAINT chk_requests_status - CHECK (status IN ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELED')) -); - -CREATE TABLE IF NOT EXISTS compilations ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - pinned BOOLEAN NOT NULL, - title VARCHAR(50) NOT NULL -); - -CREATE TABLE IF NOT EXISTS compilation_events ( - compilations_id BIGINT NOT NULL, - events_id BIGINT NOT NULL, - - CONSTRAINT fk_events_compilations - FOREIGN KEY (compilations_id) - REFERENCES compilations(id) - ON DELETE CASCADE, - - CONSTRAINT fk_compilations_events - FOREIGN KEY (events_id) - REFERENCES events(id) - ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS ratings ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - event_id BIGINT NOT NULL, - reaction VARCHAR(10) NOT NULL CHECK (reaction IN ('LIKE', 'DISLIKE')), - CONSTRAINT uq_ratings_user_event UNIQUE (user_id, event_id), - CONSTRAINT fk_ratings_users FOREIGN KEY (user_id) REFERENCES users(id), - CONSTRAINT fk_ratings_events FOREIGN KEY (event_id) REFERENCES events(id) -); \ No newline at end of file diff --git a/core/event-service/src/main/resources/schema-postgres.sql b/core/event-service/src/main/resources/schema-postgres.sql index 1345dd0..cbbce70 100644 --- a/core/event-service/src/main/resources/schema-postgres.sql +++ b/core/event-service/src/main/resources/schema-postgres.sql @@ -20,7 +20,6 @@ CREATE TABLE IF NOT EXISTS events ( title varchar(120) NOT NULL, category_id bigint, initiator_id bigint, - rate bigint NOT NULL DEFAULT 0, CONSTRAINT pk_events PRIMARY KEY (id), CONSTRAINT fk_events_categories FOREIGN KEY (category_id) REFERENCES categories (id) MATCH SIMPLE diff --git a/core/event-service/src/main/resources/schema-postgres.sql.bak b/core/event-service/src/main/resources/schema-postgres.sql.bak deleted file mode 100644 index eafdc8b..0000000 --- a/core/event-service/src/main/resources/schema-postgres.sql.bak +++ /dev/null @@ -1,93 +0,0 @@ -CREATE TABLE IF NOT EXISTS public.users ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - email varchar(255) NOT NULL, - name varchar(255) NOT NULL, - CONSTRAINT pk_users PRIMARY KEY (id) -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (email); - -CREATE TABLE IF NOT EXISTS public.categories ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - name varchar(50) NOT NULL, - CONSTRAINT pk_categories PRIMARY KEY (id) -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_name ON categories (name); - -CREATE TABLE IF NOT EXISTS public.events ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - annotation varchar(2000) NOT NULL, - created_on timestamp(3) without time zone NOT NULL, - description varchar(7000) NOT NULL, - event_date timestamp(3) without time zone NOT NULL, - location jsonb, - paid boolean NOT NULL, - participant_limit integer NOT NULL, - published_on timestamp(3) without time zone, - request_moderation boolean NOT NULL, - state varchar(255) check (state in ('PENDING','PUBLISHED','CANCELED')) NOT NULL, - title varchar(120) NOT NULL, - category_id bigint, - initiator_id bigint, - rate bigint NOT NULL DEFAULT 0, - CONSTRAINT pk_events PRIMARY KEY (id), - CONSTRAINT fk_events_users FOREIGN KEY (initiator_id) - REFERENCES public.users (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_events_categories FOREIGN KEY (category_id) - REFERENCES public.categories (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION -); - -CREATE TABLE IF NOT EXISTS public.requests ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - created timestamp(3) without time zone, - status varchar(255) check (status in ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELED')), - event_id bigint, - requester_id bigint, - CONSTRAINT pk_requests PRIMARY KEY (id), - CONSTRAINT fk_requests_users FOREIGN KEY (requester_id) - REFERENCES public.users (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_requests_events FOREIGN KEY (event_id) - REFERENCES public.events (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION -); - -CREATE TABLE IF NOT EXISTS public.compilations ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - pinned boolean NOT NULL, - title varchar(50) NOT NULL, - CONSTRAINT pk_compilations PRIMARY KEY (id) -); - -CREATE TABLE IF NOT EXISTS public.compilation_events ( - compilations_id bigint NOT NULL, - events_id bigint NOT NULL, - CONSTRAINT fk_events_compilations FOREIGN KEY (compilations_id) - REFERENCES public.compilations (id) MATCH SIMPLE - ON DELETE CASCADE, - CONSTRAINT fk_compilations_events FOREIGN KEY (events_id) - REFERENCES public.events (id) MATCH SIMPLE - ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS public.ratings ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - user_id bigint NOT NULL, - event_id bigint NOT NULL, - reaction varchar(10) NOT NULL CHECK (reaction IN ('LIKE', 'DISLIKE')), - CONSTRAINT pk_ratings PRIMARY KEY (id), - CONSTRAINT uq_ratings_user_event UNIQUE (user_id, event_id), - CONSTRAINT fk_ratings_users FOREIGN KEY (user_id) - REFERENCES public.users (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION, - CONSTRAINT fk_ratings_events FOREIGN KEY (event_id) - REFERENCES public.events (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE NO ACTION -); diff --git a/core/pom.xml b/core/pom.xml index c91243d..2584ac9 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -18,7 +18,6 @@ event-service request-service user-service - rating-service diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/RatingServiceApp.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/RatingServiceApp.java deleted file mode 100644 index 64ea6eb..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/RatingServiceApp.java +++ /dev/null @@ -1,33 +0,0 @@ -package ru.practicum.ewm.ratings; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.cloud.openfeign.EnableFeignClients; -import org.springframework.context.annotation.ComponentScan; -import ru.practicum.aggregation.client.event.EventClient; -import ru.practicum.aggregation.client.request.RequestClient; -import ru.practicum.aggregation.client.stats.StatsClient; -import ru.practicum.aggregation.client.user.UserClient; -import ru.practicum.aggregation.config.JacksonConfiguration; - -@SpringBootApplication -@EnableFeignClients(clients = { - UserClient.class, - EventClient.class, - RequestClient.class, - StatsClient.class -}) -@ComponentScan(value = { - "ru.practicum.ewm.ratings", - "ru.practicum.aggregation.config", - "ru.practicum.aggregation.client", - "ru.practicum.aggregation.error", - "ru.practicum.aggregation.repository" -}) -@ConfigurationPropertiesScan(basePackageClasses = JacksonConfiguration.class) -public class RatingServiceApp { - public static void main(String[] args) { - SpringApplication.run(RatingServiceApp.class, args); - } -} diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/controller/UserRatingController.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/controller/UserRatingController.java deleted file mode 100644 index 95a662e..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/controller/UserRatingController.java +++ /dev/null @@ -1,50 +0,0 @@ -package ru.practicum.ewm.ratings.controller; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; -import ru.practicum.aggregation.dto.rating.come.create.RatingCreateRequest; -import ru.practicum.aggregation.dto.rating.output.RatingResponse; -import ru.practicum.ewm.ratings.service.RatingService; - -@Slf4j -@RestController -@RequestMapping("/users/{userId}/events/{eventId}/likes") -@RequiredArgsConstructor -public class UserRatingController { - - private final RatingService ratingService; - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public RatingResponse addReaction(@PathVariable Long userId, - @PathVariable Long eventId, - @Valid @RequestBody RatingCreateRequest createRequest, - @NonNull HttpServletRequest request) { - - log.info(""" - ENDPOINT - Добавление новой реакции - {} {}""", request.getMethod(), request.getRequestURI()); - - return ratingService.addOrUpdateReaction(userId, eventId, createRequest); - } - - @DeleteMapping - @ResponseStatus(HttpStatus.NO_CONTENT) - public void removeReaction(@PathVariable Long userId, - @PathVariable Long eventId, - @NonNull HttpServletRequest request) { - log.info(""" - ENDPOINT - Удаление реакции - {} {}""", request.getMethod(), request.getRequestURI()); - - ratingService.removeReaction(userId, eventId); - } - -} \ No newline at end of file diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/entity/Rating.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/entity/Rating.java deleted file mode 100644 index ad15357..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/entity/Rating.java +++ /dev/null @@ -1,33 +0,0 @@ -package ru.practicum.ewm.ratings.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.experimental.FieldDefaults; -import lombok.experimental.SuperBuilder; -import ru.practicum.aggregation.enums.Reaction; -import ru.practicum.aggregation.model.entity.BaseEntity; - -@Getter -@Setter -@Entity -@Table(name = "ratings", uniqueConstraints = { - @UniqueConstraint(columnNames = {"user_id", "event_id"}) -}) -@SuperBuilder -@NoArgsConstructor -@FieldDefaults(level = AccessLevel.PRIVATE) -public class Rating extends BaseEntity { - - @JoinColumn(name = "user_id", nullable = false) - Long userId; - - @JoinColumn(name = "event_id", nullable = false) - Long eventId; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - Reaction reaction; -} diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/mapper/RatingMapper.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/mapper/RatingMapper.java deleted file mode 100644 index fc293e9..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/mapper/RatingMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package ru.practicum.ewm.ratings.mapper; - -import lombok.NonNull; -import lombok.experimental.UtilityClass; -import ru.practicum.aggregation.dto.rating.output.RatingResponse; -import ru.practicum.ewm.ratings.entity.Rating; - -@UtilityClass -public class RatingMapper { - - public RatingResponse mapToResponse(@NonNull Rating rating) { - return RatingResponse.builder() - .id(rating.getId()) - .userId(rating.getUserId()) - .eventId(rating.getEventId()) - .reaction(rating.getReaction()) - .build(); - } -} diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/repository/RatingRepository.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/repository/RatingRepository.java deleted file mode 100644 index 91753f2..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/repository/RatingRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package ru.practicum.ewm.ratings.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import ru.practicum.aggregation.enums.Reaction; -import ru.practicum.ewm.ratings.entity.Rating; - -import java.util.Optional; - -public interface RatingRepository extends JpaRepository { - - Optional findByUserIdAndEventId(Long userId, Long eventId); - - long countByEventIdAndReaction(Long eventId, Reaction reaction); -} diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/service/RatingService.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/service/RatingService.java deleted file mode 100644 index 2266e02..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/service/RatingService.java +++ /dev/null @@ -1,13 +0,0 @@ -package ru.practicum.ewm.ratings.service; - -import org.springframework.transaction.annotation.Transactional; -import ru.practicum.aggregation.dto.rating.come.create.RatingCreateRequest; -import ru.practicum.aggregation.dto.rating.output.RatingResponse; - -@Transactional -public interface RatingService { - - RatingResponse addOrUpdateReaction(long userId, long eventId, RatingCreateRequest request); - - void removeReaction(long userId, long eventId); -} diff --git a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/service/RatingServiceImpl.java b/core/rating-service/src/main/java/ru/practicum/ewm/ratings/service/RatingServiceImpl.java deleted file mode 100644 index 7bd9d23..0000000 --- a/core/rating-service/src/main/java/ru/practicum/ewm/ratings/service/RatingServiceImpl.java +++ /dev/null @@ -1,101 +0,0 @@ -package ru.practicum.ewm.ratings.service; - -import jakarta.validation.ValidationException; -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Service; -import ru.practicum.aggregation.dto.rating.come.create.RatingCreateRequest; -import ru.practicum.aggregation.dto.rating.come.update.RatingUpdateRequest; -import ru.practicum.aggregation.dto.rating.output.RatingResponse; -import ru.practicum.aggregation.enums.Reaction; -import ru.practicum.aggregation.error.exception.bussines.cause.ConflictException; -import ru.practicum.aggregation.error.exception.bussines.cause.NotFoundException; -import ru.practicum.aggregation.repository.EventFeignRepository; -import ru.practicum.ewm.ratings.entity.Rating; -import ru.practicum.ewm.ratings.mapper.RatingMapper; -import ru.practicum.ewm.ratings.repository.RatingRepository; - -import java.util.Objects; - -@Service -@RequiredArgsConstructor -@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class RatingServiceImpl implements RatingService { - - RatingRepository ratingRepository; - - EventFeignRepository eventFeignRepository; - - @Override - public RatingResponse addOrUpdateReaction(long userId, - long eventId, - @NonNull RatingCreateRequest request) { - - var initiatorId = eventFeignRepository.getInitiatorIfPublished(eventId); - - if (Objects.equals(userId, initiatorId)) { - throw new ValidationException("Нельзя ставить реакции своим событиям"); - } - - var rating = ratingRepository - .findByUserIdAndEventId(userId, eventId) - .orElse(null); - var requestReaction = request.reaction(); - - if (rating != null) { - if (rating.getReaction() == requestReaction) { - ratingRepository.delete(rating); - updateEventRate(eventId); - throw new ConflictException(""" - Реакция пользователя с id=%s событию с id=%s, удалена""" - .formatted(userId, eventId)); - } else { - rating.setReaction(requestReaction); - ratingRepository.save(rating); - updateEventRate(eventId); - return RatingMapper.mapToResponse(rating); - } - } else { - rating = Rating.builder() - .userId(userId) - .eventId(eventId) - .reaction(requestReaction) - .build(); - ratingRepository.save(rating); - updateEventRate(eventId); - return RatingMapper.mapToResponse(rating); - } - } - - @Override - public void removeReaction(long userId, long eventId) { - ratingRepository.findByUserIdAndEventId(userId, eventId) - .ifPresentOrElse(ratingRepository::delete, - () -> { - updateEventRate(eventId); - throw new NotFoundException( - "Реакция пользователя с id=%s событию с id=%s, не найдена" - .formatted(userId, eventId) - ); - } - ); - - updateEventRate(eventId); - } - - private void updateEventRate(long eventId) { - long likes = ratingRepository.countByEventIdAndReaction(eventId, Reaction.LIKE); - long dislikes = ratingRepository.countByEventIdAndReaction(eventId, Reaction.DISLIKE); - long rate = likes - dislikes; - - var request = RatingUpdateRequest.builder() - .eventId(eventId) - .rate(rate) - .build(); - - eventFeignRepository.updateRating(request); - } - -} diff --git a/core/rating-service/src/main/resources/schema-postgres.sql b/core/rating-service/src/main/resources/schema-postgres.sql deleted file mode 100644 index 83fc95d..0000000 --- a/core/rating-service/src/main/resources/schema-postgres.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS ratings ( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, - user_id bigint NOT NULL, - event_id bigint NOT NULL, - reaction varchar(10) NOT NULL CHECK (reaction IN ('LIKE', 'DISLIKE')), - CONSTRAINT pk_ratings PRIMARY KEY (id), - CONSTRAINT uq_ratings_user_event UNIQUE (user_id, event_id) -); diff --git a/core/request-service/pom.xml b/core/request-service/pom.xml index 589db77..ffe50b7 100644 --- a/core/request-service/pom.xml +++ b/core/request-service/pom.xml @@ -18,6 +18,11 @@ aggregation-api 0.0.1-SNAPSHOT
+ + ru.practicum + stats-client + 0.0.1-SNAPSHOT + org.springframework.boot diff --git a/core/request-service/src/main/java/ru/practicum/ewm/requests/RequestServiceApp.java b/core/request-service/src/main/java/ru/practicum/ewm/requests/RequestServiceApp.java index f5824cf..a09584d 100644 --- a/core/request-service/src/main/java/ru/practicum/ewm/requests/RequestServiceApp.java +++ b/core/request-service/src/main/java/ru/practicum/ewm/requests/RequestServiceApp.java @@ -7,7 +7,6 @@ import org.springframework.context.annotation.ComponentScan; import ru.practicum.aggregation.client.event.EventClient; import ru.practicum.aggregation.client.request.RequestClient; -import ru.practicum.aggregation.client.stats.StatsClient; import ru.practicum.aggregation.client.user.UserClient; import ru.practicum.aggregation.config.JacksonConfiguration; @@ -15,15 +14,15 @@ @EnableFeignClients(clients = { UserClient.class, EventClient.class, - RequestClient.class, - StatsClient.class + RequestClient.class }) @ComponentScan(value = { "ru.practicum.ewm.requests", "ru.practicum.aggregation.config", "ru.practicum.aggregation.client", "ru.practicum.aggregation.error", - "ru.practicum.aggregation.repository" + "ru.practicum.aggregation.repository", + "ru.practicum.stats.client.grpc" }) @ConfigurationPropertiesScan(basePackageClasses = JacksonConfiguration.class) public class RequestServiceApp { diff --git a/core/request-service/src/main/java/ru/practicum/ewm/requests/controller/UserRequestController.java b/core/request-service/src/main/java/ru/practicum/ewm/requests/controller/UserRequestController.java index 0e237cf..f570497 100644 --- a/core/request-service/src/main/java/ru/practicum/ewm/requests/controller/UserRequestController.java +++ b/core/request-service/src/main/java/ru/practicum/ewm/requests/controller/UserRequestController.java @@ -2,23 +2,31 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.Positive; +import lombok.AccessLevel; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import ru.practicum.aggregation.dto.participation.output.ParticipationRequestDto; import ru.practicum.ewm.requests.service.RequestService; +import ru.practicum.stats.client.dto.ActionType; +import ru.practicum.stats.client.dto.UserActionDto; +import ru.practicum.stats.client.grpc.CollectorClient; +import java.time.LocalDateTime; import java.util.List; @Slf4j @RestController @RequestMapping(path = "/users/{userId}/requests") @RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class UserRequestController { - private final RequestService requestService; + RequestService requestService; + CollectorClient collectorClient; /** * В случае, если по заданным фильтрам не найдено ни одной заявки, возвращает пустой список @@ -58,7 +66,18 @@ public ParticipationRequestDto addParticipationRequest(@PathVariable @Positive L Добавление запроса от текущего пользователя на участие в событии {} {}""", request.getMethod(), request.getRequestURI()); - return requestService.addParticipationRequest(userId, eventId); + var result = requestService.addParticipationRequest(userId, eventId); + + log.info("Регистрация на участие в событии {} пользователеля {}", eventId, userId); + collectorClient.collectUserAction( + UserActionDto.builder() + .userId(userId) + .eventId(eventId) + .actionType(ActionType.REGISTER) + .timestamp(LocalDateTime.now()) + .build()); + + return result; } @PatchMapping("/{requestId}/cancel") diff --git a/core/user-service/src/main/java/ru/practicum/ewm/users/UserServiceApp.java b/core/user-service/src/main/java/ru/practicum/ewm/users/UserServiceApp.java index 06801fa..7c7655a 100644 --- a/core/user-service/src/main/java/ru/practicum/ewm/users/UserServiceApp.java +++ b/core/user-service/src/main/java/ru/practicum/ewm/users/UserServiceApp.java @@ -7,7 +7,6 @@ import org.springframework.context.annotation.ComponentScan; import ru.practicum.aggregation.client.event.EventClient; import ru.practicum.aggregation.client.request.RequestClient; -import ru.practicum.aggregation.client.stats.StatsClient; import ru.practicum.aggregation.client.user.UserClient; import ru.practicum.aggregation.config.JacksonConfiguration; @@ -15,8 +14,7 @@ @EnableFeignClients(clients = { UserClient.class, EventClient.class, - RequestClient.class, - StatsClient.class + RequestClient.class }) @ComponentScan(value = { "ru.practicum.ewm.users", diff --git a/docker-compose.yml b/docker-compose.yml index 4a7e415..a0b5638 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,10 @@ volumes: postgres_main-db_data: postgres_stats-db_data: + kafka_data: + kafka_secrets: + kafka_init_data: + kafka_init_secrets: services: main-db: @@ -13,7 +17,7 @@ services: - POSTGRES_USER=dbuser - POSTGRES_PASSWORD=12345 volumes: - - ./docker-init.sql:/docker-entrypoint-initdb.d/docker-init.sql:ro + - ./docker-init-core.sql:/docker-entrypoint-initdb.d/docker-init-core.sql:ro - postgres_main-db_data:/var/lib/postgresql/data healthcheck: test: [ "CMD-SHELL", "pg_isready -U dbuser -d ewm-main" ] @@ -31,9 +35,43 @@ services: - POSTGRES_USER=dbuser - POSTGRES_PASSWORD=12345 volumes: + - ./docker-init-stats.sql:/docker-entrypoint-initdb.d/docker-init-stats.sql:ro - postgres_stats-db_data:/var/lib/postgresql/data healthcheck: test: pg_isready -q -d $$POSTGRES_DB -U $$POSTGRES_USER timeout: 5s interval: 5s retries: 10 + + kafka: + image: confluentinc/confluent-local:7.4.3 + hostname: kafka + container_name: kafka + volumes: + - kafka_data:/var/lib/kafka/data + - kafka_secrets:/etc/kafka/secrets + ports: + - "9092:9092" # for client connections + - "9101:9101" # JMX + restart: unless-stopped + environment: + KAFKA_NODE_ID: 1 + KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092' + KAFKA_JMX_PORT: 9101 + KAFKA_JMX_HOSTNAME: localhost + KAFKA_PROCESS_ROLES: 'broker,controller' + KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093' + KAFKA_LISTENERS: 'PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092' + CLUSTER_ID: 'K0EA9p0yEe6MkAAAAkKsEg' + + kafka-init-topics: + image: confluentinc/confluent-local:7.4.3 + container_name: kafka-init-topics + volumes: + - kafka_init_data:/var/lib/kafka/data + - kafka_init_secrets:/etc/kafka/secrets + - ./docker-init-topics.sh:/docker-init-topics.sh:ro + depends_on: + - kafka + command: ["bash", "/docker-init-topics.sh"] + init: true diff --git a/docker-init.sql b/docker-init-core.sql similarity index 100% rename from docker-init.sql rename to docker-init-core.sql diff --git a/docker-init-stats.sql b/docker-init-stats.sql new file mode 100644 index 0000000..3d62ad3 --- /dev/null +++ b/docker-init-stats.sql @@ -0,0 +1,6 @@ +CREATE DATABASE "ewm-analyzer"; + +\c "ewm-analyzer" +CREATE SCHEMA IF NOT EXISTS analyzer; +GRANT ALL PRIVILEGES ON SCHEMA analyzer TO dbuser; + diff --git a/docker-init-topics.sh b/docker-init-topics.sh new file mode 100644 index 0000000..935f554 --- /dev/null +++ b/docker-init-topics.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +while \ + ! kafka-topics --list --bootstrap-server kafka:29092 > /dev/null 2>&1; + do + sleep 2; +done + +kafka-topics --create --topic stats.user-actions.v1 \ + --partitions 1 --replication-factor 1 --if-not-exists \ + --bootstrap-server kafka:29092 && \ + +kafka-topics --create --topic stats.events-similarity.v1 \ + --partitions 1 --replication-factor 1 --if-not-exists \ + --bootstrap-server kafka:29092 diff --git a/infra/config-repo/core/event-service.yml b/infra/config-repo/core/event-service.yml index d9ff588..980d5c8 100644 --- a/infra/config-repo/core/event-service.yml +++ b/infra/config-repo/core/event-service.yml @@ -47,3 +47,18 @@ resilience4j: configs: default: timeout-duration: 3s + +grpc: + server: + port: 0 + client: + analyzer: + address: 'discovery:///analyzer' + enableKeepAlive: true + keepAliveWithoutCalls: true + negotiationType: plaintext + collector: + address: 'discovery:///collector' + enableKeepAlive: true + keepAliveWithoutCalls: true + negotiationType: plaintext diff --git a/infra/config-repo/core/rating-service.yml b/infra/config-repo/core/rating-service.yml deleted file mode 100644 index d1bc18a..0000000 --- a/infra/config-repo/core/rating-service.yml +++ /dev/null @@ -1,50 +0,0 @@ -spring: - sql.init: - mode: ALWAYS - schema-locations: classpath:schema-postgres.sql - jackson: - serialization.INDENT_OUTPUT: true - datasource: - driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:6541/ewm-ratings?currentSchema=rating_service - username: dbuser - password: 12345 - jpa: - generate-ddl: false - hibernate.ddl-auto: validate - show-sql: false - properties: - hibernate: - format_sql: true - cloud: - openfeign: - okhttp: - enabled: true - client: - config: - default: - connectTimeout: 2000 - readTimeout: 3000 - loggerLevel: basic - circuitbreaker: - enabled: true - -resilience4j: - circuitbreaker: - configs: - default: - sliding-window-size: 5 - failure-rate-threshold: 50 - minimum-number-of-calls: 3 - slow-call-duration-threshold: 3s - slow-call-rate-threshold: 50 - wait-duration-in-open-state: 10s - permitted-number-of-calls-in-half-open-state: 3 - ignore-exceptions: - - ru.practicum.aggregation.error.exception.bussines.ServiceProcessingException - - jakarta.validation.ValidationException - timelimiter: - configs: - default: - timeout-duration: 3s - diff --git a/infra/config-repo/core/request-service.yml b/infra/config-repo/core/request-service.yml index 70d5efd..758d871 100644 --- a/infra/config-repo/core/request-service.yml +++ b/infra/config-repo/core/request-service.yml @@ -48,3 +48,12 @@ resilience4j: default: timeout-duration: 3s +grpc: + server: + port: 0 + client: + collector: + address: 'discovery:///collector' + enableKeepAlive: true + keepAliveWithoutCalls: true + negotiationType: plaintext diff --git a/infra/config-repo/stats/aggregator.yml b/infra/config-repo/stats/aggregator.yml new file mode 100644 index 0000000..c100fc9 --- /dev/null +++ b/infra/config-repo/stats/aggregator.yml @@ -0,0 +1,24 @@ +grpc: + server: + port: 0 + +kafka: + bootstrap-server: localhost:9092 + topics: + users-interaction: stats.user-actions.v1 + events-similarity: stats.events-similarity.v1 + + user-action-consumer-config: + "bootstrap.servers": ${kafka.bootstrap-server} + "group.id": "user-action.group" + "key.deserializer": "org.apache.kafka.common.serialization.LongDeserializer" + "value.deserializer": "ru.practicum.ewm.kafka.deserializer.UserActionsDeserializer" + "auto.offset.reset": "earliest" + "enable.auto.commit": false + "max.poll.records": 10 + "fetch.max.wait.ms": 1000 + + events-similarity-producer-config: + "bootstrap.servers": ${kafka.bootstrap-server} + "key.serializer": "org.apache.kafka.common.serialization.LongSerializer" + "value.serializer": "ru.practicum.ewm.kafka.serializer.GeneralAvroSerializer" diff --git a/infra/config-repo/stats/analyzer.yml b/infra/config-repo/stats/analyzer.yml new file mode 100644 index 0000000..7a92046 --- /dev/null +++ b/infra/config-repo/stats/analyzer.yml @@ -0,0 +1,51 @@ +grpc: + server: + port: 0 + +spring: + sql.init: + mode: ALWAYS + schema-locations: classpath:schema-postgres.sql + jackson: + serialization.INDENT_OUTPUT: true + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:6542/ewm-analyzer?currentSchema=analyzer + username: dbuser + password: 12345 + jpa: + generate-ddl: false + hibernate.ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: true + jdbc: + fetch_size: 100 + batch_size: 100 + +kafka: + bootstrap-server: localhost:9092 + topics: + users-interaction: stats.user-actions.v1 + events-similarity: stats.events-similarity.v1 + + users-interaction-consumer-config: + "bootstrap.servers": ${kafka.bootstrap-server} + "group.id": "users-interaction.group" + "key.deserializer": "org.apache.kafka.common.serialization.LongDeserializer" + "value.deserializer": "ru.practicum.ewm.kafka.deserializer.UserActionsDeserializer" + "auto.offset.reset": "earliest" + "enable.auto.commit": false + "max.poll.records": 10 + "fetch.max.wait.ms": 1000 + + events-similarity-consumer-config: + "bootstrap.servers": ${kafka.bootstrap-server} + "group.id": "events-similarity.group" + "key.deserializer": "org.apache.kafka.common.serialization.LongDeserializer" + "value.deserializer": "ru.practicum.ewm.kafka.deserializer.EventsSimilarityDeserializer" + "auto.offset.reset": "earliest" + "enable.auto.commit": false + "max.poll.records": 10 + "fetch.max.wait.ms": 1000 diff --git a/infra/config-repo/stats/collector.yml b/infra/config-repo/stats/collector.yml new file mode 100644 index 0000000..0efc46c --- /dev/null +++ b/infra/config-repo/stats/collector.yml @@ -0,0 +1,15 @@ +grpc: + server: + port: 0 + executor: + type: fixed + max-threads: 100 + core-pool-size: 100 + +kafka: + topics: + users-interaction: stats.user-actions.v1 + user-action-producer-config: + "bootstrap.servers": localhost:9092 + "key.serializer": "org.apache.kafka.common.serialization.LongSerializer" + "value.serializer": "ru.practicum.ewm.kafka.serializer.GeneralAvroSerializer" diff --git a/infra/config-repo/stats/stats-server.yml b/infra/config-repo/stats/stats-server.yml deleted file mode 100644 index 92bfb29..0000000 --- a/infra/config-repo/stats/stats-server.yml +++ /dev/null @@ -1,18 +0,0 @@ -spring: - sql.init: - mode: ALWAYS - schema-locations: classpath:schema-postgres.sql - jackson: - serialization.INDENT_OUTPUT: true - datasource: - driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:6542/ewm-stats - username: dbuser - password: 12345 - jpa: - generate-ddl: false - hibernate.ddl-auto: validate - show-sql: false - properties: - hibernate: - format_sql: true diff --git a/pom.xml b/pom.xml index 73091ef..86af283 100644 --- a/pom.xml +++ b/pom.xml @@ -12,9 +12,9 @@ Explore With Me - stats - infra - core + core + infra + stats ru.practicum @@ -24,10 +24,23 @@ 21 + 21 21 UTF-8 + + 2.6.0 + 2025.0.3 + + 1.63.0 + 3.1.0.RELEASE + + 3.9.1 + 1.12.0 + + 3.25.5 + 5.1.2 @@ -39,12 +52,65 @@ pom import + + + org.apache.avro + avro + ${avro.version} + + + org.apache.kafka + kafka-clients + ${kafka.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-starter.version} + + + + + net.devh + grpc-spring-boot-starter + ${grpc-spring-boot-starter.version} + + + net.devh + grpc-client-spring-boot-starter + ${grpc-spring-boot-starter.version} + + + net.devh + grpc-server-spring-boot-starter + ${grpc-spring-boot-starter.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} +
+ + org.apache.avro + avro-maven-plugin + ${avro.version} + + + io.github.ascopes + protobuf-maven-plugin + ${protobuf-plugin.version} + org.apache.maven.plugins maven-surefire-plugin diff --git a/readme.md b/readme.md index 11a93aa..d750076 100644 --- a/readme.md +++ b/readme.md @@ -2,13 +2,13 @@ ![Static Badge](https://img.shields.io/badge/Java-21-green) ![Static Badge](https://img.shields.io/badge/Spring_Boot-3.5.9-green) -![Static Badge](https://img.shields.io/badge/Lombok-red) -![Static Badge](https://img.shields.io/badge/JPA--specification-8A2BE2) +![Static Badge](https://img.shields.io/badge/Microservices-8A2BE2) ![Static Badge](https://img.shields.io/badge/SpringCloud-538681) +![Static Badge](https://img.shields.io/badge/Kafka-blue) +![Static Badge](https://img.shields.io/badge/JPA--specification-5d7cc2) ![Static Badge](https://img.shields.io/badge/PostgreSQL-16.1-blue) ![Static Badge](https://img.shields.io/badge/docker_compose-blue) ![Static Badge](https://img.shields.io/badge/Maven-orange) -![Static Badge](https://img.shields.io/badge/Microservices-8A2BE2) ## Бэкэнд для сервиса поиска мероприятий @@ -17,28 +17,197 @@ configuration files location: infra/config-repo ### Microservice map -![ms](.img/microservices.png) + +microservices.svg + +### Calculate similarities map + +similarity.png + +### Matrix example + +Матрица ведущих весов (leadingWeightMatrix) + +| | u1 | u3 | u5 | u7 | u8 | u10 | u11 | u12 | u13 | +|-----|------|------|------|------|------|------|------|------|------| +| e1 | | | 0.80 | | | | | | | +| e2 | | | | | | | | | 0.80 | +| e4 | 0.40 | 0.80 | | | | 0.40 | | | | +| e5 | 1.00 | | | | | | | | | +| e7 | | | | | 0.80 | | | | | +| e8 | | | | | | | | 0.40 | | +| e9 | | | | | | | 0.80 | | | +| e11 | | | | | | | | | 0.40 | +| e13 | | | | | | | | | 0.40 | +| e14 | | | | | 0.40 | | | | | +| e15 | | | | 0.80 | | | | | | +| e16 | | | | | | | | 0.80 | | +| e17 | | | | | | 0.80 | | | | +| e18 | | | | | | 0.80 | | 0.40 | | +| e20 | 0.80 | | | | | | | | | + +Матрица числителей (sumOfMinWeightsMatrix) + +| | e2 | e4 | e5 | e7 | e8 | e11 | e13 | e14 | e16 | e17 | e18 | e20 | +|-----|------|------|------|------|------|------|------|------|------|------|------|------| +| e2 | 0.00 | | | | | 0.40 | 0.40 | | | | | | +| e4 | | 0.00 | 0.40 | | | | | | | 0.40 | 0.40 | 0.40 | +| e5 | | 0.40 | 0.00 | | | | | | | | | 0.80 | +| e7 | | | | 0.00 | | | | 0.40 | | | | | +| e8 | | | | | 0.00 | | | | 0.40 | | 0.40 | | +| e11 | 0.40 | | | | | 0.00 | 0.40 | | | | | | +| e13 | 0.40 | | | | | 0.40 | 0.00 | | | | | | +| e14 | | | | 0.40 | | | | 0.00 | | | | | +| e16 | | | | | 0.40 | | | | 0.00 | | 0.40 | | +| e17 | | 0.40 | | | | | | | | 0.00 | 0.80 | | +| e18 | | 0.40 | | | 0.40 | | | | 0.40 | 0.80 | 0.00 | | +| e20 | | 0.40 | 0.80 | | | | | | | | | 0.00 | + +Таблица знаменателей (eventToLeadingWeightsSum) + +| | e1 | e2 | e4 | e5 | e7 | e8 | e9 | e11 | e13 | e14 | e15 | e16 | e17 | e18 | e20 | +|-------|------|------|------|------|------|------|------|------|------|------|------|------|------|------|------| +| Σ | 0.80 | 0.80 | 1.60 | 1.00 | 0.80 | 0.40 | 0.80 | 0.40 | 0.40 | 0.40 | 0.80 | 0.80 | 0.80 | 1.20 | 0.80 | ### External API -![external-api.png](.img/external-api.png) -![external-api-2.png](.img/external-api-2.png) +```mermaid +mindmap + root((API)) + admin + 🌐/categories + DELETE /admin/categories/:catId + PATCH /admin/categories/:catId + POST /admin/categories + 🌐/compilations + DELETE /admin/compilations/:compId + PATCH /admin/compilations/:compId + POST /admin/compilations + 🌐/events + GET /admin/events + PATCH /admin/events/:eventId + 🌐/users + DELETE /admin/users/:userId + GET /admin/users/:userId + GET /admin/users + POST /admin/users + user + 🌐/events + GET /users/:userId/events + GET /users/:userId/events/:eventId + GET /users/:userId/events/:eventId/requests + PATCH /users/:userId/events/:eventId + PATCH /users/:userId/events/:eventId/requests + POST /users/:userId/events + 🌐/requests + GET /users/:userId/requests + PATCH /users/:userId/requests/:requestId/cancel + POST /users/:userId/requests + 🌐/rating + PUT /events/:eventId/like + 🌐/recommendations + GET /events/recommendations + public + 🌐/categories + GET /categories + GET /categories/:catId + 🌐/compilations + GET /compilations + GET /compilations/:compId + 🌐/events + GET /events + GET /events/:eventId +``` ### Internal API -![internal-api.png](.img/internal-api.png) +```mermaid +mindmap + root((API)) + system + 🌐/events + GET /system/events/:eventId + GET /system/events/:eventId/initiator + 🌐/requests + GET /system/requests/count + GET /system/requests/users/:userId/events/:eventId + PATCH /system/requests/events/:eventId/status + 🌐/users + GET /system/users/:userId/check + GET /system/users/:userId + GET /system/users/short +``` ### Database map +```mermaid +erDiagram + EVENTS { + bigint id PK + varchar annotation + timestamp created_on + varchar description + timestamp event_date + jsonb location + boolean paid + integer participant_limit + timestamp published_on + boolean request_moderation + varchar state + varchar title + bigint category_id FK + bigint initiator_id FK + } + + CATEGORIES { + bigint id PK + varchar name + } + + COMPILATIONS { + bigint id PK + boolean pinned + varchar title + } + + COMPILATION_EVENTS { + bigint compilation_id FK + bigint events_id FK + } -ewm-main/event-service -![ewm-main-event-service.png](.img/ewm-main-event-service.png) + REQUESTS { + bigint id PK + timestamp created + varchar status + bigint event_id FK + bigint requester_id FK + } -ewm-main/rating-service -![ewm-main-rating-service.png](.img/ewm-main-rating-service.png) + USERS { + bigint id PK + varchar email + varchar name + } -ewm-main/request-service -![ewm-main-request-service.png](.img/ewm-main-request-service.png) + INTERACTIONS { + bigint id PK + bigint user_id FK + bigint event_id FK + numeric action_weight + } -ewm-main/user-service -![ewm-main-user-service.png](.img/ewm-main-user-service.png) + SIMILARITIES { + bigint id PK + bigint event_a FK + bigint event_b FK + numeric score + } -ewm-stats/stats-server -![ewm-stats-stats-server.png](.img/ewm-stats-stats-server.png) \ No newline at end of file + CATEGORIES ||--o{ EVENTS : classifies + USERS ||--o{ EVENTS : initiates + EVENTS ||--o{ COMPILATION_EVENTS : included_in + COMPILATIONS ||--o{ COMPILATION_EVENTS : contains + EVENTS ||--o{ REQUESTS : receives + USERS ||--o{ REQUESTS : creates + USERS ||--o{ INTERACTIONS : performs + EVENTS ||--o{ INTERACTIONS : tracked_in + EVENTS ||--o{ SIMILARITIES : compared_as_a + EVENTS ||--o{ SIMILARITIES : compared_as_b +``` \ No newline at end of file diff --git a/stats/aggregator/pom.xml b/stats/aggregator/pom.xml new file mode 100644 index 0000000..3cef4da --- /dev/null +++ b/stats/aggregator/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + + ru.practicum + stats + 0.0.1-SNAPSHOT + + + aggregator + + + + ru.practicum + avro-schemas + 0.0.1-SNAPSHOT + + + ru.practicum + proto-schemas + 0.0.1-SNAPSHOT + compile + + + ru.practicum + timestamp-utils + 0.0.1-SNAPSHOT + + + + net.devh + grpc-client-spring-boot-starter + + + net.devh + grpc-server-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + com.github.ben-manes.caffeine + caffeine + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/AggregatorApp.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/AggregatorApp.java new file mode 100644 index 0000000..e1daf47 --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/AggregatorApp.java @@ -0,0 +1,16 @@ +package ru.practicum.ewm.stats.aggregator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; + +@SpringBootApplication +public class AggregatorApp { + public static void main(String[] args) { + ConfigurableApplicationContext context = SpringApplication + .run(AggregatorApp.class, args); + + final AggregatorRunner runner = context.getBean(AggregatorRunner.class); + runner.run(args); + } +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/AggregatorRunner.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/AggregatorRunner.java new file mode 100644 index 0000000..2551dba --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/AggregatorRunner.java @@ -0,0 +1,30 @@ +package ru.practicum.ewm.stats.aggregator; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.aggregator.consumer.UserActionConsumer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AggregatorRunner implements CommandLineRunner { + + private final UserActionConsumer userActionConsumer; + + private int cycleNumber = 0; + + @Override + public void run(String... args) { + log.info("Цикл запуска №{}", ++cycleNumber); + + if (!userActionConsumer.isRunning()) { + log.info("Запускаем UserActionConsumer в текущем потоке"); + userActionConsumer.run(); + } else { + log.info("EventsSimilarityClient уже запущен"); + } + } + +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/cofig/AggregatorConfig.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/cofig/AggregatorConfig.java new file mode 100644 index 0000000..9664cdf --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/cofig/AggregatorConfig.java @@ -0,0 +1,42 @@ +package ru.practicum.ewm.stats.aggregator.cofig; + +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +import java.util.Properties; + +@Configuration +public class AggregatorConfig { + + @Bean + @ConfigurationProperties("kafka.user-action-consumer-config") + public Properties usersInteractionConsumerConfig() { + return new Properties(); + } + + @Bean + @ConfigurationProperties("kafka.events-similarity-producer-config") + public Properties eventsSimilarityProducerConfig() { + return new Properties(); + } + + @Bean(destroyMethod = "") + public KafkaConsumer usersInteractionKafkaConsumer( + @Qualifier("usersInteractionConsumerConfig") Properties config + ) { + return new KafkaConsumer<>(config); + } + + @Bean + public KafkaProducer eventsSimilarityKafkaProducer( + @Qualifier("eventsSimilarityProducerConfig") Properties config + ) { + return new KafkaProducer<>(config); + } +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/consumer/BaseKafkaProcessor.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/consumer/BaseKafkaProcessor.java new file mode 100644 index 0000000..d1af2e8 --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/consumer/BaseKafkaProcessor.java @@ -0,0 +1,129 @@ +package ru.practicum.ewm.stats.aggregator.consumer; + +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.errors.WakeupException; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +public abstract class BaseKafkaProcessor implements Runnable { + + final String topic; + + private final KafkaConsumer consumer; + + private final Map lastOffsets = new HashMap<>(); + + private static final Duration CONSUME_ATTEMPT_TIMEOUT = Duration.ofMillis(1000); + + @Getter + private volatile boolean running = false; + + public BaseKafkaProcessor(String topic, KafkaConsumer consumer) { + this.topic = topic; + this.consumer = consumer; + } + + @PreDestroy + public void shutdown() { + log.info("Получил сигнал остановки"); + running = false; + consumer.wakeup(); + } + + @Override + public void run() { + running = true; + + log.info("Подписка на топик: {}", topic); + consumer.subscribe(Collections.singleton(topic)); + + try { + pollLoop(); + + } catch (WakeupException ignored) { + log.info("Получил инструкцию wakeup"); + } catch (Exception e) { + log.error("Ошибка во время обработки событий от хаба", e); + } finally { + log.info("Остановка poll loop"); + running = false; + + try { + if (!lastOffsets.isEmpty()) { + log.info("Регистрация последнего смещения"); + consumer.commitSync(lastOffsets); + } + + } finally { + log.info("Остановка консьюмера"); + consumer.close(); + } + } + } + + private void pollLoop() { + int iteration = 1; + while (running) { + log.debug("Проверка источника событий хаба"); + ConsumerRecords records = consumer.poll(CONSUME_ATTEMPT_TIMEOUT); + + if (records.isEmpty()) { + log.debug("Источник событий хаба пуст"); + continue; + } + + log.info("Итерация {}. Батч из {} записей получен. Начинаем обработку.", + iteration++, records.count() + ); + + int recordNumber = 1; + for (ConsumerRecord record : records) { + + log.info(""" + Обработка действия пользователя №{}: + { + "topic": "{}", + "partition": "{}", + "offset": "{}", + "timestamp": "{}" + }""", + recordNumber++, + record.topic(), + record.partition(), + record.offset(), + TimestampUtils.toLocalDateTime(record.timestamp()) + ); + + try { + processRecord(record.value()); + + log.debug("Сохранение смещения локально"); + lastOffsets.put( + new TopicPartition(record.topic(), record.partition()), + new OffsetAndMetadata(record.offset() + 1) + ); + + } catch (Exception e) { + log.error("Ошибка обновления состояния:\n{}", record.value(), e); + throw e; + } + } + } + } + + protected abstract void processRecord(T record); +} \ No newline at end of file diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/consumer/UserActionConsumer.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/consumer/UserActionConsumer.java new file mode 100644 index 0000000..8229dc6 --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/consumer/UserActionConsumer.java @@ -0,0 +1,27 @@ +package ru.practicum.ewm.stats.aggregator.consumer; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.aggregator.service.SimilarityService; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +@Slf4j +@Component +public class UserActionConsumer extends BaseKafkaProcessor { + + private final SimilarityService service; + + public UserActionConsumer(@Value("${kafka.topics.users-interaction}") String topic, + KafkaConsumer consumer, + SimilarityService service) { + super(topic, consumer); + this.service = service; + } + + @Override + protected void processRecord(UserActionAvro record) { + service.collectSimilarity(record); + } +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/producer/EventsSimilarityProducer.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/producer/EventsSimilarityProducer.java new file mode 100644 index 0000000..0adb819 --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/producer/EventsSimilarityProducer.java @@ -0,0 +1,45 @@ +package ru.practicum.ewm.stats.aggregator.producer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; + +import javax.annotation.Nonnull; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EventsSimilarityProducer { + + @Value("${kafka.topics.events-similarity}") + private String topic; + + private final Producer producer; + + public CompletableFuture sendMessage( + Long key, + @Nonnull T similarity + ) { + ProducerRecord record = new ProducerRecord<>( + topic, null, key, similarity); + + CompletableFuture future = new CompletableFuture<>(); + + producer.send(record, (metadata, exception) -> { + if (exception != null) { + future.completeExceptionally(exception); + } else { + future.complete(metadata); + } + }); + + return future; + } + +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/service/SimilarityService.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/service/SimilarityService.java new file mode 100644 index 0000000..5e8a83c --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/service/SimilarityService.java @@ -0,0 +1,8 @@ +package ru.practicum.ewm.stats.aggregator.service; + +import ru.practicum.ewm.stats.avro.UserActionAvro; + +public interface SimilarityService { + + void collectSimilarity(UserActionAvro record); +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/service/SimilarityServiceImpl.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/service/SimilarityServiceImpl.java new file mode 100644 index 0000000..8ee4bed --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/service/SimilarityServiceImpl.java @@ -0,0 +1,425 @@ +package ru.practicum.ewm.stats.aggregator.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import ru.practicum.ewm.stats.aggregator.producer.EventsSimilarityProducer; +import ru.practicum.ewm.stats.aggregator.util.TableRenderer; +import ru.practicum.ewm.stats.avro.ActionTypeAvro; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; +import ru.practicum.ewm.stats.avro.UserActionAvro; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SimilarityServiceImpl implements SimilarityService { + + private final EventsSimilarityProducer producer; + + /** + * Матрица ведущих весов
+ * {@code Map>}
+ * где {@code maxActionWeight} - максимальный вес + * из всех действий этого пользователя по этому мероприятию + */ + Map> leadingWeightMatrix = new HashMap<>(); + + /** + * Матрица числителей
+ * {@code Map>}
+ * где {@code SumOfMinWeightsFromMaxActionWeights} - сумма минимальных весов + * по всем ведущим весам всех действий пользоваелей, для этой пары мероприятий + */ + Map> sumOfMinWeightsMatrix = new HashMap<>(); + + /** + * Таблица знаминателей
+ * {@code Map}
+ * где {@code leadingWeightsSum} - сумма ведущих весов всех + * действий пользователей по этому мероприятию + */ + Map eventToLeadingWeightsSum = new HashMap<>(); + + /// Посчитать сходство и отправить в кафку + @Override + public void collectSimilarity(@NonNull UserActionAvro avro) { + + var userId = avro.getUserId(); + var eventId = avro.getEventId(); + var actionType = avro.getActionType(); + + // Получаем ведущий вес + double newLeadingWeight = calculateLeadingWeight(actionType); + + // Проходим верификацию + if (!verifyData(userId, eventId, newLeadingWeight)) { + printMatrix(); + return; + } + + // Считаем сходство + var similarities = calculateSimilarities(eventId, userId); + if (similarities.isEmpty()) { + log.info("Сходства не обнаружены"); + printMatrix(); + return; + } + + // Отправляем по одному + for (EventsSimilarityAvro similarity : similarities) { + sendSimilarity(eventId, similarity); + } + + printMatrix(); + } + + // ----------------------------------BUSSINES-LOGIC------------------------------------- + + private boolean verifyData(long userId, long eventId, double newLeadingWeight) { + + // Получаем или создаем карту для события + var usersToWeights = leadingWeightMatrix + .computeIfAbsent(eventId, k -> new HashMap<>()); + + // Получаем текущий вес пользователя + Double currentLeadingWeight = usersToWeights.get(userId); + + // Если пользователь еще не взаимодействовал с событием + if (currentLeadingWeight == null) { + currentLeadingWeight = 0.0; + } + + // Если веса одинаковые или новый меньше + if (newLeadingWeight <= currentLeadingWeight) { + log.info("Обновлять нечего"); + return false; + } + + log.info(""" + Обновление ведущего веса мероприятия + { + "userId": {}, + "eventId": {}, + "score": {} + }""", + userId, eventId, newLeadingWeight); + + // Заменяем старый ведущий вес на новый + usersToWeights.put(userId, newLeadingWeight); + + // Обновляем сумму весов для события по всем пользователям + updateSumOfLeadingWeights(eventId, currentLeadingWeight, newLeadingWeight); + + // Запускаем пересчет модели весов + recalculateData(eventId, userId, currentLeadingWeight, newLeadingWeight); + + return true; + } + + private void recalculateData(long eventA_Id, + long userId, + double oldLeadingWeight_A, + double leadingWeight_A) { + + leadingWeightMatrix.forEach((eventB_Id, userIdToLeadingWeight) -> { + + // Пропускаем строку с событием А + if (eventB_Id == eventA_Id) { + return; + } + + log.info("Пересчет модели весов для мероприятий {} и {}", eventA_Id, eventB_Id); + + // Получаем ведущий вес В из текущей строки + Double leadingWeight_B = userIdToLeadingWeight.get(userId); + if (leadingWeight_B == null) { + return; // пользователь не взаимодействовал с этим событием + } + + // Вычисляем старый минимальный вес среди ведущих + double oldMinWeight = Math.min(oldLeadingWeight_A, leadingWeight_B); + + // Вычисляем новый минимальный вес среди ведущих + double newMinWeight = Math.min(leadingWeight_A, leadingWeight_B); + + // Вычисляем старую сумму минимальных весов + double oldSumOfMinWeight = getSumOfMinWeight(eventA_Id, eventB_Id); + + // Новая сумма минимальных весов = (старая сумма - старый минимальный вес) + новый минимальный вес + double newSumOfMinWeight = (oldSumOfMinWeight - oldMinWeight) + newMinWeight; + + // Сетим новую сумму минимальных весов в матрицу вместо старой + putNewSumOfMinWeight(eventA_Id, eventB_Id, newSumOfMinWeight); + }); + + log.info("Пересчет данных завершен"); + } + + @NonNull + private List calculateSimilarities(long eventA_Id, long userId) { + + Map candidates = getCandidates(eventA_Id); + if (candidates.isEmpty()) { + log.info("В матрице сходства отсутствуют записи о событии {}", eventA_Id); + return Collections.emptyList(); + } + + List eventIds = new ArrayList<>(); + leadingWeightMatrix.forEach((event_Id, userIdToLeadingWeight) -> { + if (userIdToLeadingWeight.containsKey(userId)) { + eventIds.add(event_Id); + } + }); + + var similarities = new ArrayList(); + candidates.forEach((eventB_Id, sumOfMinWeight) -> { + + if (eventB_Id == eventA_Id) { + return; + } + if (!eventIds.contains(eventB_Id)) { + return; + } + + long eventA = Math.min(eventA_Id, eventB_Id); + long eventB = Math.max(eventA_Id, eventB_Id); + + log.info("Рассчет сходства мероприятий {} и {}", eventA, eventB); + + double leadingWeightsSumByEventA = eventToLeadingWeightsSum.get(eventA); + double leadingWeightsSumByEventB = eventToLeadingWeightsSum.get(eventB); + double norm1 = Math.sqrt(leadingWeightsSumByEventA); + double norm2 = Math.sqrt(leadingWeightsSumByEventB); + + double score = sumOfMinWeight / (norm1 * norm2); + + log.info(""" + Коэффициент подобия + для мероприятий [{}] и [{}] равен [{}] + (min=[{}], norm1=[{}], norm2=[{}]) + """, + eventA, eventB, + (double) Math.round(score * 100) / 100.0, + (double) Math.round(sumOfMinWeight * 100) / 100.0, + (double) Math.round(norm1 * 100) / 100.0, + (double) Math.round(norm2 * 100) / 100.0 + ); + + similarities.add( + EventsSimilarityAvro.newBuilder() + .setEventA(eventA) + .setEventB(eventB) + .setScore(score) + .setTimestamp(Instant.now()) + .build() + ); + }); + + log.info("Расчет сходства завершен"); + return similarities.stream().filter(sim -> sim.getScore() >= 0.005).toList(); + } + + private void sendSimilarity(long eventId, @NonNull EventsSimilarityAvro avro) { + + log.info(""" + Отправляю новое сходство + { + "eventA": {}, + "eventB": {}, + "score": {}, + "timestamp": {} + }""", + avro.getEventA(), + avro.getEventB(), + (double) Math.round(avro.getScore() * 100) / 100.0, + TimestampUtils.toLocalDateTime(avro.getTimestamp()) + ); + + CompletableFuture result = producer.sendMessage(eventId, avro); + + result.whenComplete((record, throwable) -> { + if (throwable != null) { + log.error("Ошибка отправки действия пользователя {}:\n {}", + avro, throwable.getMessage(), throwable); + } else { + log.info("Сходство отправлено по адресу:\n topic={}, key={}, partition={}, offset={}", + record.topic(), + avro.getEventA(), + record.partition(), + record.offset() + ); + } + }); + } + + // -------------------------------------UTILITY------------------------------------- + + private double calculateLeadingWeight(@NonNull ActionTypeAvro actionType) { + return switch (actionType) { + case VIEWS -> 0.4d; + case REGISTER -> 0.8d; + case LIKE -> 1.0d; + }; + } + + /// Пересчет суммы ведущих весов текущего события по всем пользователям + private void updateSumOfLeadingWeights(long eventId, double oldLeadingWeight, double newLeadingWeight) { + eventToLeadingWeightsSum.compute(eventId, (k, oldSum) -> { + + double weight = oldSum == null + ? newLeadingWeight : (oldSum - oldLeadingWeight) + newLeadingWeight; + + log.info(""" + Обновление суммы ведущих весов события по всем пользователям + { + "eventId": {}, + "leadingWeightsSum": {} + }""", + eventId, (double) Math.round(weight * 100) / 100.0 + ); + + return weight; + }); + } + + @NonNull + private Map getCandidates(long eventA_Id) { + Map candidates = new HashMap<>(); + + // Случай 1: eventA_Id — минимальный ключ (верхний треугольник) + Map upper = sumOfMinWeightsMatrix.get(eventA_Id); + if (upper != null) { + candidates.putAll(upper); + } + + // Случай 2: eventA_Id — максимальный ключ (нижний треугольник) + sumOfMinWeightsMatrix.forEach((first, row) -> { + Double v = row.get(eventA_Id); + if (v != null) { + candidates.put(first, v); + } + }); + + return candidates; + } + + private void putNewSumOfMinWeight(long eventA, long eventB, double sum) { + // Упорядочиваем идентификаторы чтобы не писать в симметричную половину + long first = Math.min(eventA, eventB); + long second = Math.max(eventA, eventB); + + sumOfMinWeightsMatrix + .computeIfAbsent(first, e -> new HashMap<>()) + .put(second, sum); + } + + private double getSumOfMinWeight(long eventA, long eventB) { + long first = Math.min(eventA, eventB); + long second = Math.max(eventA, eventB); + + return sumOfMinWeightsMatrix + .computeIfAbsent(first, e -> new HashMap<>()) + .getOrDefault(second, 0.0); + } + + // -------------------------------------LOGGING------------------------------------- + + private void printMatrix() { + log.info(buildLeadingWeightMatrixTable()); + log.info(buildSumOfMinWeightsMatrixTable()); + log.info(buildEventToLeadingWeightsSumTable()); + } + + /** + * GENERATED BY DEEPSEEK
+ */ + public String buildLeadingWeightMatrixTable() { + if (leadingWeightMatrix.isEmpty()) { + return TableRenderer.EMPTY_TABLE; + } + + var eventIds = new TreeSet<>(leadingWeightMatrix.keySet()); + var userIds = new TreeSet(); + leadingWeightMatrix.values().forEach(m -> userIds.addAll(m.keySet())); + + List columnNames = userIds.stream().map(id -> "u" + id).toList(); + List rowNames = eventIds.stream().map(id -> "e" + id).toList(); + + List> values = new ArrayList<>(); + for (Long eventId : eventIds) { + Map userWeights = leadingWeightMatrix.get(eventId); + List row = new ArrayList<>(); + for (Long userId : userIds) { + row.add(TableRenderer.formatValue(userWeights.get(userId))); + } + values.add(row); + } + + return TableRenderer.renderTable("Матрица ведущих весов:", columnNames, rowNames, values); + } + + /** + * GENERATED BY DEEPSEEK
+ */ + public String buildSumOfMinWeightsMatrixTable() { + if (sumOfMinWeightsMatrix.isEmpty()) { + return TableRenderer.EMPTY_TABLE; + } + + var eventIds = new TreeSet(); + sumOfMinWeightsMatrix.forEach((a, m) -> { + eventIds.add(a); + eventIds.addAll(m.keySet()); + }); + + List columnNames = eventIds.stream().map(id -> "e" + id).toList(); + List rowNames = new ArrayList<>(columnNames); + + List> values = new ArrayList<>(); + for (Long eventA : eventIds) { + Map candidates = getCandidates(eventA); + List row = new ArrayList<>(); + for (Long eventB : eventIds) { + if (eventA.equals(eventB)) { + row.add(""); + } else { + row.add(TableRenderer.formatValue(candidates.get(eventB))); + } + } + values.add(row); + } + + return TableRenderer.renderTable("Матрица числителей:", columnNames, rowNames, values); + } + + /** + * GENERATED BY DEEPSEEK
+ */ + public String buildEventToLeadingWeightsSumTable() { + if (eventToLeadingWeightsSum.isEmpty()) { + return TableRenderer.EMPTY_TABLE; + } + + var eventIds = new TreeSet<>(eventToLeadingWeightsSum.keySet()); + + List columnNames = eventIds.stream().map(id -> "e" + id).toList(); + List rowNames = List.of("Сумма"); + + List> values = new ArrayList<>(); + List row = new ArrayList<>(); + for (Long eventId : eventIds) { + row.add(TableRenderer.formatValue(eventToLeadingWeightsSum.get(eventId))); + } + values.add(row); + + return TableRenderer.renderTable("Таблица знаминателей:", columnNames, rowNames, values); + } + +} diff --git a/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/util/TableRenderer.java b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/util/TableRenderer.java new file mode 100644 index 0000000..ab0b438 --- /dev/null +++ b/stats/aggregator/src/main/java/ru/practicum/ewm/stats/aggregator/util/TableRenderer.java @@ -0,0 +1,139 @@ +package ru.practicum.ewm.stats.aggregator.util; + +import java.util.List; +import java.util.Locale; + +/** + * GENERATED BY DEEPSEEK
+ * Утилита для рендеринга матриц в виде Unicode-таблиц для логирования. + */ +@SuppressWarnings("StringRepeatCanBeUsed") +public final class TableRenderer { + + private TableRenderer() { + } + + /** + * Заглушка для пустой таблицы. + */ + public static final String EMPTY_TABLE = """ + ╔══════════╗ + ║ is empty ║ + ╚══════════╝ + """; + + /** + * Рендерит таблицу с Unicode-рамками. + * Угловая ячейка (левый верхний угол) всегда пустая. + * + * @param title заголовок таблицы (может быть null) + * @param columnNames заголовки столбцов (без левого служебного столбца) + * @param rowNames заголовки строк + * @param values значения: values.get(i).get(j) — ячейка для i-й строки и j-го столбца + * (null или отсутствие — пустая ячейка) + * @return готовая строка таблицы + */ + public static String renderTable(String title, + List columnNames, + List rowNames, + List> values) { + + // Если нет ни строк, ни столбцов — возвращаем заглушку + if (columnNames.isEmpty() && rowNames.isEmpty()) { + return EMPTY_TABLE; + } + + // Ширина левого служебного столбца — по максимальной длине имён строк + int rowColWidth = rowNames.stream() + .mapToInt(String::length) + .max() + .orElse(0); + + // Ширины столбцов + int[] colWidths = new int[columnNames.size()]; + for (int j = 0; j < columnNames.size(); j++) { + int headerLen = columnNames.get(j).length(); + int maxValueLen = 0; + for (List row : values) { + if (j < row.size()) { + String v = row.get(j); + if (v != null && v.length() > maxValueLen) { + maxValueLen = v.length(); + } + } + } + colWidths[j] = Math.max(headerLen, maxValueLen); + } + + StringBuilder sb = new StringBuilder(); + if (title != null && !title.isEmpty()) { + sb.append("\n").append(title).append("\n"); + } + + // Верхняя граница + sb.append("╔").append("═".repeat(rowColWidth + 2)).append("╦"); + for (int j = 0; j < columnNames.size(); j++) { + sb.append("═".repeat(colWidths[j] + 2)); + sb.append(j < columnNames.size() - 1 ? "╦" : "╗"); + } + sb.append("\n"); + + // Заголовок: угловая ячейка пустая + sb.append("║ ").append(padRight("", rowColWidth)).append(" ║"); + for (int j = 0; j < columnNames.size(); j++) { + sb.append(" ").append(padRight(columnNames.get(j), colWidths[j])).append(" ║"); + } + sb.append("\n"); + + // Разделитель + sb.append("╠").append("═".repeat(rowColWidth + 2)).append("╬"); + for (int j = 0; j < columnNames.size(); j++) { + sb.append("═".repeat(colWidths[j] + 2)); + sb.append(j < columnNames.size() - 1 ? "╬" : "╣"); + } + sb.append("\n"); + + // Строки данных + for (int i = 0; i < rowNames.size(); i++) { + sb.append("║ ").append(padRight(rowNames.get(i), rowColWidth)).append(" ║"); + List row = i < values.size() ? values.get(i) : List.of(); + for (int j = 0; j < columnNames.size(); j++) { + String v = j < row.size() ? row.get(j) : ""; + sb.append(" ").append(padRight(v, colWidths[j])).append(" ║"); + } + sb.append("\n"); + } + + // Нижняя граница + sb.append("╚").append("═".repeat(rowColWidth + 2)).append("╩"); + for (int j = 0; j < columnNames.size(); j++) { + sb.append("═".repeat(colWidths[j] + 2)); + sb.append(j < columnNames.size() - 1 ? "╩" : "╝"); + } + + return sb.toString(); + } + + /** + * Форматирует значение Double с двумя знаками после запятой (Locale.ROOT). + * null → пустая строка. + */ + public static String formatValue(Double value) { + return value == null ? "" : String.format(Locale.ROOT, "%.2f", value); + } + + /** + * Дополняет строку пробелами справа до указанной длины. + * + * @param s исходная строка (может быть null) + * @param len желаемая минимальная длина + * @return строка, дополненная пробелами справа + */ + private static String padRight(String s, int len) { + String str = s == null ? "" : s; + if (str.length() >= len) { + return str; + } + return str + " ".repeat(len - str.length()); + } +} \ No newline at end of file diff --git a/stats/stats-server/src/main/resources/application.yaml b/stats/aggregator/src/main/resources/application.yml similarity index 96% rename from stats/stats-server/src/main/resources/application.yaml rename to stats/aggregator/src/main/resources/application.yml index e4f93c2..41c7466 100644 --- a/stats/stats-server/src/main/resources/application.yaml +++ b/stats/aggregator/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: application: - name: stats-server + name: aggregator config: import: "optional:configserver:" cloud: @@ -29,3 +29,4 @@ eureka: instance-id: ${spring.application.name}:${random.value} lease-renewal-interval-in-seconds: 5 lease-expiration-duration-in-seconds: 10 + diff --git a/core/rating-service/pom.xml b/stats/analyzer/pom.xml similarity index 70% rename from core/rating-service/pom.xml rename to stats/analyzer/pom.xml index 72e6f4a..1479ef5 100644 --- a/core/rating-service/pom.xml +++ b/stats/analyzer/pom.xml @@ -6,55 +6,62 @@ ru.practicum - core + stats 0.0.1-SNAPSHOT - rating-service + analyzer ru.practicum - aggregation-api + avro-schemas + 0.0.1-SNAPSHOT + + + ru.practicum + proto-schemas + 0.0.1-SNAPSHOT + compile + + + ru.practicum + timestamp-utils 0.0.1-SNAPSHOT - org.springframework.boot - spring-boot-starter-web + net.devh + grpc-client-spring-boot-starter - org.springframework.boot - spring-boot-starter-data-jpa + net.devh + grpc-server-spring-boot-starter + - org.springframework.boot - spring-boot-starter-validation + org.projectlombok + lombok + provided org.springframework.boot - spring-boot-starter-test - test + spring-boot-starter-data-jpa - - io.hypersistence - hypersistence-utils-hibernate-63 - 3.15.2 - compile + org.springframework.boot + spring-boot-starter-validation - org.postgresql postgresql runtime + - com.h2database - h2 - runtime + org.springframework.boot + spring-boot-starter-web - org.springframework.cloud spring-cloud-starter-netflix-eureka-client @@ -73,15 +80,6 @@ caffeine - - org.springframework.retry - spring-retry - - - org.springframework.boot - spring-boot-starter-aop - - org.springframework.boot spring-boot-starter-actuator @@ -91,16 +89,12 @@ micrometer-registry-prometheus - + - org.springframework.cloud - spring-cloud-starter-openfeign - - - - - org.springframework.cloud - spring-cloud-starter-circuitbreaker-resilience4j + org.apache.tomcat + annotations-api + 6.0.53 + provided diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/AnalyzerApp.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/AnalyzerApp.java new file mode 100644 index 0000000..ff4b58f --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/AnalyzerApp.java @@ -0,0 +1,19 @@ +package ru.practicum.ewm.stats.analyzer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.ConfigurableApplicationContext; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class AnalyzerApp { + public static void main(String[] args) { + + ConfigurableApplicationContext context = SpringApplication + .run(AnalyzerApp.class, args); + + final AnalyzerRunner runner = context.getBean(AnalyzerRunner.class); + runner.run(args); + } +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/AnalyzerRunner.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/AnalyzerRunner.java new file mode 100644 index 0000000..03ddb97 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/AnalyzerRunner.java @@ -0,0 +1,53 @@ +package ru.practicum.ewm.stats.analyzer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.analyzer.consumer.EventsSimilarityComsumer; +import ru.practicum.ewm.stats.analyzer.consumer.UsersInteractionConsumer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AnalyzerRunner implements CommandLineRunner { + + private final EventsSimilarityComsumer eventsSimilarityComsumer; + private final UsersInteractionConsumer usersInteractionConsumer; + + private int cycleNumber = 0; + + @Override + public void run(String... args) { + + log.info("Цикл запуска №{}", ++cycleNumber); + + if (!eventsSimilarityComsumer.isRunning()) { + Thread EventsSimilarityThread = new Thread(eventsSimilarityComsumer); + EventsSimilarityThread.setName("EventsSimilarityComsumerThread"); + + log.info("Запускаем EventsSimilarityComsumer в отдельном потоке"); + EventsSimilarityThread.start(); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } else { + log.info("EventsSimilarityComsumer уже запущен"); + } + + if (!usersInteractionConsumer.isRunning()) { + + if (!eventsSimilarityComsumer.isRunning()) { + log.info("UsersInteractionConsumer не запущен"); + return; + } + log.info("Запускаем UsersInteractionConsumer в текущем потоке"); + usersInteractionConsumer.run(); + } else { + log.info("UsersInteractionConsumer уже запущен"); + } + } + +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/config/AnalyzerConfiguration.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/config/AnalyzerConfiguration.java new file mode 100644 index 0000000..4079c0b --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/config/AnalyzerConfiguration.java @@ -0,0 +1,41 @@ +package ru.practicum.ewm.stats.analyzer.config; + +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +import java.util.Properties; + +@Configuration +public class AnalyzerConfiguration { + + @Bean + @ConfigurationProperties("kafka.users-interaction-consumer-config") + public Properties usersInteractionConsumerConfig() { + return new Properties(); + } + + @Bean + @ConfigurationProperties("kafka.events-similarity-consumer-config") + public Properties eventsSimilarityConsumerConfig() { + return new Properties(); + } + + @Bean(destroyMethod = "") + public KafkaConsumer usersInteractionKafkaConsumer( + @Qualifier("usersInteractionConsumerConfig") Properties config + ) { + return new KafkaConsumer<>(config); + } + + @Bean(destroyMethod = "") + public KafkaConsumer eventsSimilarityKafkaConsumer( + @Qualifier("eventsSimilarityConsumerConfig") Properties config + ) { + return new KafkaConsumer<>(config); + } +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/BaseKafkaProcessor.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/BaseKafkaProcessor.java new file mode 100644 index 0000000..44af4af --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/BaseKafkaProcessor.java @@ -0,0 +1,128 @@ +package ru.practicum.ewm.stats.analyzer.consumer; + +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.errors.WakeupException; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +public abstract class BaseKafkaProcessor implements Runnable { + + final String topic; + + private final KafkaConsumer consumer; + + private final Map lastOffsets = new HashMap<>(); + + private static final Duration CONSUME_ATTEMPT_TIMEOUT = Duration.ofMillis(1000); + + @Getter + private volatile boolean running = false; + + public BaseKafkaProcessor(String topic, KafkaConsumer consumer) { + this.topic = topic; + this.consumer = consumer; + } + + @PreDestroy + public void shutdown() { + log.info("Получил сигнал остановки"); + running = false; + consumer.wakeup(); + } + + @Override + public void run() { + running = true; + + log.info("Подписка на топик: {}", topic); + consumer.subscribe(Collections.singleton(topic)); + + try { + pollLoop(); + + } catch (WakeupException ignored) { + log.info("Получил инструкцию wakeup"); + } catch (Exception e) { + log.error("Ошибка во время обработки событий от хаба", e); + } finally { + log.info("Остановка poll loop"); + running = false; + + try { + if (!lastOffsets.isEmpty()) { + log.info("Регистрация последнего смещения"); + consumer.commitSync(lastOffsets); + } + + } finally { + log.info("Остановка консьюмера"); + consumer.close(); + } + } + } + + private void pollLoop() { + int iteration = 1; + while (running) { + log.debug("Проверка источника событий хаба"); + ConsumerRecords records = consumer.poll(CONSUME_ATTEMPT_TIMEOUT); + + if (records.isEmpty()) { + log.debug("Источник событий хаба пуст"); + continue; + } + + log.info("Итерация {}. Батч из {} записей получен. Начинаем обработку.", + iteration++, records.count()); + + int recordNumber = 1; + for (ConsumerRecord record : records) { + + log.info(""" + Обработка действия пользователя №{}: + { + "topic": "{}", + "partition": "{}", + "offset": "{}", + "timestamp": "{}" + }""", + recordNumber++, + record.topic(), + record.partition(), + record.offset(), + TimestampUtils.toLocalDateTime(record.timestamp()) + ); + + try { + processRecord(record.value()); + + log.debug("Сохранение смещения локально"); + lastOffsets.put( + new TopicPartition(record.topic(), record.partition()), + new OffsetAndMetadata(record.offset() + 1) + ); + + } catch (Exception e) { + log.error("Ошибка обновления состояния:\n{}", record.value(), e); + throw e; + } + } + } + } + + protected abstract void processRecord(T record); +} \ No newline at end of file diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/EventsSimilarityComsumer.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/EventsSimilarityComsumer.java new file mode 100644 index 0000000..2619d2e --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/EventsSimilarityComsumer.java @@ -0,0 +1,27 @@ +package ru.practicum.ewm.stats.analyzer.consumer; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.analyzer.service.SimilarityService; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; + +@Slf4j +@Component +public class EventsSimilarityComsumer extends BaseKafkaProcessor { + + private final SimilarityService service; + + public EventsSimilarityComsumer(@Value("${kafka.topics.events-similarity}") String topic, + KafkaConsumer consumer, + SimilarityService service) { + super(topic, consumer); + this.service = service; + } + + @Override + protected void processRecord(EventsSimilarityAvro record) { + service.addSimilarity(record); + } +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/UsersInteractionConsumer.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/UsersInteractionConsumer.java new file mode 100644 index 0000000..e5a637f --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/consumer/UsersInteractionConsumer.java @@ -0,0 +1,25 @@ +package ru.practicum.ewm.stats.analyzer.consumer; + +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.analyzer.service.InteractionsService; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +@Component +public class UsersInteractionConsumer extends BaseKafkaProcessor { + + private final InteractionsService service; + + public UsersInteractionConsumer(@Value("${kafka.topics.users-interaction}") String topic, + KafkaConsumer consumer, + InteractionsService service) { + super(topic, consumer); + this.service = service; + } + + @Override + protected void processRecord(UserActionAvro record) { + service.addInteraction(record); + } +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/controller/AnalyzerController.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/controller/AnalyzerController.java new file mode 100644 index 0000000..c178f3c --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/controller/AnalyzerController.java @@ -0,0 +1,96 @@ +package ru.practicum.ewm.stats.analyzer.controller; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; +import org.springframework.lang.NonNull; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.ewm.stats.analyzer.service.InteractionsService; +import ru.practicum.ewm.stats.analyzer.service.RecommendationService; +import ru.practicum.ewm.stats.analyzer.service.SimilarityService; +import ru.practicum.ewm.stats.proto.InteractionsCountRequestProto; +import ru.practicum.ewm.stats.proto.RecommendationsControllerGrpc.RecommendationsControllerImplBase; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; +import ru.practicum.ewm.stats.proto.SimilarEventsRequestProto; +import ru.practicum.ewm.stats.proto.UserRecommendationsRequestProto; + +@Slf4j +@GrpcService +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class AnalyzerController extends RecommendationsControllerImplBase { + + SimilarityService similarityService; + InteractionsService interactionsService; + RecommendationService recommendationService; + + /** + * возвращает поток рекомендованных мероприятий для указанного пользователя + */ + @Transactional(readOnly = true) + @Override + public void getRecomendationsForUser(@NonNull UserRecommendationsRequestProto request, + StreamObserver responseObserver) { + log.info(""" + Получен gRPC запрос: Получить рекомендации для пользователя + {}""", request); + + try (var result = recommendationService.getRecomendationsForUser( + request.getUserId(), request.getMaxResults()) + ) { + result.forEach(responseObserver::onNext); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("Не удалось получить рекомендации для пользователя", e); + responseObserver.onError(new StatusRuntimeException(Status.fromThrowable(e))); + } + } + + /** + * возвращает поток мероприятий, с которыми не взаимодействовал этот пользователь, + * но которые максимально похожи на указанное мероприятие + */ + @Transactional(readOnly = true) + @Override + public void getSimilarEvents(@NonNull SimilarEventsRequestProto request, + StreamObserver responseObserver) { + log.info(""" + Получен gRPC запрос: Получить похожие по мероприятиям + {}""", request); + + try (var result = similarityService.getSimilarEvents(request)) { + result.forEach(responseObserver::onNext); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("Не удалось получить похожие по мероприятиям", e); + responseObserver.onError(new StatusRuntimeException(Status.fromThrowable(e))); + } + } + + /** + * получает идентификаторы мероприятий и возвращает их поток с суммой + * максимальных весов действий каждого пользователя с этими мероприятиями + */ + @Transactional(readOnly = true) + @Override + public void getInteractionsCount(@NonNull InteractionsCountRequestProto request, + StreamObserver responseObserver) { + log.info(""" + Получен gRPC запрос: Получить количество взаимодействий + {}""", request.getEventIdList()); + + try (var stream = interactionsService.getInteractionsByEventIds(request.getEventIdList())) { + stream.forEach(responseObserver::onNext); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("Не удалось получить количество взаимодействий", e); + responseObserver.onError(new StatusRuntimeException(Status.fromThrowable(e))); + } + } + +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/BaseEntity.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/BaseEntity.java new file mode 100644 index 0000000..bd781a3 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/BaseEntity.java @@ -0,0 +1,43 @@ +package ru.practicum.ewm.stats.analyzer.entity; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Getter +@Setter +@SuperBuilder(toBuilder = true) +@MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + + if (org.hibernate.Hibernate.getClass(this) + != org.hibernate.Hibernate.getClass(o)) { + return false; + } + + BaseEntity other = (BaseEntity) o; + + return id != null && id.equals(other.id); + } + + @Override + public final int hashCode() { + return org.hibernate.Hibernate.getClass(this).hashCode(); + } +} \ No newline at end of file diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/Interaction.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/Interaction.java new file mode 100644 index 0000000..129e7ae --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/Interaction.java @@ -0,0 +1,32 @@ +package ru.practicum.ewm.stats.analyzer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; + +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder(toBuilder = true) +@Table(name = "interactions", uniqueConstraints = { + @UniqueConstraint(columnNames = {"userId", "eventId"}) +}) +public class Interaction extends BaseEntity { + + @Column(nullable = false) + Long userId; + + @Column(nullable = false) + Long eventId; + + @Column(nullable = false, precision = 3, scale = 2) + BigDecimal actionWeight; +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/Similarity.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/Similarity.java new file mode 100644 index 0000000..0c22885 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/entity/Similarity.java @@ -0,0 +1,32 @@ +package ru.practicum.ewm.stats.analyzer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; + +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder(toBuilder = true) +@Table(name = "similariries", uniqueConstraints = { + @UniqueConstraint(columnNames = {"eventA", "eventB"}) +}) +public class Similarity extends BaseEntity { + + @Column(name = "event_a", nullable = false) + Long eventA; + + @Column(name = "event_b", nullable = false) + Long eventB; + + @Column(nullable = false, precision = 3, scale = 2) + BigDecimal score; +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/mapper/InteractionMapper.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/mapper/InteractionMapper.java new file mode 100644 index 0000000..1db70e7 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/mapper/InteractionMapper.java @@ -0,0 +1,31 @@ +package ru.practicum.ewm.stats.analyzer.mapper; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; +import ru.practicum.ewm.stats.analyzer.entity.Interaction; +import ru.practicum.ewm.stats.avro.ActionTypeAvro; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +import java.math.BigDecimal; + +@UtilityClass +public class InteractionMapper { + + public Interaction toEntity(@NonNull UserActionAvro avro) { + return Interaction.builder() + .userId(avro.getUserId()) + .eventId(avro.getEventId()) + .actionWeight(toActionWeight(avro.getActionType())) + .build(); + } + + @NonNull + private BigDecimal toActionWeight(@NonNull ActionTypeAvro avroType) { + return switch (avroType) { + case VIEWS -> BigDecimal.valueOf(0.4); + case REGISTER -> BigDecimal.valueOf(0.8); + case LIKE -> BigDecimal.ONE; + }; + } + +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/mapper/SimilarityMapper.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/mapper/SimilarityMapper.java new file mode 100644 index 0000000..32480db --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/mapper/SimilarityMapper.java @@ -0,0 +1,27 @@ +package ru.practicum.ewm.stats.analyzer.mapper; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; +import ru.practicum.ewm.stats.analyzer.entity.Similarity; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@UtilityClass +public class SimilarityMapper { + + public Similarity toEntity(@NonNull EventsSimilarityAvro avro) { + return Similarity.builder() + .eventA(avro.getEventA()) + .eventB(avro.getEventB()) + .score(toBigDecimal(avro.getScore())) + .build(); + } + + @NonNull + private BigDecimal toBigDecimal(double value) { + return new BigDecimal(value).setScale(2, RoundingMode.HALF_UP); + } + +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventInteraction.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventInteraction.java new file mode 100644 index 0000000..9240cd4 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventInteraction.java @@ -0,0 +1,10 @@ +package ru.practicum.ewm.stats.analyzer.model; + +import java.math.BigDecimal; + +public record EventInteraction( + long userId, + long eventId, + BigDecimal actionWeight +) { +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventSimilarity.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventSimilarity.java new file mode 100644 index 0000000..63a4173 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventSimilarity.java @@ -0,0 +1,10 @@ +package ru.practicum.ewm.stats.analyzer.model; + +import java.math.BigDecimal; + +public record EventSimilarity( + long eventA, + long eventB, + BigDecimal score +) { +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventWeightSum.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventWeightSum.java new file mode 100644 index 0000000..3ebffd9 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/model/EventWeightSum.java @@ -0,0 +1,9 @@ +package ru.practicum.ewm.stats.analyzer.model; + +import java.math.BigDecimal; + +public record EventWeightSum( + long eventId, + BigDecimal score +) { +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/repository/InteractionsRepository.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/repository/InteractionsRepository.java new file mode 100644 index 0000000..b1e3e0b --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/repository/InteractionsRepository.java @@ -0,0 +1,56 @@ +package ru.practicum.ewm.stats.analyzer.repository; + +import org.hibernate.query.spi.Limit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.practicum.ewm.stats.analyzer.entity.Interaction; +import ru.practicum.ewm.stats.analyzer.model.EventInteraction; +import ru.practicum.ewm.stats.analyzer.model.EventWeightSum; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +public interface InteractionsRepository extends JpaRepository { + + @Query(""" + select new ru.practicum.ewm.stats.analyzer.model.EventWeightSum( + ir.eventId, + coalesce(sum(ir.actionWeight), 0) + ) + from Interaction ir + where ir.eventId in :eventIds + group by ir.eventId + """) + Stream aggregateActionWeightsByEvent(Collection eventIds); + + @Query(""" + select new ru.practicum.ewm.stats.analyzer.model.EventInteraction( + ir.userId, + ir.eventId, + ir.actionWeight + ) + from Interaction ir + where ir.eventId in :eventIds + and ir.userId = :userId + """) + Set aggregateActionWeightByEventAndByUser(Collection eventIds, Long userId); + + @Query(""" + SELECT DISTINCT i.eventId FROM Interaction i + WHERE i.userId = :userId + """) + Set findInteractedEventIds(long userId); + + Optional findByUserIdAndEventId(long userId, long eventId); + + @Query(""" + select ir.eventId + from Interaction ir + where ir.userId = :userId + order by ir.id desc + """) + List findRecentlyInteractions(long userId, Limit limit); +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/repository/SimilarityRepository.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/repository/SimilarityRepository.java new file mode 100644 index 0000000..afa31f8 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/repository/SimilarityRepository.java @@ -0,0 +1,32 @@ +package ru.practicum.ewm.stats.analyzer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.practicum.ewm.stats.analyzer.entity.Similarity; +import ru.practicum.ewm.stats.analyzer.model.EventSimilarity; + +import java.util.List; +import java.util.Optional; + +public interface SimilarityRepository extends JpaRepository { + + Optional findByEventAAndEventB(Long eventA, Long eventB); + + @Query(""" + SELECT new ru.practicum.ewm.stats.analyzer.model.EventSimilarity( + s.eventA, s.eventB, s.score + ) + FROM Similarity s + WHERE s.eventA = :eventId OR s.eventB = :eventId + """) + List findSimilaritiesByEventId(long eventId); + + @Query(""" + SELECT DISTINCT new ru.practicum.ewm.stats.analyzer.model.EventSimilarity( + s.eventA, s.eventB, s.score + ) + FROM Similarity s + WHERE s.eventA IN :eventIds OR s.eventB IN :eventIds + """) + List findSimilaritiesByManyEventIds(List eventIds); +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/InteractionsService.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/InteractionsService.java new file mode 100644 index 0000000..ef75dc9 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/InteractionsService.java @@ -0,0 +1,16 @@ +package ru.practicum.ewm.stats.analyzer.service; + +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.ewm.stats.avro.UserActionAvro; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; + +import java.util.Collection; +import java.util.stream.Stream; + +public interface InteractionsService { + + @Transactional + void addInteraction(UserActionAvro record); + + Stream getInteractionsByEventIds(Collection eventIds); +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/InteractionsServiceImpl.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/InteractionsServiceImpl.java new file mode 100644 index 0000000..c459516 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/InteractionsServiceImpl.java @@ -0,0 +1,74 @@ +package ru.practicum.ewm.stats.analyzer.service; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ru.practicum.ewm.stats.analyzer.mapper.InteractionMapper; +import ru.practicum.ewm.stats.analyzer.repository.InteractionsRepository; +import ru.practicum.ewm.stats.avro.UserActionAvro; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +import java.util.Collection; +import java.util.HashSet; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class InteractionsServiceImpl implements InteractionsService { + + InteractionsRepository repository; + + @Override + public void addInteraction(UserActionAvro avro) { + + var interaction = InteractionMapper.toEntity(avro); + + long userId = avro.getUserId(); + long eventId = avro.getEventId(); + var actionType = avro.getActionType(); + + log.info(""" + Сохраняю новое действие пользователя: + { + "userId": {}, + "eventId": {}, + "actionType": {}, + "timestamp": {} + } + """, + userId, + eventId, + actionType, + TimestampUtils.toLocalDateTime(avro.getTimestamp()) + ); + + repository.findByUserIdAndEventId(userId, eventId) + .ifPresentOrElse(inter -> { + interaction.setId(inter.getId()); + + // Сетим максимальное + var updated = interaction.toBuilder() + .actionWeight(interaction.getActionWeight().max(inter.getActionWeight())) + .build(); + + repository.save(updated); + }, + () -> repository.save(interaction) + ); + } + + @Override + public Stream getInteractionsByEventIds(Collection eventIds) { + return repository.aggregateActionWeightsByEvent(new HashSet<>(eventIds)) + .map(eventWeightSum -> RecommendedEventProto.newBuilder() + .setEventId(eventWeightSum.eventId()) + .setScore(eventWeightSum.score().doubleValue()) + .build()); + } + +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/RecommendationService.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/RecommendationService.java new file mode 100644 index 0000000..79f3643 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/RecommendationService.java @@ -0,0 +1,10 @@ +package ru.practicum.ewm.stats.analyzer.service; + +import ru.practicum.ewm.stats.proto.RecommendedEventProto; + +import java.util.stream.Stream; + +public interface RecommendationService { + + Stream getRecomendationsForUser(long userId, int maxResults); +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/RecommendationServiceImpl.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/RecommendationServiceImpl.java new file mode 100644 index 0000000..7c59a86 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/RecommendationServiceImpl.java @@ -0,0 +1,223 @@ +package ru.practicum.ewm.stats.analyzer.service; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.query.spi.Limit; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import ru.practicum.ewm.stats.analyzer.model.EventInteraction; +import ru.practicum.ewm.stats.analyzer.model.EventSimilarity; +import ru.practicum.ewm.stats.analyzer.repository.InteractionsRepository; +import ru.practicum.ewm.stats.analyzer.repository.SimilarityRepository; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class RecommendationServiceImpl implements RecommendationService { + + InteractionsRepository interactionsRepository; + SimilarityRepository similarityRepository; + + /** + * Сформировать персональные рекомендации для пользователя. + */ + @Override + public Stream getRecomendationsForUser(long userId, int maxResults) { + + // Выгрузить мероприятия, с которыми пользователь уже взаимодействовал + Set userInteractedEvents = interactionsRepository + .findInteractedEventIds(userId); + + // ЭТАП 1: Подбор мероприятий, с которыми пользователь ещё не взаимодействовал + List candidates = findCandidates(userId, userInteractedEvents, maxResults); + if (candidates.isEmpty()) { + log.info("Нет новых мероприятий для пользователя с id={}", userId); + return Stream.empty(); + } + + // ЭТАП 2: Вычисление предсказанной оценки для каждого нового мероприятия + Map predictedScores = predictScores( + userId, candidates, userInteractedEvents, maxResults); + if (predictedScores.isEmpty()) { + log.info("Не удалось предсказать оценки для пользователя с id={}", userId); + return Stream.empty(); + } + + /* + * Отсортировать найденные мероприятия по предсказанной оценке + * от большего к меньшему и выбрать первые N мероприятий. + */ + return predictedScores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(maxResults) + .map(entry -> RecommendedEventProto.newBuilder() + .setEventId(entry.getKey()) + .setScore(entry.getValue()) + .build()); + } + + /** + * ЭТАП 1. Подбор мероприятий, с которыми пользователь ещё не взаимодействовал. + */ + @NonNull + private List findCandidates(long userId, + @NonNull Set userInteractedEvents, + int maxResults) { + + /* + * Получить недавно просмотренные. Выгрузить мероприятия, с которыми + * пользователь уже взаимодействовал, отсортировать по дате взаимодействия + * от новых к старым и ограничить N взаимодействиями. + */ + List recently = interactionsRepository + .findRecentlyInteractions(userId, new Limit(0, maxResults)); + + /* + * Если пользователь ещё не взаимодействовал ни с одним мероприятием, + * то рекомендовать нечего — возвращается пустой список. + */ + if (recently.isEmpty()) { + log.info("Нет недавних взаимодействий у пользователя с id={}", userId); + return List.of(); + } + + // Найти похожие новые мероприятия, исключая уже взаимодействованные + return findNewCandidates(recently, userInteractedEvents, maxResults); + } + + /** + * ЭТАП 2. Вычисление предсказанной оценки для каждого нового мероприятия. + */ + @NonNull + private Map predictScores(long userId, + @NonNull List candidates, + @NonNull Set userInteractedEvents, + int maxResults) { + + Map predictedScores = new HashMap<>(); + + for (Long candidateId : candidates) { + + /* + * Найти K ближайших соседей — просмотренных мероприятий, максимально + * похожих на предсказываемое. На основе их оценок будет предсказана новая. + */ + List nearbouses = findNearbouses(candidateId, userInteractedEvents, maxResults); + if (nearbouses.isEmpty()) { + continue; + } + + // Получить оценки пользователя за ближайших соседей + Set interactions = interactionsRepository + .aggregateActionWeightByEventAndByUser(nearbouses, userId); + if (interactions.isEmpty()) { + continue; + } + + // Получить коэффициенты подобия между кандидатом и его соседями + Map similarityByNearbour = similarityRepository + .findSimilaritiesByEventId(candidateId).stream() + .filter(sim -> { + long other = sim.eventA() == candidateId + ? sim.eventB() : sim.eventA(); + return nearbouses.contains(other); + }) + .collect(Collectors.toMap( + sim -> sim.eventA() == candidateId + ? sim.eventB() : sim.eventA(), + sim -> sim.score().doubleValue(), + (a, b) -> a + )); + + /* + * Вычислить сумму взвешенных оценок: перемножить оценки мероприятий + * с их коэффициентами подобия и сложить все произведения. + * + * Вычислить сумму коэффициентов подобия: сложить все коэффициенты. + */ + double weightedSum = 0.0; + double scoreSum = 0.0; + + for (EventInteraction interaction : interactions) { + Double score = similarityByNearbour.get(interaction.eventId()); + if (score == null) { + continue; + } + double weight = interaction.actionWeight().doubleValue(); + weightedSum += score * weight; + scoreSum += score; + } + + // если сумма коэффициентов равна нулю — пропустить + if (scoreSum == 0.0) { + continue; + } + + // Вычислить оценку нового мероприятия + predictedScores.put(candidateId, weightedSum / scoreSum); + } + + return predictedScores; + } + + /** + * Найти мероприятия, похожие на недавно просмотренные, с которыми пользователь + * ещё не взаимодействовал. Отсортировать по коэффициенту подобия от большего + * к меньшему и выбрать первые N мероприятий. + */ + @NonNull + private List findNewCandidates(@NonNull List recently, + @NonNull Set userInteractedEvents, + int maxResults) { + return similarityRepository.findSimilaritiesByManyEventIds(recently).stream() + + // Определить, какое из двух мероприятий пары является новым + .map(sim -> recently.contains(sim.eventA()) + ? sim.eventB() : sim.eventA()) + + // Исключить мероприятия, с которыми пользователь уже взаимодействовал + .filter(candidate -> !userInteractedEvents.contains(candidate)) + + .distinct() + .limit(maxResults) + .toList(); + } + + /** + * Найти K ближайших соседей — просмотренных мероприятий, максимально похожих + * на предсказываемое. Важно найти именно максимально похожие мероприятия, + * с которыми пользователь уже взаимодействовал. + */ + @NonNull + private List findNearbouses(long candidateId, + @NonNull Set userInteractedEvents, + int maxResults) { + return similarityRepository.findSimilaritiesByEventId(candidateId).stream() + + // Отсортировать по коэффициенту подобия от большего к меньшему + .sorted(Comparator.comparingDouble( + (EventSimilarity sim) -> sim.score().doubleValue()).reversed()) + + // Для каждой пары определить "соседа" — мероприятие, отличное от предсказываемого + .map(sim -> sim.eventA() == candidateId + ? sim.eventB() : sim.eventA()) + + // Исключить само предсказываемое мероприятие + .filter(id -> !id.equals(candidateId)) + + // Оставить только те мероприятия, с которыми пользователь уже взаимодействовал + .filter(userInteractedEvents::contains) + .distinct() + .limit(maxResults) + .toList(); + } + +} \ No newline at end of file diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/SimilarityService.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/SimilarityService.java new file mode 100644 index 0000000..0de9605 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/SimilarityService.java @@ -0,0 +1,16 @@ +package ru.practicum.ewm.stats.analyzer.service; + +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; +import ru.practicum.ewm.stats.proto.SimilarEventsRequestProto; + +import java.util.stream.Stream; + +public interface SimilarityService { + + @Transactional + void addSimilarity(EventsSimilarityAvro record); + + Stream getSimilarEvents(SimilarEventsRequestProto request); +} diff --git a/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/SimilarityServiceImpl.java b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/SimilarityServiceImpl.java new file mode 100644 index 0000000..ad60555 --- /dev/null +++ b/stats/analyzer/src/main/java/ru/practicum/ewm/stats/analyzer/service/SimilarityServiceImpl.java @@ -0,0 +1,81 @@ +package ru.practicum.ewm.stats.analyzer.service; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import ru.practicum.ewm.stats.analyzer.mapper.SimilarityMapper; +import ru.practicum.ewm.stats.analyzer.model.EventSimilarity; +import ru.practicum.ewm.stats.analyzer.repository.InteractionsRepository; +import ru.practicum.ewm.stats.analyzer.repository.SimilarityRepository; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; +import ru.practicum.ewm.stats.proto.SimilarEventsRequestProto; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class SimilarityServiceImpl implements SimilarityService { + + SimilarityRepository repository; + InteractionsRepository interactionsRepository; + + @Override + public void addSimilarity(EventsSimilarityAvro record) { + + var similarity = SimilarityMapper.toEntity(record); + + log.info(""" + Сохраняю новое сходство событий: + { + "eventA": {}, + "eventB": {}, + "score": {}, + "timestamp": {} + } + """, + similarity.getEventA(), + similarity.getEventB(), + similarity.getScore(), + TimestampUtils.toLocalDateTime(record.getTimestamp()) + ); + + repository.findByEventAAndEventB(similarity.getEventA(), similarity.getEventB()) + .ifPresentOrElse(sim -> { + similarity.setId(sim.getId()); + repository.save(similarity); + }, + () -> repository.save(similarity) + ); + } + + @Override + public Stream getSimilarEvents(@NonNull SimilarEventsRequestProto request) { + + long eventId = request.getEventId(); + Set badInteractions = interactionsRepository + .findInteractedEventIds(request.getUserId()); + + return repository.findSimilaritiesByEventId(eventId).stream() + .filter(sim -> !badInteractions + .containsAll(List.of(sim.eventA(), sim.eventB()))) + .sorted(Comparator.comparingDouble( + (EventSimilarity sim) -> sim.score().doubleValue()).reversed()) + .limit(request.getMaxResults()) + .map(sim -> RecommendedEventProto.newBuilder() + .setEventId(sim.eventA() == eventId + ? sim.eventB() : sim.eventA()) + .setScore(sim.score().doubleValue()) + .build()); + } + +} diff --git a/core/rating-service/src/main/resources/application.yml b/stats/analyzer/src/main/resources/application.yml similarity index 96% rename from core/rating-service/src/main/resources/application.yml rename to stats/analyzer/src/main/resources/application.yml index 338ad89..d419e05 100644 --- a/core/rating-service/src/main/resources/application.yml +++ b/stats/analyzer/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: application: - name: rating-service + name: analyzer config: import: "optional:configserver:" cloud: @@ -29,3 +29,4 @@ eureka: instance-id: ${spring.application.name}:${random.value} lease-renewal-interval-in-seconds: 5 lease-expiration-duration-in-seconds: 10 + diff --git a/stats/analyzer/src/main/resources/schema-postgres.sql b/stats/analyzer/src/main/resources/schema-postgres.sql new file mode 100644 index 0000000..ed9c740 --- /dev/null +++ b/stats/analyzer/src/main/resources/schema-postgres.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS interactions( + id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + user_id BIGINT NOT NULL, + event_id BIGINT NOT NULL, + action_weight NUMERIC(3, 2) NOT NULL CHECK (action_weight BETWEEN 0.0 AND 1.0), + UNIQUE (event_id, user_id) +); + +CREATE TABLE IF NOT EXISTS similariries( + id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + event_a BIGINT NOT NULL, + event_b BIGINT NOT NULL, + score NUMERIC(3, 2) NOT NULL CHECK (score BETWEEN 0.0 AND 1.0), + UNIQUE (event_a, event_b) +); diff --git a/stats/stats-server/pom.xml b/stats/collector/pom.xml similarity index 80% rename from stats/stats-server/pom.xml rename to stats/collector/pom.xml index 4f59218..b3961f6 100644 --- a/stats/stats-server/pom.xml +++ b/stats/collector/pom.xml @@ -3,52 +3,50 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 + ru.practicum stats 0.0.1-SNAPSHOT - stats-server - jar + collector ru.practicum - stat-dto + avro-schemas 0.0.1-SNAPSHOT - org.projectlombok - lombok - provided + ru.practicum + proto-schemas + 0.0.1-SNAPSHOT + compile - org.springframework.boot - spring-boot-starter-web + ru.practicum + timestamp-utils + 0.0.1-SNAPSHOT + - org.springframework.boot - spring-boot-starter-data-jpa + net.devh + grpc-server-spring-boot-starter + org.springframework.boot - spring-boot-starter-test - test + spring-boot-starter-web org.springframework.boot spring-boot-starter-validation - org.postgresql - postgresql - runtime - - - com.h2database - h2 - runtime + org.projectlombok + lombok + provided diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/StatsServerApp.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/CollectorApp.java similarity index 61% rename from stats/stats-server/src/main/java/ru/practicum/stat/server/StatsServerApp.java rename to stats/collector/src/main/java/ru/practicum/ewm/stats/collector/CollectorApp.java index 31068cb..f10b0da 100644 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/StatsServerApp.java +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/CollectorApp.java @@ -1,11 +1,11 @@ -package ru.practicum.stat.server; +package ru.practicum.ewm.stats.collector; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class StatsServerApp { +public class CollectorApp { public static void main(String[] args) { - SpringApplication.run(StatsServerApp.class, args); + SpringApplication.run(CollectorApp.class, args); } } diff --git a/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/config/CollectorConfiguration.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/config/CollectorConfiguration.java new file mode 100644 index 0000000..4e1b01e --- /dev/null +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/config/CollectorConfiguration.java @@ -0,0 +1,28 @@ +package ru.practicum.ewm.stats.collector.config; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +import java.util.Properties; + +@Configuration +public class CollectorConfiguration { + + @Bean + @ConfigurationProperties("kafka.user-action-producer-config") + public Properties userActionProducerConfig() { + return new Properties(); + } + + @Bean + public KafkaProducer userActionKafkaProducer( + @Qualifier("userActionProducerConfig") Properties config + ) { + return new KafkaProducer<>(config); + } + +} diff --git a/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/controller/CollectorController.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/controller/CollectorController.java new file mode 100644 index 0000000..f0f9acc --- /dev/null +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/controller/CollectorController.java @@ -0,0 +1,53 @@ +package ru.practicum.ewm.stats.collector.controller; + +import com.google.protobuf.Empty; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; +import org.springframework.lang.NonNull; +import ru.practicum.ewm.stats.collector.service.UserActionService; +import ru.practicum.ewm.stats.proto.UserActionControllerGrpc.UserActionControllerImplBase; +import ru.practicum.ewm.stats.proto.UserActionProto; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +@Slf4j +@GrpcService +@RequiredArgsConstructor +public class CollectorController extends UserActionControllerImplBase { + + private final UserActionService userActionService; + + @Override + public void collectUserAction(@NonNull UserActionProto request, + StreamObserver responseObserver) { + log.info(""" + Получен gRPC запрос: + collectUserAction(UserActionProto request) + { + "user_id": "{}" + "event_id": "{}", + "action_type": "{}", + "timestamp": "{}" + }""", + request.getUserId(), + request.getEventId(), + request.getActionType().name(), + TimestampUtils.toLocalDateTime(request.getTimestamp()) + ); + + try { + userActionService.collectUserAction(request); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + + } catch (Exception e) { + responseObserver.onError( + new StatusRuntimeException(Status.fromThrowable(e)) + ); + } + } + +} diff --git a/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/mapper/UserActionMapper.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/mapper/UserActionMapper.java new file mode 100644 index 0000000..75cceaf --- /dev/null +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/mapper/UserActionMapper.java @@ -0,0 +1,32 @@ +package ru.practicum.ewm.stats.collector.mapper; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; +import ru.practicum.ewm.stats.avro.ActionTypeAvro; +import ru.practicum.ewm.stats.avro.UserActionAvro; +import ru.practicum.ewm.stats.proto.ActionTypeProto; +import ru.practicum.ewm.stats.proto.UserActionProto; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +@UtilityClass +public class UserActionMapper { + + public UserActionAvro toAvro(@NonNull UserActionProto proto) { + return UserActionAvro.newBuilder() + .setUserId(proto.getUserId()) + .setEventId(proto.getEventId()) + .setActionType(UserActionMapper.toAvroActionType(proto.getActionType())) + .setTimestamp(TimestampUtils.toInstant(proto.getTimestamp())) + .build(); + } + + private ActionTypeAvro toAvroActionType(@NonNull ActionTypeProto protoType) { + return switch (protoType) { + case ACTION_VIEW -> ActionTypeAvro.VIEWS; + case ACTION_LIKE -> ActionTypeAvro.LIKE; + case ACTION_REGISTER -> ActionTypeAvro.REGISTER; + default -> throw new IllegalArgumentException("Unknown action type: " + protoType); + }; + } + +} diff --git a/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/producer/UserActionProducer.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/producer/UserActionProducer.java new file mode 100644 index 0000000..82d5b72 --- /dev/null +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/producer/UserActionProducer.java @@ -0,0 +1,44 @@ +package ru.practicum.ewm.stats.collector.producer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +import javax.annotation.Nonnull; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserActionProducer { + + @Value("${kafka.topics.users-interaction}") + private String topic; + + private final Producer producer; + + public CompletableFuture sendMessage(Long key, + @Nonnull T userAction) { + ProducerRecord record = new ProducerRecord<>( + topic, null, key, userAction); + + CompletableFuture future = new CompletableFuture<>(); + + log.info("Отправляю сообщение:\n{}", record.value()); + producer.send(record, (metadata, exception) -> { + if (exception != null) { + future.completeExceptionally(exception); + } else { + future.complete(metadata); + } + }); + + return future; + } + +} diff --git a/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/service/UserActionService.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/service/UserActionService.java new file mode 100644 index 0000000..fb3f3e8 --- /dev/null +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/service/UserActionService.java @@ -0,0 +1,8 @@ +package ru.practicum.ewm.stats.collector.service; + +import ru.practicum.ewm.stats.proto.UserActionProto; + +public interface UserActionService { + + void collectUserAction(UserActionProto userAction); +} diff --git a/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/service/UserActionServiceImpl.java b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/service/UserActionServiceImpl.java new file mode 100644 index 0000000..e665ce1 --- /dev/null +++ b/stats/collector/src/main/java/ru/practicum/ewm/stats/collector/service/UserActionServiceImpl.java @@ -0,0 +1,42 @@ +package ru.practicum.ewm.stats.collector.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.stereotype.Service; +import ru.practicum.ewm.stats.avro.UserActionAvro; +import ru.practicum.ewm.stats.collector.producer.UserActionProducer; +import ru.practicum.ewm.stats.collector.mapper.UserActionMapper; +import ru.practicum.ewm.stats.proto.UserActionProto; + +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserActionServiceImpl implements UserActionService { + + private final UserActionProducer client; + + @Override + public void collectUserAction(UserActionProto proto) { + UserActionAvro avro = UserActionMapper.toAvro(proto); + + CompletableFuture result = client.sendMessage(avro.getEventId(), avro); + result.whenComplete((record, throwable) -> { + if (throwable != null) { + log.error("Ошибка отправки действия пользователя {}:\n {}", + avro.getActionType(), throwable.getMessage(), throwable); + } else { + log.info("{} отправлен по адресу:\n topic={}, key={}, partition={}, offset={}", + avro.getActionType(), + record.topic(), + avro.getEventId(), + record.partition(), + record.offset() + ); + } + }); + } + +} diff --git a/stats/collector/src/main/resources/application.yml b/stats/collector/src/main/resources/application.yml new file mode 100644 index 0000000..4122d52 --- /dev/null +++ b/stats/collector/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: 0 + +spring: + application: + name: collector + config: + import: "optional:configserver:" + cloud: + config: + discovery: + enabled: true + service-id: config-server + fail-fast: true + retry: + initial-interval: 1000 + max-interval: 10000 + multiplier: 2.0 + max-attempts: 20 + +eureka: + client: + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + register-with-eureka: true + fetch-registry: true + instance: + prefer-ip-address: true + instance-id: ${spring.application.name}:${random.value} + lease-renewal-interval-in-seconds: 5 + lease-expiration-duration-in-seconds: 10 diff --git a/stats/pom.xml b/stats/pom.xml index 494a796..08490ff 100644 --- a/stats/pom.xml +++ b/stats/pom.xml @@ -14,8 +14,11 @@ pom - stat-dto - stats-server + stats-client + aggregator + analyzer + collector + serialization \ No newline at end of file diff --git a/stats/serialization/avro-schemas/pom.xml b/stats/serialization/avro-schemas/pom.xml new file mode 100644 index 0000000..db05527 --- /dev/null +++ b/stats/serialization/avro-schemas/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + ru.practicum + serialization + 0.0.1-SNAPSHOT + + + avro-schemas + jar + + + + org.projectlombok + lombok + provided + + + org.springframework.boot + spring-boot-starter-validation + + + + org.apache.avro + avro + + + org.apache.kafka + kafka-clients + + + com.google.code.findbugs + jsr305 + 3.0.2 + compile + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.avro + avro-maven-plugin + + + schemas + generate-sources + + idl-protocol + + + ${project.basedir}/src/main/avro/ + ${project.build.directory}/generated-sources + String + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources + + + + + + + + + \ No newline at end of file diff --git a/stats/serialization/avro-schemas/src/main/avro/stats/Event.avdl b/stats/serialization/avro-schemas/src/main/avro/stats/Event.avdl new file mode 100644 index 0000000..28bfce1 --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/avro/stats/Event.avdl @@ -0,0 +1,12 @@ +@namespace("ru.practicum.ewm.stats.avro") +protocol EventProtocol { + + /** + * Информация о мероприятии + */ + record EventAvro { + long id; + long categoryId; + } + +} diff --git a/stats/serialization/avro-schemas/src/main/avro/stats/EventsSimilarity.avdl b/stats/serialization/avro-schemas/src/main/avro/stats/EventsSimilarity.avdl new file mode 100644 index 0000000..ec26e31 --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/avro/stats/EventsSimilarity.avdl @@ -0,0 +1,17 @@ +@namespace("ru.practicum.ewm.stats.avro") + +protocol EventsSimilarityProtocol { + + import idl "Event.avdl"; + + /** + * Сходство мероприятий + */ + record EventsSimilarityAvro { + long eventA; + long eventB; + double score; + timestamp_ms timestamp; + } + +} diff --git a/stats/serialization/avro-schemas/src/main/avro/stats/UserAction.avdl b/stats/serialization/avro-schemas/src/main/avro/stats/UserAction.avdl new file mode 100644 index 0000000..6eed764 --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/avro/stats/UserAction.avdl @@ -0,0 +1,17 @@ +@namespace("ru.practicum.ewm.stats.avro") +protocol UserActionProtocol { + + record UserActionAvro { + long userId; + long eventId; + ActionTypeAvro actionType; + timestamp_ms timestamp; + } + + enum ActionTypeAvro { + VIEWS, // Просмотр + REGISTER, // Регистрация + LIKE // Лайк + } + +} diff --git a/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/EventsSimilarityDeserializer.java b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/EventsSimilarityDeserializer.java new file mode 100644 index 0000000..5a5243a --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/EventsSimilarityDeserializer.java @@ -0,0 +1,12 @@ +package ru.practicum.ewm.kafka.deserializer; + +import ru.practicum.ewm.kafka.deserializer.base.BaseAvroDeserializer; +import ru.practicum.ewm.stats.avro.EventsSimilarityAvro; + +@SuppressWarnings("unused") +public class EventsSimilarityDeserializer extends BaseAvroDeserializer { + + public EventsSimilarityDeserializer() { + super(EventsSimilarityAvro.getClassSchema()); + } +} diff --git a/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/UserActionsDeserializer.java b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/UserActionsDeserializer.java new file mode 100644 index 0000000..5af867f --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/UserActionsDeserializer.java @@ -0,0 +1,12 @@ +package ru.practicum.ewm.kafka.deserializer; + +import ru.practicum.ewm.kafka.deserializer.base.BaseAvroDeserializer; +import ru.practicum.ewm.stats.avro.UserActionAvro; + +@SuppressWarnings("unused") +public class UserActionsDeserializer extends BaseAvroDeserializer { + + public UserActionsDeserializer() { + super(UserActionAvro.getClassSchema()); + } +} diff --git a/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/base/BaseAvroDeserializer.java b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/base/BaseAvroDeserializer.java new file mode 100644 index 0000000..463a0b0 --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/base/BaseAvroDeserializer.java @@ -0,0 +1,37 @@ +package ru.practicum.ewm.kafka.deserializer.base; + +import org.apache.avro.Schema; +import org.apache.avro.io.BinaryDecoder; +import org.apache.avro.io.DatumReader; +import org.apache.avro.io.DecoderFactory; +import org.apache.avro.specific.SpecificDatumReader; +import org.apache.avro.specific.SpecificRecordBase; +import org.apache.kafka.common.serialization.Deserializer; + +import javax.annotation.Nonnull; + +public class BaseAvroDeserializer implements Deserializer { + + private final DecoderFactory decoderFactory; + private final DatumReader reader; + + public BaseAvroDeserializer(Schema schema) { + this(DecoderFactory.get(), schema); + } + + public BaseAvroDeserializer(DecoderFactory decoderFactory, Schema schema) { + this.decoderFactory = decoderFactory; + this.reader = new SpecificDatumReader<>(schema); + } + + @Override + public T deserialize(String topic, @Nonnull byte[] data) { + try { + BinaryDecoder decoder = decoderFactory.binaryDecoder(data, null); + return reader.read(null, decoder); + + } catch (Exception e) { + throw new DeserializationException("Ошибка десериализации данных из топика [" + topic + "]", e); + } + } +} \ No newline at end of file diff --git a/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/base/DeserializationException.java b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/base/DeserializationException.java new file mode 100644 index 0000000..8094078 --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/deserializer/base/DeserializationException.java @@ -0,0 +1,9 @@ +package ru.practicum.ewm.kafka.deserializer.base; + +public class DeserializationException extends RuntimeException { + + @SuppressWarnings("unused") + public DeserializationException(String message, Exception e) { + super(message); + } +} diff --git a/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/serializer/GeneralAvroSerializer.java b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/serializer/GeneralAvroSerializer.java new file mode 100644 index 0000000..cd00192 --- /dev/null +++ b/stats/serialization/avro-schemas/src/main/java/ru/practicum/ewm/kafka/serializer/GeneralAvroSerializer.java @@ -0,0 +1,42 @@ +package ru.practicum.ewm.kafka.serializer; + +import org.apache.avro.io.BinaryEncoder; +import org.apache.avro.io.DatumWriter; +import org.apache.avro.io.EncoderFactory; +import org.apache.avro.specific.SpecificDatumWriter; +import org.apache.avro.specific.SpecificRecordBase; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Serializer; + +import java.io.ByteArrayOutputStream; + +@SuppressWarnings("unused") +public class GeneralAvroSerializer implements Serializer { + + private final EncoderFactory encoderFactory = EncoderFactory.get(); + + @Override + public byte[] serialize(String topic, SpecificRecordBase data) { + if (data == null) { + return null; + } + + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + BinaryEncoder encoder = encoderFactory.binaryEncoder(outputStream, null); + DatumWriter datumWriter = new SpecificDatumWriter<>(data.getSchema()); + + datumWriter.setSchema(data.getSchema()); + datumWriter.write(data, encoder); + encoder.flush(); + + return outputStream.toByteArray(); + + } catch (Exception e) { + throw new SerializationException("Ошибка сереализации данных в топик [" + topic + "]", e); + } finally { + close(); + } + } + +} diff --git a/stats/serialization/pom.xml b/stats/serialization/pom.xml new file mode 100644 index 0000000..3387202 --- /dev/null +++ b/stats/serialization/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + + ru.practicum + stats + 0.0.1-SNAPSHOT + + + serialization + pom + + + avro-schemas + proto-schemas + timestamp-utils + + + \ No newline at end of file diff --git a/stats/serialization/proto-schemas/pom.xml b/stats/serialization/proto-schemas/pom.xml new file mode 100644 index 0000000..a2d7b52 --- /dev/null +++ b/stats/serialization/proto-schemas/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + ru.practicum + serialization + 0.0.1-SNAPSHOT + + + proto-schemas + jar + + + + io.grpc + grpc-stub + + + io.grpc + grpc-protobuf + + + + + org.apache.tomcat + annotations-api + 6.0.53 + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.github.ascopes + protobuf-maven-plugin + + + ${protobuf.version} + + + + io.grpc + protoc-gen-grpc-java + ${grpc.version} + + + + + + + + generate + + + + + + + + \ No newline at end of file diff --git a/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/interactions_count_request.proto b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/interactions_count_request.proto new file mode 100644 index 0000000..5fda228 --- /dev/null +++ b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/interactions_count_request.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package stats.messages.analyzer; + +option java_package = "ru.practicum.ewm.stats.proto"; +option java_multiple_files = true; + +// Запрос на получение рекомендуемых мероприятий +message InteractionsCountRequestProto { + + /** + * Идентификаторы мероприятий для которых + * нужно вернуть количество взаимодействий + */ + repeated int64 event_id = 1; +} diff --git a/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/similar_events_request.proto b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/similar_events_request.proto new file mode 100644 index 0000000..4012f02 --- /dev/null +++ b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/similar_events_request.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package stats.messages.analyzer; + +option java_package = "ru.practicum.ewm.stats.proto"; +option java_multiple_files = true; + +// Запрос на получение похожих мероприятий +message SimilarEventsRequestProto { + int64 user_id = 1; + int64 event_id = 2; + int32 max_results = 3; // Ограничение на количество возвращаемых мероприятий +} diff --git a/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/user_predictions_request.proto b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/user_predictions_request.proto new file mode 100644 index 0000000..1c4ffbe --- /dev/null +++ b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/request/user_predictions_request.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package stats.messages.analyzer; + +option java_package = "ru.practicum.ewm.stats.proto"; +option java_multiple_files = true; + +// Запрос на получение рекомендуемых мероприятий +message UserRecommendationsRequestProto { + int64 user_id = 1; + int32 max_results = 2; // Ограничение на количество возвращаемых мероприятий +} diff --git a/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/response/recommended_event.proto b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/response/recommended_event.proto new file mode 100644 index 0000000..0429623 --- /dev/null +++ b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/analyzer/response/recommended_event.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package stats.messages.analyzer; + +option java_package = "ru.practicum.ewm.stats.proto"; +option java_multiple_files = true; + +// Рекомендуемое мероприятие +message RecommendedEventProto { + int64 event_id = 1; + double score = 2; // Коэффициент похожести +} diff --git a/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/collector/user_action.proto b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/collector/user_action.proto new file mode 100644 index 0000000..fb2392d --- /dev/null +++ b/stats/serialization/proto-schemas/src/main/protobuf/stats/messages/collector/user_action.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package stats.messages.collector; + +import "google/protobuf/timestamp.proto"; + +option java_package = "ru.practicum.ewm.stats.proto"; +option java_multiple_files = true; + +message UserActionProto { + int64 user_id = 1; + int64 event_id = 2; + ActionTypeProto action_type = 3; + google.protobuf.Timestamp timestamp = 4; +} + +// тип действия +enum ActionTypeProto { + ACTION_VIEW = 0; // просмотр страницы мероприятия + ACTION_REGISTER = 1; // заявка на участие в мероприятии + ACTION_LIKE = 2; // лайк мероприятию +} diff --git "a/stats/serialization/proto-schemas/src/main/protobuf/stats/servi\321\201e/analyzer/recommendations_controller.proto" "b/stats/serialization/proto-schemas/src/main/protobuf/stats/servi\321\201e/analyzer/recommendations_controller.proto" new file mode 100644 index 0000000..b00c8af --- /dev/null +++ "b/stats/serialization/proto-schemas/src/main/protobuf/stats/servi\321\201e/analyzer/recommendations_controller.proto" @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package stats.service.dashboard; + +import "stats/messages/analyzer/request/similar_events_request.proto"; +import "stats/messages/analyzer/request/user_predictions_request.proto"; +import "stats/messages/analyzer/request/interactions_count_request.proto"; + +import "stats/messages/analyzer/response/recommended_event.proto"; + +option java_package = "ru.practicum.ewm.stats.proto"; + +service RecommendationsController { + + // Получить рекомендации для пользователя + rpc GetRecomendationsForUser (stats.messages.analyzer.UserRecommendationsRequestProto) + returns (stream stats.messages.analyzer.RecommendedEventProto); + + // Получить похожие по мероприятиям + rpc GetSimilarEvents (stats.messages.analyzer.SimilarEventsRequestProto) + returns (stream stats.messages.analyzer.RecommendedEventProto); + + // Получить количество взаимодействий + rpc GetInteractionsCount (stats.messages.analyzer.InteractionsCountRequestProto) + returns (stream stats.messages.analyzer.RecommendedEventProto); +} diff --git "a/stats/serialization/proto-schemas/src/main/protobuf/stats/servi\321\201e/collector/user_action_controller.proto" "b/stats/serialization/proto-schemas/src/main/protobuf/stats/servi\321\201e/collector/user_action_controller.proto" new file mode 100644 index 0000000..9682c1f --- /dev/null +++ "b/stats/serialization/proto-schemas/src/main/protobuf/stats/servi\321\201e/collector/user_action_controller.proto" @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package stats.service.collector; + +import "stats/messages/collector/user_action.proto"; +import "google/protobuf/empty.proto"; + +option java_package = "ru.practicum.ewm.stats.proto"; + +service UserActionController { + + // Сохранить действие пользователя + rpc CollectUserAction (stats.messages.collector.UserActionProto) + returns (google.protobuf.Empty); +} diff --git a/stats/serialization/timestamp-utils/pom.xml b/stats/serialization/timestamp-utils/pom.xml new file mode 100644 index 0000000..d47e615 --- /dev/null +++ b/stats/serialization/timestamp-utils/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + ru.practicum + serialization + 0.0.1-SNAPSHOT + + + timestamp-utils + jar + + + + org.projectlombok + lombok + provided + + + com.google.protobuf + protobuf-java + ${protobuf.version} + compile + + + + \ No newline at end of file diff --git a/stats/serialization/timestamp-utils/src/main/java/ru/yandex/practicum/telemetry/utils/TimestampUtils.java b/stats/serialization/timestamp-utils/src/main/java/ru/yandex/practicum/telemetry/utils/TimestampUtils.java new file mode 100644 index 0000000..e7295bf --- /dev/null +++ b/stats/serialization/timestamp-utils/src/main/java/ru/yandex/practicum/telemetry/utils/TimestampUtils.java @@ -0,0 +1,64 @@ +package ru.yandex.practicum.telemetry.utils; + +import com.google.protobuf.Timestamp; +import lombok.NonNull; +import lombok.experimental.UtilityClass; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; + +@UtilityClass +@SuppressWarnings("unused") +public class TimestampUtils { + + private final ZoneId zoneId = ZoneId.systemDefault(); + + public final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + public Timestamp toTimestamp(@NonNull Instant instant) { + return Timestamp.newBuilder() + .setSeconds(instant.getEpochSecond()) + .setNanos(instant.getNano()) + .build(); + } + + public Timestamp toTimestamp(@NonNull LocalDateTime localDateTime) { + return toTimestamp(localDateTime.atZone(zoneId).toInstant()); + } + + public Instant toInstant(@NonNull Timestamp timestamp) { + return Instant.ofEpochSecond( + timestamp.getSeconds(), + timestamp.getNanos() + ); + } + + public Instant toInstant(@NonNull LocalDateTime localDateTime) { + return toInstant(toTimestamp(localDateTime)); + } + + public LocalDateTime toLocalDateTime(@NonNull Timestamp timestamp) { + return LocalDateTime.ofInstant(TimestampUtils.toInstant(timestamp), zoneId) + .truncatedTo(ChronoUnit.MILLIS); + } + + public LocalDateTime toLocalDateTime(long timestamp) { + return toLocalDateTime(TimestampUtils.toTimestamp(Instant.ofEpochSecond(timestamp))) + .truncatedTo(ChronoUnit.MILLIS); + } + + public LocalDateTime toLocalDateTime(@NonNull Instant instant) { + return toLocalDateTime(TimestampUtils.toTimestamp(instant)); + } + + public String toString(@NonNull Timestamp timestamp) { + return formatter.format(toLocalDateTime(timestamp)); + } + + public String toString(long longTimestamp) { + return toString(toTimestamp(Instant.ofEpochSecond(longTimestamp).atZone(zoneId).toInstant())); + } +} diff --git a/stats/stat-dto/pom.xml b/stats/stat-dto/pom.xml deleted file mode 100644 index 3a9f1c0..0000000 --- a/stats/stat-dto/pom.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - 4.0.0 - - - ru.practicum - stats - 0.0.1-SNAPSHOT - - - stat-dto - jar - - - - org.projectlombok - lombok - true - - - com.fasterxml.jackson.core - jackson-annotations - - - jakarta.validation - jakarta.validation-api - 3.0.2 - compile - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - \ No newline at end of file diff --git a/stats/stat-dto/src/main/java/ru/practicum/stat/dto/EndpointHitDto.java b/stats/stat-dto/src/main/java/ru/practicum/stat/dto/EndpointHitDto.java deleted file mode 100644 index 4883b3a..0000000 --- a/stats/stat-dto/src/main/java/ru/practicum/stat/dto/EndpointHitDto.java +++ /dev/null @@ -1,38 +0,0 @@ -package ru.practicum.stat.dto; - -import com.fasterxml.jackson.annotation.JsonFormat; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class EndpointHitDto { - - @NotBlank(message = "Название приложения не может быть пустым") - @Size(max = 255, message = "APP слишком длинный (макс. 255 символов)") - private String app; - - @NotBlank(message = "URI не может быть пустым") - @Size(max = 512, message = "URI слишком длинный (макс. 512 символов)") - private String uri; - - @NotBlank(message = "IP-адрес не может быть пустым") - @Size(max = 45, message = "IP слишком длинный (макс. 45 символов)") - private String ip; - - @NotNull(message = "TIMESTAMP является обязательным") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime timestamp; - - @Builder.Default - private String actionType = "VIEW"; // VIEW, LIKE, DISLIKE для рейтингов -} diff --git a/stats/stat-dto/src/main/java/ru/practicum/stat/dto/ViewStatsDto.java b/stats/stat-dto/src/main/java/ru/practicum/stat/dto/ViewStatsDto.java deleted file mode 100644 index 75e35e9..0000000 --- a/stats/stat-dto/src/main/java/ru/practicum/stat/dto/ViewStatsDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package ru.practicum.stat.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ViewStatsDto { - private String app; - private String uri; - private Long hits; -} diff --git a/stats/stats-client/pom.xml b/stats/stats-client/pom.xml new file mode 100644 index 0000000..afb9f20 --- /dev/null +++ b/stats/stats-client/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + ru.practicum + stats + 0.0.1-SNAPSHOT + + + stats-client + jar + + + + ru.practicum + proto-schemas + 0.0.1-SNAPSHOT + compile + + + ru.practicum + timestamp-utils + 0.0.1-SNAPSHOT + + + + org.projectlombok + lombok + provided + + + + net.devh + grpc-client-spring-boot-starter + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + + \ No newline at end of file diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/ActionType.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/ActionType.java new file mode 100644 index 0000000..515de7d --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/ActionType.java @@ -0,0 +1,7 @@ +package ru.practicum.stats.client.dto; + +public enum ActionType { + VIEWS, // Просмотр + REGISTER, // Регистрация + LIKE // Лайк +} diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/ActionsWeightsSum.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/ActionsWeightsSum.java new file mode 100644 index 0000000..3143943 --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/ActionsWeightsSum.java @@ -0,0 +1,10 @@ +package ru.practicum.stats.client.dto; + +import lombok.Builder; + +@Builder +public record ActionsWeightsSum( + long eventId, + double score +) { +} diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/PredictedScore.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/PredictedScore.java new file mode 100644 index 0000000..d67bf4b --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/PredictedScore.java @@ -0,0 +1,13 @@ +package ru.practicum.stats.client.dto; + +import lombok.Builder; + +/** + * Предсказание о возможном понравившемся событии + */ +@Builder +public record PredictedScore( + long eventId, + double score +) { +} diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/SimilarityCoefficient.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/SimilarityCoefficient.java new file mode 100644 index 0000000..65e3950 --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/SimilarityCoefficient.java @@ -0,0 +1,10 @@ +package ru.practicum.stats.client.dto; + +import lombok.Builder; + +@Builder +public record SimilarityCoefficient( + long eventId, + double score +) { +} diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/UserActionDto.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/UserActionDto.java new file mode 100644 index 0000000..315d3cf --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/dto/UserActionDto.java @@ -0,0 +1,14 @@ +package ru.practicum.stats.client.dto; + +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record UserActionDto( + long userId, + long eventId, + ActionType actionType, + LocalDateTime timestamp +) { +} diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/grpc/AnalyzerClient.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/grpc/AnalyzerClient.java new file mode 100644 index 0000000..beeda3f --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/grpc/AnalyzerClient.java @@ -0,0 +1,92 @@ +package ru.practicum.stats.client.grpc; + +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.proto.InteractionsCountRequestProto; +import ru.practicum.ewm.stats.proto.RecommendationsControllerGrpc.RecommendationsControllerBlockingStub; +import ru.practicum.ewm.stats.proto.RecommendedEventProto; +import ru.practicum.ewm.stats.proto.SimilarEventsRequestProto; +import ru.practicum.ewm.stats.proto.UserRecommendationsRequestProto; +import ru.practicum.stats.client.dto.ActionsWeightsSum; +import ru.practicum.stats.client.dto.PredictedScore; +import ru.practicum.stats.client.dto.SimilarityCoefficient; + +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +@SuppressWarnings("unused") +@Slf4j +@Component +public class AnalyzerClient { + + @SuppressWarnings("unused") + @GrpcClient("analyzer") + private RecommendationsControllerBlockingStub client; + + // коэффициент сходства + public List getSimilarEvents(long eventId, long userId, int maxResults) { + SimilarEventsRequestProto request = SimilarEventsRequestProto.newBuilder() + .setEventId(eventId) + .setUserId(userId) + .setMaxResults(maxResults) + .build(); + + Iterator iterator = client.getSimilarEvents(request); + + return asStream(iterator).map(proto -> + SimilarityCoefficient.builder() + .eventId(proto.getEventId()) + .score(proto.getScore()) + .build() + ) + .toList(); + } + + // предсказанная оценка + public List getRecomendationsForUser(long userId, int maxResults) { + var request = UserRecommendationsRequestProto.newBuilder() + .setUserId(userId) + .setMaxResults(maxResults) + .build(); + Iterator iterator = client.getRecomendationsForUser(request); + return asStream(iterator).map(recommendedEventProto -> + PredictedScore.builder() + .eventId(recommendedEventProto.getEventId()) + .score(recommendedEventProto.getScore()) + .build() + ) + .toList(); + } + + // сумма весов действий с указанным мероприятием + public List getInteractionsCount(List eventIds) { + InteractionsCountRequestProto request = InteractionsCountRequestProto.newBuilder() + .addAllEventId(eventIds) + .build(); + + Iterator iterator = client.getInteractionsCount(request); + + return asStream(iterator).map(proto -> + ActionsWeightsSum.builder() + .eventId(proto.getEventId()) + .score(proto.getScore()) + .build() + ) + .toList(); + } + + @NonNull + private Stream asStream(Iterator iterator) { + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), + false + ); + } + +} \ No newline at end of file diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/grpc/CollectorClient.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/grpc/CollectorClient.java new file mode 100644 index 0000000..8799a34 --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/grpc/CollectorClient.java @@ -0,0 +1,47 @@ +package ru.practicum.stats.client.grpc; + +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.Empty; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import ru.practicum.ewm.stats.proto.UserActionControllerGrpc.UserActionControllerFutureStub; +import ru.practicum.ewm.stats.proto.UserActionProto; +import ru.practicum.stats.client.dto.UserActionDto; +import ru.practicum.stats.client.mapper.ActionTypeMapper; +import ru.yandex.practicum.telemetry.utils.TimestampUtils; + +import java.util.concurrent.CompletableFuture; + +@SuppressWarnings("unused") +@Slf4j +@Component +public class CollectorClient { + + @GrpcClient("collector") + private UserActionControllerFutureStub futureStub; + + public void collectUserAction(@NonNull UserActionDto dto) { + var request = UserActionProto.newBuilder() + .setUserId(dto.userId()) + .setEventId(dto.eventId()) + .setActionType(ActionTypeMapper.toProto(dto.actionType())) + .setTimestamp(TimestampUtils.toTimestamp(dto.timestamp())) + .build(); + + ListenableFuture listenableFuture = futureStub.collectUserAction(request); + + CompletableFuture completableFuture = new CompletableFuture<>(); + + listenableFuture.addListener(() -> { + try { + completableFuture.complete(listenableFuture.get()); + } catch (Exception e) { + completableFuture.completeExceptionally(e); + } + }, MoreExecutors.directExecutor()); + } + +} diff --git a/stats/stats-client/src/main/java/ru/practicum/stats/client/mapper/ActionTypeMapper.java b/stats/stats-client/src/main/java/ru/practicum/stats/client/mapper/ActionTypeMapper.java new file mode 100644 index 0000000..5d2107b --- /dev/null +++ b/stats/stats-client/src/main/java/ru/practicum/stats/client/mapper/ActionTypeMapper.java @@ -0,0 +1,27 @@ +package ru.practicum.stats.client.mapper; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; +import ru.practicum.ewm.stats.proto.ActionTypeProto; +import ru.practicum.stats.client.dto.ActionType; + +@UtilityClass +public class ActionTypeMapper { + + public ActionType toActionType(@NonNull ActionTypeProto proto) { + return switch (proto) { + case ACTION_VIEW -> ActionType.VIEWS; + case ACTION_REGISTER -> ActionType.REGISTER; + case ACTION_LIKE -> ActionType.LIKE; + case UNRECOGNIZED -> throw new IllegalArgumentException("Action type is UNRECOGNIZED"); + }; + } + + public ActionTypeProto toProto(@NonNull ActionType actionType) { + return switch (actionType) { + case VIEWS -> ActionTypeProto.ACTION_VIEW; + case REGISTER -> ActionTypeProto.ACTION_REGISTER; + case LIKE -> ActionTypeProto.ACTION_LIKE; + }; + } +} diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/config/JacksonConfiguration.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/config/JacksonConfiguration.java deleted file mode 100644 index dec31a4..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/config/JacksonConfiguration.java +++ /dev/null @@ -1,28 +0,0 @@ -package ru.practicum.stat.server.config; - -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; -import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; -import org.springframework.context.annotation.Bean; - -import java.time.format.DateTimeFormatter; - -@SpringBootConfiguration -public class JacksonConfiguration { - - @Bean - public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { - String pattern = "yyyy-MM-dd HH:mm:ss"; - return builder -> { - builder.simpleDateFormat(pattern); - builder.serializers(new LocalDateTimeSerializer( - DateTimeFormatter.ofPattern(pattern) - )); - builder.deserializers(new LocalDateTimeDeserializer( - DateTimeFormatter.ofPattern(pattern) - )); - }; - } - -} \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/config/WebConfig.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/config/WebConfig.java deleted file mode 100644 index 61bd1da..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/config/WebConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package ru.practicum.stat.server.config; - -import org.jspecify.annotations.NonNull; -import org.springframework.context.annotation.Configuration; -import org.springframework.format.FormatterRegistry; -import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.time.format.DateTimeFormatter; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Override - public void addFormatters(@NonNull FormatterRegistry registry) { - DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); - registrar.setUseIsoFormat(false); - registrar.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - registrar.registerFormatters(registry); - } -} \ No newline at end of file diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/controller/StatController.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/controller/StatController.java deleted file mode 100644 index 8434216..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/controller/StatController.java +++ /dev/null @@ -1,48 +0,0 @@ -package ru.practicum.stat.server.controller; - -import jakarta.validation.Valid; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; -import ru.practicum.stat.dto.EndpointHitDto; -import ru.practicum.stat.dto.ViewStatsDto; -import ru.practicum.stat.server.service.StatService; - -import java.time.LocalDateTime; -import java.util.List; - -@Slf4j -@RestController -@RequiredArgsConstructor -public class StatController { - - private final StatService statService; - - @PostMapping("/hit") - @ResponseStatus(HttpStatus.CREATED) - public void hit(@NonNull @Valid @RequestBody EndpointHitDto endpointHitDto) { - log.info("Сохранение статистики: сервис={}, uri={}, ip={}", - endpointHitDto.getApp(), endpointHitDto.getUri(), endpointHitDto.getIp()); - statService.saveHit(endpointHitDto); - } - - @GetMapping("/stats") - public List getStats( - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end, - @RequestParam(required = false) List uris, - @RequestParam(defaultValue = "false") boolean unique - ) { - log.info("Запрос статистики: start={}, end={}, uris={}, unique={}", start, end, uris, unique); - - if (start.isAfter(end)) { - log.warn("Некорректный диапазон дат: start={} позже end={}", start, end); - throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); - } - - return statService.getStats(start, end, uris, unique); - } -} diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/entity/EndpointHit.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/entity/EndpointHit.java deleted file mode 100644 index 120b5cb..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/entity/EndpointHit.java +++ /dev/null @@ -1,44 +0,0 @@ -package ru.practicum.stat.server.entity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "stats") -@Getter -@Setter -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class EndpointHit { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String app; - - @Column(nullable = false, length = 512) - private String uri; - - @Column(nullable = false, length = 45) - private String ip; - - @Column(name = "hit_timestamp", nullable = false) - private LocalDateTime timestamp; - - @Override - public String toString() { - return """ - { - "id": "%s", - "app": "%s", - "uri": "%s", - "ip": "%s", - "timestamp": "%s" - }""".formatted(id, app, uri, ip, timestamp); - } -} diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/error/ApiError.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/error/ApiError.java deleted file mode 100644 index 389e94d..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/error/ApiError.java +++ /dev/null @@ -1,25 +0,0 @@ -package ru.practicum.stat.server.error; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.experimental.FieldDefaults; -import org.springframework.http.HttpStatus; -import java.time.LocalDateTime; - -@Getter -@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class ApiError { - private HttpStatus status; - private String reason; - private String message; - private String errors; - private LocalDateTime timestamp = LocalDateTime.now(); - - public ApiError(HttpStatus status, String reason, String message, String stackTrace) { - this.status = status; - this.reason = reason; - this.message = message; - this.errors = stackTrace; - } -} - diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/error/StatErrorHandler.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/error/StatErrorHandler.java deleted file mode 100644 index 73c0155..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/error/StatErrorHandler.java +++ /dev/null @@ -1,52 +0,0 @@ -package ru.practicum.stat.server.error; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.lang.NonNull; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.io.PrintWriter; -import java.io.StringWriter; - -@Slf4j -@RestControllerAdvice -public class StatErrorHandler { - - @ExceptionHandler({ - MethodArgumentNotValidException.class, - IllegalArgumentException.class, - MissingServletRequestParameterException.class, - MethodArgumentTypeMismatchException.class - }) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ApiError handleBadRequest(@NonNull final Exception e) { - log.error("400 Bad Request: {}", e.getMessage()); - return new ApiError( - HttpStatus.BAD_REQUEST, - "Bad Request", - e.getMessage(), - getStackTrace(e) - ); - } - - private String getStackTrace(@NonNull Exception e) { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - e.printStackTrace(pw); - return sw.toString(); - } - - @ExceptionHandler - @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public ApiError handleException(Exception e) { - log.info("500 {}", e.getMessage(), e); - String stackTrace = getStackTrace(e); - return new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, - "Error ....", e.getMessage(), stackTrace); - } -} diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/mapper/EndpointHitMapper.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/mapper/EndpointHitMapper.java deleted file mode 100644 index dfe0e33..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/mapper/EndpointHitMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package ru.practicum.stat.server.mapper; - -import org.springframework.lang.NonNull; -import ru.practicum.stat.dto.EndpointHitDto; -import ru.practicum.stat.server.entity.EndpointHit; - -import java.time.temporal.ChronoUnit; - -public class EndpointHitMapper { - - public static EndpointHit toEntity(@NonNull EndpointHitDto dto) { - return EndpointHit.builder() - .app(dto.getApp()) - .uri(dto.getUri()) - .ip(dto.getIp()) - .timestamp(dto.getTimestamp().truncatedTo(ChronoUnit.SECONDS)) - .build(); - } -} diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/repository/StatRepository.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/repository/StatRepository.java deleted file mode 100644 index 18b4294..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/repository/StatRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package ru.practicum.stat.server.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import ru.practicum.stat.dto.ViewStatsDto; -import ru.practicum.stat.server.entity.EndpointHit; - -import java.time.LocalDateTime; -import java.util.List; - -public interface StatRepository extends JpaRepository { - - @Query("SELECT new ru.practicum.stat.dto.ViewStatsDto(e.app, e.uri, COUNT(e.ip)) " + - "FROM EndpointHit e " + - "WHERE e.timestamp BETWEEN :start AND :end " + - "AND (:uris IS NULL OR e.uri IN :uris) " + - "GROUP BY e.app, e.uri " + - "ORDER BY COUNT(e.ip) DESC") - List getStats(@Param("start") LocalDateTime start, - @Param("end") LocalDateTime end, - @Param("uris") List uris); - - @Query("SELECT new ru.practicum.stat.dto.ViewStatsDto(e.app, e.uri, COUNT(DISTINCT e.ip)) " + - "FROM EndpointHit e " + - "WHERE e.timestamp BETWEEN :start AND :end " + - "AND (:uris IS NULL OR e.uri IN :uris) " + - "GROUP BY e.app, e.uri " + - "ORDER BY COUNT(DISTINCT e.ip) DESC") - List getStatsUnique(@Param("start") LocalDateTime start, - @Param("end") LocalDateTime end, - @Param("uris") List uris); -} diff --git a/stats/stats-server/src/main/java/ru/practicum/stat/server/service/StatService.java b/stats/stats-server/src/main/java/ru/practicum/stat/server/service/StatService.java deleted file mode 100644 index 48bd30f..0000000 --- a/stats/stats-server/src/main/java/ru/practicum/stat/server/service/StatService.java +++ /dev/null @@ -1,42 +0,0 @@ -package ru.practicum.stat.server.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import ru.practicum.stat.dto.EndpointHitDto; -import ru.practicum.stat.dto.ViewStatsDto; -import ru.practicum.stat.server.mapper.EndpointHitMapper; -import ru.practicum.stat.server.repository.StatRepository; - -import java.time.LocalDateTime; -import java.util.List; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class StatService { - - private final StatRepository statRepository; - - @Transactional - public void saveHit(@NonNull EndpointHitDto endpointHitDto) { - log.debug("Сохранение записи о просмотре для URI: {}", endpointHitDto.getUri()); - statRepository.save(EndpointHitMapper.toEntity(endpointHitDto)); - } - - public List getStats(LocalDateTime start, - LocalDateTime end, - List uris, - boolean unique) { - if (unique) { - log.debug("Получение статистики (уникальные IP) для URI: {}", uris); - return statRepository.getStatsUnique(start, end, uris); - } - - log.debug("Получение общей статистики для URI: {}", uris); - return statRepository.getStats(start, end, uris); - } -} diff --git a/stats/stats-server/src/main/resources/application-test.yaml.bak b/stats/stats-server/src/main/resources/application-test.yaml.bak deleted file mode 100644 index 0a6d29e..0000000 --- a/stats/stats-server/src/main/resources/application-test.yaml.bak +++ /dev/null @@ -1,12 +0,0 @@ -spring: - datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:ewm-stats - jpa: - hibernate: - ddl-auto: none - show-sql: true - -logging: - level: - org.springframework.cloud: DEBUG \ No newline at end of file diff --git a/stats/stats-server/src/main/resources/schema-postgres.sql b/stats/stats-server/src/main/resources/schema-postgres.sql deleted file mode 100644 index 37cf885..0000000 --- a/stats/stats-server/src/main/resources/schema-postgres.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS stats ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - app VARCHAR(255) NOT NULL, - uri VARCHAR(512) NOT NULL, - ip VARCHAR(45) NOT NULL, - hit_timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL - ); - -CREATE INDEX IF NOT EXISTS idx_endpoint_hits_stats ON stats(hit_timestamp, uri, ip); \ No newline at end of file