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 @@
+
\ 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 @@


-
-
+

+
+



-
## Бэкэнд для сервиса поиска мероприятий
@@ -17,28 +17,197 @@
configuration files location: infra/config-repo
### Microservice map
-
+
+
+
+### Calculate similarities map
+
+
+
+### 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
-
-
+```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
-
+```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
-
+ REQUESTS {
+ bigint id PK
+ timestamp created
+ varchar status
+ bigint event_id FK
+ bigint requester_id FK
+ }
-ewm-main/rating-service
-
+ USERS {
+ bigint id PK
+ varchar email
+ varchar name
+ }
-ewm-main/request-service
-
+ INTERACTIONS {
+ bigint id PK
+ bigint user_id FK
+ bigint event_id FK
+ numeric action_weight
+ }
-ewm-main/user-service
-
+ SIMILARITIES {
+ bigint id PK
+ bigint event_a FK
+ bigint event_b FK
+ numeric score
+ }
-ewm-stats/stats-server
-
\ 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