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 countUniqueHits(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("uris") List uris); + + @Query("SELECT h.app, h.uri, COUNT(h) 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 countAllHits(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("uris") List uris); +} \ No newline at end of file diff --git a/statistics/server/src/main/java/ru/practicum/statistics/service/StatsService.java b/statistics/server/src/main/java/ru/practicum/statistics/service/StatsService.java new file mode 100644 index 0000000..d2a3e8a --- /dev/null +++ b/statistics/server/src/main/java/ru/practicum/statistics/service/StatsService.java @@ -0,0 +1,39 @@ +package ru.practicum.statistics.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.statistics.dto.EndpointHit; +import ru.practicum.statistics.dto.ViewStats; +import ru.practicum.statistics.mapper.HitMapper; +import ru.practicum.statistics.model.HitEntity; +import ru.practicum.statistics.repository.HitRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StatsService { + private final HitRepository hitRepository; + + @Transactional + public void saveHit(EndpointHit hit) { + HitEntity entity = HitMapper.toEntity(hit); + hitRepository.save(entity); + } + + @Transactional(readOnly = true) + public List getStats(LocalDateTime start, LocalDateTime end, List uris, boolean unique) { + List results; + if (unique) { + results = hitRepository.countUniqueHits(start, end, uris); + } else { + results = hitRepository.countAllHits(start, end, uris); + } + return results.stream() + .map(row -> new ViewStats((String) row[0], (String) row[1], (Long) row[2])) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/statistics/server/src/main/resources/application.properties b/statistics/server/src/main/resources/application.properties new file mode 100644 index 0000000..f4fe0ee --- /dev/null +++ b/statistics/server/src/main/resources/application.properties @@ -0,0 +1,14 @@ +server.port=9090 +spring.application.name=stats-server + +spring.datasource.url=${STATS_DB_URL:jdbc:postgresql://localhost:5432/statsdb} +spring.datasource.username=${STATS_DB_USER:postgres} +spring.datasource.password=${STATS_DB_PASSWORD:password} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.format_sql=true +spring.sql.init.mode=always + +logging.level.org.springframework.orm.jpa=INFO +logging.level.org.springframework.transaction=INFO \ No newline at end of file diff --git a/statistics/server/src/main/resources/schema.sql b/statistics/server/src/main/resources/schema.sql new file mode 100644 index 0000000..18cec6e --- /dev/null +++ b/statistics/server/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS hits ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + app VARCHAR(255) NOT NULL, + uri VARCHAR(512) NOT NULL, + ip VARCHAR(64) NOT NULL, + timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, + CONSTRAINT pk_hit PRIMARY KEY (id) +); \ No newline at end of file