diff --git a/docker-compose.yml b/docker-compose.yml
index be96142..db8c301 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,14 +1,56 @@
services:
+ stats-db:
+ image: postgres:16.1
+ container_name: stats-db
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_DB: statsdb
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: password
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
stats-server:
+ build: ./statistics/server
+ container_name: stats-server
ports:
- "9090:9090"
+ depends_on:
+ stats-db:
+ condition: service_healthy
+ environment:
+ STATS_DB_URL: jdbc:postgresql://stats-db:5432/statsdb
+ STATS_DB_USER: postgres
+ STATS_DB_PASSWORD: password
- stats-db:
+ ewm-db:
image: postgres:16.1
+ container_name: ewm-db
+ ports:
+ - "5433:5432"
+ environment:
+ POSTGRES_DB: ewmdb
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: password
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
ewm-service:
+ build: ./ewm-service
+ container_name: ewm-service
ports:
- "8080:8080"
-
- ewm-db:
- image: postgres:16.1
+ depends_on:
+ ewm-db:
+ condition: service_healthy
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://ewm-db:5432/ewmdb
+ SPRING_DATASOURCE_USERNAME: postgres
+ SPRING_DATASOURCE_PASSWORD: password
\ No newline at end of file
diff --git a/ewm-service/Dockerfile b/ewm-service/Dockerfile
new file mode 100644
index 0000000..0ff1817
--- /dev/null
+++ b/ewm-service/Dockerfile
@@ -0,0 +1,5 @@
+FROM eclipse-temurin:21-jre-jammy
+VOLUME /tmp
+ARG JAR_FILE=target/*.jar
+COPY ${JAR_FILE} app.jar
+ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"]
\ No newline at end of file
diff --git a/ewm-service/pom.xml b/ewm-service/pom.xml
new file mode 100644
index 0000000..ec1add2
--- /dev/null
+++ b/ewm-service/pom.xml
@@ -0,0 +1,40 @@
+
+
+ 4.0.0
+
+
+ ru.practicum
+ explore-with-me
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ ewm-service
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
\ No newline at end of file
diff --git a/ewm-service/src/main/java/ru/practicum/ewm/EwmServiceApp.java b/ewm-service/src/main/java/ru/practicum/ewm/EwmServiceApp.java
new file mode 100644
index 0000000..6345abd
--- /dev/null
+++ b/ewm-service/src/main/java/ru/practicum/ewm/EwmServiceApp.java
@@ -0,0 +1,11 @@
+package ru.practicum.ewm;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class EwmServiceApp {
+ public static void main(String[] args) {
+ SpringApplication.run(EwmServiceApp.class, args);
+ }
+}
\ No newline at end of file
diff --git a/ewm-service/src/main/resources/application.properties b/ewm-service/src/main/resources/application.properties
new file mode 100644
index 0000000..055ebb3
--- /dev/null
+++ b/ewm-service/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+server.port=8080
+spring.application.name=ewm-service
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index b15acb2..23a1372 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,6 +22,11 @@
UTF-8
+
+ statistics
+ ewm-service
+
+
diff --git a/statistics/client/pom.xml b/statistics/client/pom.xml
new file mode 100644
index 0000000..a311cdc
--- /dev/null
+++ b/statistics/client/pom.xml
@@ -0,0 +1,36 @@
+
+
+ 4.0.0
+
+
+ ru.practicum
+ statistics
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ statistics-client
+
+
+
+ ru.practicum
+ statistics-dto
+ ${project.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
\ No newline at end of file
diff --git a/statistics/client/src/main/java/ru/practicum/statistics/client/StatsClient.java b/statistics/client/src/main/java/ru/practicum/statistics/client/StatsClient.java
new file mode 100644
index 0000000..3d5ff58
--- /dev/null
+++ b/statistics/client/src/main/java/ru/practicum/statistics/client/StatsClient.java
@@ -0,0 +1,54 @@
+package ru.practicum.statistics.client;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+import ru.practicum.statistics.dto.EndpointHit;
+import ru.practicum.statistics.dto.ViewStats;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
+
+@Service
+public class StatsClient {
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final RestTemplate rest;
+ private final String serverUrl;
+
+ public StatsClient(@Value("${stats-server.url:http://localhost:9090}") String serverUrl,
+ RestTemplateBuilder builder) {
+ this.serverUrl = serverUrl;
+ this.rest = builder.build();
+ }
+
+ public void sendHit(EndpointHit hit) {
+ HttpEntity requestEntity = new HttpEntity<>(hit);
+ rest.exchange(serverUrl + "/hit", HttpMethod.POST, requestEntity, Void.class);
+ }
+
+ public List getStats(LocalDateTime start, LocalDateTime end, List uris, boolean unique) {
+ String startStr = URLEncoder.encode(start.format(DATE_TIME_FORMATTER), StandardCharsets.UTF_8);
+ String endStr = URLEncoder.encode(end.format(DATE_TIME_FORMATTER), StandardCharsets.UTF_8);
+
+ UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(serverUrl + "/stats")
+ .queryParam("start", startStr)
+ .queryParam("end", endStr)
+ .queryParam("unique", unique);
+ if (uris != null && !uris.isEmpty()) {
+ for (String uri : uris) {
+ builder.queryParam("uris", uri);
+ }
+ }
+
+ ResponseEntity response = rest.getForEntity(builder.build().toUriString(), ViewStats[].class);
+ return Arrays.asList(response.getBody());
+ }
+}
\ No newline at end of file
diff --git a/statistics/dto/pom.xml b/statistics/dto/pom.xml
new file mode 100644
index 0000000..c22aa54
--- /dev/null
+++ b/statistics/dto/pom.xml
@@ -0,0 +1,31 @@
+
+
+ 4.0.0
+
+
+ ru.practicum
+ statistics
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ statistics-dto
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+
+
+ jakarta.validation
+ jakarta.validation-api
+
+
+
\ No newline at end of file
diff --git a/statistics/dto/src/main/java/ru/practicum/statistics/dto/EndpointHit.java b/statistics/dto/src/main/java/ru/practicum/statistics/dto/EndpointHit.java
new file mode 100644
index 0000000..5f47f50
--- /dev/null
+++ b/statistics/dto/src/main/java/ru/practicum/statistics/dto/EndpointHit.java
@@ -0,0 +1,30 @@
+package ru.practicum.statistics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class EndpointHit {
+ private Long id;
+
+ @NotBlank(message = "Название приложения не может быть пустым")
+ private String app;
+
+ @NotBlank(message = "URI не может быть пустым")
+ private String uri;
+
+ @NotBlank(message = "IP-адрес не может быть пустым")
+ private String ip;
+
+ @NotNull(message = "Временная метка не может быть null")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime timestamp;
+}
\ No newline at end of file
diff --git a/statistics/dto/src/main/java/ru/practicum/statistics/dto/ViewStats.java b/statistics/dto/src/main/java/ru/practicum/statistics/dto/ViewStats.java
new file mode 100644
index 0000000..079d83c
--- /dev/null
+++ b/statistics/dto/src/main/java/ru/practicum/statistics/dto/ViewStats.java
@@ -0,0 +1,14 @@
+package ru.practicum.statistics.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ViewStats {
+ private String app;
+ private String uri;
+ private Long hits;
+}
\ No newline at end of file
diff --git a/statistics/pom.xml b/statistics/pom.xml
new file mode 100644
index 0000000..afb69c6
--- /dev/null
+++ b/statistics/pom.xml
@@ -0,0 +1,22 @@
+
+
+ 4.0.0
+
+
+ ru.practicum
+ explore-with-me
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ statistics
+ pom
+
+
+ dto
+ client
+ server
+
+
\ No newline at end of file
diff --git a/statistics/server/Dockerfile b/statistics/server/Dockerfile
new file mode 100644
index 0000000..0ff1817
--- /dev/null
+++ b/statistics/server/Dockerfile
@@ -0,0 +1,5 @@
+FROM eclipse-temurin:21-jre-jammy
+VOLUME /tmp
+ARG JAR_FILE=target/*.jar
+COPY ${JAR_FILE} app.jar
+ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"]
\ No newline at end of file
diff --git a/statistics/server/pom.xml b/statistics/server/pom.xml
new file mode 100644
index 0000000..45706f5
--- /dev/null
+++ b/statistics/server/pom.xml
@@ -0,0 +1,69 @@
+
+
+ 4.0.0
+
+
+ ru.practicum
+ statistics
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ statistics-server
+
+
+
+ ru.practicum
+ statistics-dto
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ com.h2database
+ h2
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
\ No newline at end of file
diff --git a/statistics/server/src/main/java/ru/practicum/statistics/StatsServerApp.java b/statistics/server/src/main/java/ru/practicum/statistics/StatsServerApp.java
new file mode 100644
index 0000000..df27dc0
--- /dev/null
+++ b/statistics/server/src/main/java/ru/practicum/statistics/StatsServerApp.java
@@ -0,0 +1,11 @@
+package ru.practicum.statistics;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class StatsServerApp {
+ public static void main(String[] args) {
+ SpringApplication.run(StatsServerApp.class, args);
+ }
+}
\ No newline at end of file
diff --git a/statistics/server/src/main/java/ru/practicum/statistics/controller/StatsController.java b/statistics/server/src/main/java/ru/practicum/statistics/controller/StatsController.java
new file mode 100644
index 0000000..6daeb85
--- /dev/null
+++ b/statistics/server/src/main/java/ru/practicum/statistics/controller/StatsController.java
@@ -0,0 +1,40 @@
+package ru.practicum.statistics.controller;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import ru.practicum.statistics.dto.EndpointHit;
+import ru.practicum.statistics.dto.ViewStats;
+import ru.practicum.statistics.service.StatsService;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+public class StatsController {
+ private final StatsService statsService;
+
+ @PostMapping("/hit")
+ public ResponseEntity hit(@Valid @RequestBody EndpointHit hit) {
+ statsService.saveHit(hit);
+ return ResponseEntity.status(HttpStatus.CREATED).build();
+ }
+
+ @GetMapping("/stats")
+ public List getStats(
+ @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @NotNull LocalDateTime start,
+ @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @NotNull LocalDateTime end,
+ @RequestParam(required = false) List uris,
+ @RequestParam(defaultValue = "false") boolean unique) {
+
+ if (start.isAfter(end)) {
+ throw new IllegalArgumentException("Дата начала должна быть раньше даты окончания.");
+ }
+ return statsService.getStats(start, end, uris, unique);
+ }
+}
\ No newline at end of file
diff --git a/statistics/server/src/main/java/ru/practicum/statistics/exception/ErrorHandler.java b/statistics/server/src/main/java/ru/practicum/statistics/exception/ErrorHandler.java
new file mode 100644
index 0000000..3438870
--- /dev/null
+++ b/statistics/server/src/main/java/ru/practicum/statistics/exception/ErrorHandler.java
@@ -0,0 +1,49 @@
+package ru.practicum.statistics.exception;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+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;
+
+@RestControllerAdvice
+public class ErrorHandler {
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ErrorResponse handleIllegalArgument(IllegalArgumentException e) {
+ return new ErrorResponse("Ошибка валидации", e.getMessage());
+ }
+
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ErrorResponse handleTypeMismatch(MethodArgumentTypeMismatchException e) {
+ return new ErrorResponse("Ошибка валидации", "Некорректное значение параметра: " + e.getValue());
+ }
+
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ErrorResponse handleException(Exception e) {
+ return new ErrorResponse("Внутренняя ошибка сервера", e.getMessage());
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
+ String errorMessage = e.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .findFirst()
+ .orElse("Ошибка валидации");
+ return new ErrorResponse("Ошибка валидации", errorMessage);
+ }
+
+ @Getter
+ @AllArgsConstructor
+ static class ErrorResponse {
+ private final String error;
+ private final String description;
+ }
+}
\ No newline at end of file
diff --git a/statistics/server/src/main/java/ru/practicum/statistics/mapper/HitMapper.java b/statistics/server/src/main/java/ru/practicum/statistics/mapper/HitMapper.java
new file mode 100644
index 0000000..27ba82b
--- /dev/null
+++ b/statistics/server/src/main/java/ru/practicum/statistics/mapper/HitMapper.java
@@ -0,0 +1,19 @@
+package ru.practicum.statistics.mapper;
+
+import ru.practicum.statistics.dto.EndpointHit;
+import ru.practicum.statistics.model.HitEntity;
+
+public class HitMapper {
+
+ public static HitEntity toEntity(EndpointHit dto) {
+ if (dto == null) {
+ return null;
+ }
+ HitEntity entity = new HitEntity();
+ entity.setApp(dto.getApp());
+ entity.setUri(dto.getUri());
+ entity.setIp(dto.getIp());
+ entity.setTimestamp(dto.getTimestamp());
+ return entity;
+ }
+}
\ No newline at end of file
diff --git a/statistics/server/src/main/java/ru/practicum/statistics/model/HitEntity.java b/statistics/server/src/main/java/ru/practicum/statistics/model/HitEntity.java
new file mode 100644
index 0000000..88e126a
--- /dev/null
+++ b/statistics/server/src/main/java/ru/practicum/statistics/model/HitEntity.java
@@ -0,0 +1,31 @@
+package ru.practicum.statistics.model;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "hits")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class HitEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String app;
+
+ @Column(nullable = false)
+ private String uri;
+
+ @Column(nullable = false)
+ private String ip;
+
+ @Column(name = "timestamp", nullable = false)
+ private LocalDateTime timestamp;
+}
\ No newline at end of file
diff --git a/statistics/server/src/main/java/ru/practicum/statistics/repository/HitRepository.java b/statistics/server/src/main/java/ru/practicum/statistics/repository/HitRepository.java
new file mode 100644
index 0000000..9a9bd35
--- /dev/null
+++ b/statistics/server/src/main/java/ru/practicum/statistics/repository/HitRepository.java
@@ -0,0 +1,30 @@
+package ru.practicum.statistics.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.statistics.model.HitEntity;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface HitRepository extends JpaRepository {
+
+ @Query("SELECT h.app, h.uri, COUNT(DISTINCT h.ip) as hits FROM HitEntity h " +
+ "WHERE h.timestamp BETWEEN :start AND :end " +
+ "AND (:uris IS NULL OR h.uri IN :uris) " +
+ "GROUP BY h.app, h.uri " +
+ "ORDER BY hits DESC")
+ List