diff --git a/build.gradle b/build.gradle index aff0d0b..f952978 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.liquibase:liquibase-core' implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.mapstruct:mapstruct:1.5.5.Final' @@ -49,6 +50,12 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + //WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' diff --git a/src/main/java/com/krylov/refound/ReFoundApplication.java b/src/main/java/com/krylov/refound/ReFoundApplication.java index 2259c01..e1acad3 100644 --- a/src/main/java/com/krylov/refound/ReFoundApplication.java +++ b/src/main/java/com/krylov/refound/ReFoundApplication.java @@ -1,18 +1,21 @@ package com.krylov.refound; +import com.krylov.refound.ai.config.AiProperties; +import com.krylov.refound.ai.config.AiRetryProperties; import com.krylov.refound.config.MinioProperties; import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication( scanBasePackages = "com.krylov.refound", exclude = { S3AutoConfiguration.class } )@EnableScheduling -@EnableConfigurationProperties(MinioProperties.class) - +@EnableConfigurationProperties({MinioProperties.class, AiProperties.class, AiRetryProperties.class}) +@EnableRetry public class ReFoundApplication { public static void main(String[] args) { diff --git a/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java b/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java new file mode 100644 index 0000000..95260d3 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java @@ -0,0 +1,52 @@ +package com.krylov.refound.ai.client; + +import com.krylov.refound.ai.exception.AiBadRequestException; +import com.krylov.refound.ai.exception.AiServerException; +import com.krylov.refound.ai.exception.AiUnavailableException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiHttpClient { + + private final WebClient aiWebClient; + + public R post(String uri, T request, Class responseType) { + log.info("AI POST {}", uri); + try { + R response = aiWebClient.post() + .uri(uri) + .bodyValue(request) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + resp -> resp.bodyToMono(String.class) + .map(AiBadRequestException::new) + ) + .onStatus( + HttpStatusCode::is5xxServerError, + resp -> resp.bodyToMono(String.class) + .map(AiServerException::new) + ) + .bodyToMono(responseType) + .block(); + + log.info("AI response received"); + return response; + } catch (WebClientRequestException ex) { + log.error("AI connection failed", ex); + throw new AiUnavailableException(ex); + + } catch (Exception ex) { + log.error("AI request failed", ex); + throw new AiUnavailableException(ex); + } + } +} diff --git a/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java b/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java new file mode 100644 index 0000000..72cb296 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java @@ -0,0 +1,64 @@ +package com.krylov.refound.ai.client; + +import com.krylov.refound.ai.exception.AiBadRequestException; +import com.krylov.refound.ai.exception.AiServerException; +import com.krylov.refound.ai.exception.AiUnavailableException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiMultipartHttpClient { + private final WebClient aiWebClient; + + public R post(String uri, MultipartFile file, Class responseType) { + log.info("AI POST {}", uri); + try { + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part( + "file", + new ByteArrayResource(file.getBytes()) { + + @Override + public String getFilename() { + return file.getOriginalFilename(); + } + + } + ); + + R response = aiWebClient.post() + .uri(uri) + .contentType(MediaType.MULTIPART_FORM_DATA) + .bodyValue(builder.build()) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + resp -> resp.bodyToMono(String.class) + .map(AiBadRequestException::new) + ) + .onStatus( + HttpStatusCode::is5xxServerError, + resp -> resp.bodyToMono(String.class) + .map(AiServerException::new) + ) + .bodyToMono(responseType) + .block(); + log.info("AI image response received"); + return response; + } catch (WebClientRequestException ex) { + throw new AiUnavailableException(ex); + } catch (Exception ex) { + throw new RuntimeException("Cannot send image to AI", ex); + } + } +} diff --git a/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java b/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java new file mode 100644 index 0000000..7596ef0 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java @@ -0,0 +1,40 @@ +package com.krylov.refound.ai.client; + +import com.krylov.refound.ai.dto.ImageModerationResponse; +import com.krylov.refound.ai.exception.AiServerException; +import com.krylov.refound.ai.exception.AiUnavailableException; +import lombok.RequiredArgsConstructor; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + + +@Service +@RequiredArgsConstructor +public class ImageModerationClient { + private final AiMultipartHttpClient client; + + @Retryable( + retryFor = { + AiUnavailableException.class, + AiServerException.class + }, + maxAttempts = 3, + backoff = @Backoff( + delay = 500, + multiplier = 2 + ) + ) + public ImageModerationResponse moderate( + MultipartFile file + ) { + + return client.post( + "/api/v1/moderation/image", + file, + ImageModerationResponse.class + ); + + } +} diff --git a/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java b/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java new file mode 100644 index 0000000..c72cc05 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java @@ -0,0 +1,51 @@ +package com.krylov.refound.ai.client; + +import com.krylov.refound.ai.dto.ModerationResponse; +import com.krylov.refound.ai.dto.TextModerationRequest; +import com.krylov.refound.ai.exception.AiServerException; +import com.krylov.refound.ai.exception.AiUnavailableException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TextModerationClient { + + private final AiHttpClient client; + + @Retryable( + retryFor = { + AiUnavailableException.class, + AiServerException.class + }, + maxAttempts = 3, + backoff = @Backoff( + delay = 500, + multiplier = 2 + ) + ) + public ModerationResponse moderate(String text) { + log.info("Calling AI service"); + return client.post( + "/api/v1/moderation/text", + new TextModerationRequest(text), + ModerationResponse.class + ); + + } + + @Recover + public ModerationResponse recover(AiUnavailableException e, String text) { + log.error("AI unavailable after retries", e); + + return ModerationResponse.builder() + .approved(false) + .reason("AI_UNAVAILABLE") + .build(); + } +} diff --git a/src/main/java/com/krylov/refound/ai/config/AiProperties.java b/src/main/java/com/krylov/refound/ai/config/AiProperties.java new file mode 100644 index 0000000..e190d5a --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/config/AiProperties.java @@ -0,0 +1,20 @@ +package com.krylov.refound.ai.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "ai") +public class AiProperties { + /** + * http://localhost:8000 + */ + private String url; + + /** + * 5 seconds + */ + private int timeout; +} diff --git a/src/main/java/com/krylov/refound/ai/config/AiRetryProperties.java b/src/main/java/com/krylov/refound/ai/config/AiRetryProperties.java new file mode 100644 index 0000000..8880eaf --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/config/AiRetryProperties.java @@ -0,0 +1,16 @@ +package com.krylov.refound.ai.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "ai.retry") +public class AiRetryProperties { + private int maxAttempts = 3; + + private long delay = 500; + + private double multiplier = 2; +} diff --git a/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java b/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java new file mode 100644 index 0000000..16fac02 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java @@ -0,0 +1,34 @@ +package com.krylov.refound.ai.config; + +import java.time.Duration; +import reactor.netty.http.client.HttpClient; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +@EnableConfigurationProperties(AiProperties.class) +public class WebClientConfig { + + @Bean + public WebClient aiWebClient(AiProperties properties) { + + HttpClient httpClient = + HttpClient.create() + .responseTimeout( + Duration.ofSeconds( + properties.getTimeout() + ) + ); + + return WebClient.builder() + .baseUrl(properties.getUrl()) + .clientConnector( + new ReactorClientHttpConnector(httpClient) + ) + .build(); + + } +} diff --git a/src/main/java/com/krylov/refound/ai/dto/ImageModerationResponse.java b/src/main/java/com/krylov/refound/ai/dto/ImageModerationResponse.java new file mode 100644 index 0000000..35bedc8 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/dto/ImageModerationResponse.java @@ -0,0 +1,15 @@ +package com.krylov.refound.ai.dto; + +import java.util.List; +import lombok.Data; + +@Data +public class ImageModerationResponse { + private Boolean approved; + + private Double score; + + private String reason; + + private List detectedLabels; +} diff --git a/src/main/java/com/krylov/refound/ai/dto/ModerationResponse.java b/src/main/java/com/krylov/refound/ai/dto/ModerationResponse.java new file mode 100644 index 0000000..b7bef40 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/dto/ModerationResponse.java @@ -0,0 +1,19 @@ +package com.krylov.refound.ai.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ModerationResponse { + + private Boolean approved; + + private Double score; + + private String reason; + + private List detectedLabels; + +} diff --git a/src/main/java/com/krylov/refound/ai/dto/TextModerationRequest.java b/src/main/java/com/krylov/refound/ai/dto/TextModerationRequest.java new file mode 100644 index 0000000..4f6555d --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/dto/TextModerationRequest.java @@ -0,0 +1,9 @@ +package com.krylov.refound.ai.dto; + +import lombok.Data; + +@Data +public class TextModerationRequest { + private final String text; + +} diff --git a/src/main/java/com/krylov/refound/ai/exception/AiBadRequestException.java b/src/main/java/com/krylov/refound/ai/exception/AiBadRequestException.java new file mode 100644 index 0000000..6f79dc9 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/exception/AiBadRequestException.java @@ -0,0 +1,9 @@ +package com.krylov.refound.ai.exception; + +public class AiBadRequestException extends AiException { + + public AiBadRequestException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/krylov/refound/ai/exception/AiException.java b/src/main/java/com/krylov/refound/ai/exception/AiException.java new file mode 100644 index 0000000..b51056f --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/exception/AiException.java @@ -0,0 +1,13 @@ +package com.krylov.refound.ai.exception; + +public abstract class AiException extends RuntimeException { + + protected AiException(String message) { + super(message); + } + + protected AiException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/krylov/refound/ai/exception/AiServerException.java b/src/main/java/com/krylov/refound/ai/exception/AiServerException.java new file mode 100644 index 0000000..c41318f --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/exception/AiServerException.java @@ -0,0 +1,9 @@ +package com.krylov.refound.ai.exception; + +public class AiServerException extends AiException { + + public AiServerException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/krylov/refound/ai/exception/AiTimeoutException.java b/src/main/java/com/krylov/refound/ai/exception/AiTimeoutException.java new file mode 100644 index 0000000..a964c0c --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/exception/AiTimeoutException.java @@ -0,0 +1,9 @@ +package com.krylov.refound.ai.exception; + +public class AiTimeoutException extends AiException { + + public AiTimeoutException(Throwable cause) { + super("AI service timeout", cause); + } + +} diff --git a/src/main/java/com/krylov/refound/ai/exception/AiUnavailableException.java b/src/main/java/com/krylov/refound/ai/exception/AiUnavailableException.java new file mode 100644 index 0000000..9fa1b62 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/exception/AiUnavailableException.java @@ -0,0 +1,9 @@ +package com.krylov.refound.ai.exception; + +public class AiUnavailableException extends AiException { + + public AiUnavailableException(Throwable cause) { + super("AI service is unavailable", cause); + } + +} diff --git a/src/main/java/com/krylov/refound/ai/exception/ContentBlockedException.java b/src/main/java/com/krylov/refound/ai/exception/ContentBlockedException.java new file mode 100644 index 0000000..a7c8255 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/exception/ContentBlockedException.java @@ -0,0 +1,19 @@ +package com.krylov.refound.ai.exception; + +import java.util.List; +import lombok.Getter; + +@Getter +public class ContentBlockedException extends RuntimeException { + + private final String reason; + private final List detectedLabels; + + public ContentBlockedException(String reason, List detectedLabels) { + super(reason); + this.reason = reason; + this.detectedLabels = detectedLabels; + } + + +} diff --git a/src/main/java/com/krylov/refound/ai/facade/ContentModerationFacade.java b/src/main/java/com/krylov/refound/ai/facade/ContentModerationFacade.java new file mode 100644 index 0000000..1b431a9 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/facade/ContentModerationFacade.java @@ -0,0 +1,30 @@ +package com.krylov.refound.ai.facade; + +import com.krylov.refound.ai.pipeline.ContentModerationPipeline; +import com.krylov.refound.ai.pipeline.ModerationContext; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContentModerationFacade { + + private final ContentModerationPipeline pipeline; + + public void moderate(String title, String description, List images) { + log.info("moderate ContentModerationFacade get"); + ModerationContext context = + ModerationContext.builder() + .title(title) + .description(description) + .images(images) + .build(); + + pipeline.execute(context); + + } +} diff --git a/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java b/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java new file mode 100644 index 0000000..b8c2711 --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java @@ -0,0 +1,19 @@ +package com.krylov.refound.ai.pipeline; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ContentModerationPipeline { + + private final List processors; + + public void execute(ModerationContext context) { + for (ModerationProcessor processor : processors) { + processor.process(context); + } + } + +} diff --git a/src/main/java/com/krylov/refound/ai/pipeline/ImageModerationProcessor.java b/src/main/java/com/krylov/refound/ai/pipeline/ImageModerationProcessor.java new file mode 100644 index 0000000..9eb455c --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/pipeline/ImageModerationProcessor.java @@ -0,0 +1,51 @@ +package com.krylov.refound.ai.pipeline; + + +import com.krylov.refound.ai.client.ImageModerationClient; +import com.krylov.refound.ai.dto.ImageModerationResponse; +import com.krylov.refound.ai.exception.ContentBlockedException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + + +@Slf4j +@Component +@RequiredArgsConstructor +@Order(20) +public class ImageModerationProcessor implements ModerationProcessor { + private final ImageModerationClient client; + + @Override + public void process(ModerationContext context) { + List images = context.getImages(); + + if (images == null || images.isEmpty()) { + return; + } + + for (MultipartFile image : images) { + if (image == null || image.isEmpty()) { + continue; + } + + ImageModerationResponse response = client.moderate(image); + + log.info("Image moderation result approved={}, score={}, reason={}, labels={}", + response.getApproved(), + response.getScore(), + response.getReason(), + response.getDetectedLabels() + ); + + if (!response.getApproved()) { + throw new ContentBlockedException(response.getReason(), response.getDetectedLabels()); + } + } + } + +} diff --git a/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java b/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java new file mode 100644 index 0000000..eb4bdad --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java @@ -0,0 +1,17 @@ +package com.krylov.refound.ai.pipeline; + +import java.util.List; +import lombok.Builder; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + +@Data +@Builder +public class ModerationContext { + private String title; + + private String description; + + private List images; + +} diff --git a/src/main/java/com/krylov/refound/ai/pipeline/ModerationProcessor.java b/src/main/java/com/krylov/refound/ai/pipeline/ModerationProcessor.java new file mode 100644 index 0000000..efde4ae --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/pipeline/ModerationProcessor.java @@ -0,0 +1,7 @@ +package com.krylov.refound.ai.pipeline; + +public interface ModerationProcessor { + + void process(ModerationContext context); + +} diff --git a/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java b/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java new file mode 100644 index 0000000..3c8c8ed --- /dev/null +++ b/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java @@ -0,0 +1,61 @@ +package com.krylov.refound.ai.pipeline; + +import com.krylov.refound.ai.client.TextModerationClient; +import com.krylov.refound.ai.dto.ModerationResponse; +import com.krylov.refound.ai.exception.ContentBlockedException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@Order(10) +public class TextModerationProcessor implements ModerationProcessor { + + private final TextModerationClient client; + + @Override + public void process(ModerationContext context) { + + String text = buildText( + context.getTitle(), + context.getDescription() + ); + + ModerationResponse response = client.moderate(text); + + log.info( + "AI moderation result approved={}, score={}, reason={}", + response.getApproved(), + response.getScore(), + response.getReason() + ); + + if (!response.getApproved()) { + throw new ContentBlockedException( + response.getReason(), + response.getDetectedLabels() + ); + } + + } + + private String buildText(String title, String description) { + + StringBuilder builder = new StringBuilder(); + + if (title != null) { + builder.append(title); + } + + if (description != null) { + builder.append("\n") + .append(description); + } + + return builder.toString(); + } + +} diff --git a/src/main/java/com/krylov/refound/config/RetryConfig.java b/src/main/java/com/krylov/refound/config/RetryConfig.java new file mode 100644 index 0000000..a08ac25 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/RetryConfig.java @@ -0,0 +1,10 @@ +package com.krylov.refound.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry +public class RetryConfig { + +} diff --git a/src/main/java/com/krylov/refound/controller/PostController.java b/src/main/java/com/krylov/refound/controller/PostController.java index b7e2e2c..9589da9 100644 --- a/src/main/java/com/krylov/refound/controller/PostController.java +++ b/src/main/java/com/krylov/refound/controller/PostController.java @@ -41,8 +41,7 @@ public class PostController { private final ObjectMapper objectMapper; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public PostRequest create(@ModelAttribute PostRequest request, - @RequestParam(value = "images", required = false) List images) { + public PostRequest create(@ModelAttribute PostRequest request, @RequestParam(value = "images", required = false) List images) { log.info(request.toString()); return service.create(request, images); } diff --git a/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java b/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java new file mode 100644 index 0000000..f8c72d1 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java @@ -0,0 +1,18 @@ +package com.krylov.refound.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class ModerationErrorDetails { + + private List labels; + + private List blockedWords; + + private Double score; +} diff --git a/src/main/java/com/krylov/refound/enums/ErrorCode.java b/src/main/java/com/krylov/refound/enums/ErrorCode.java index 543efaa..f8aa7cf 100644 --- a/src/main/java/com/krylov/refound/enums/ErrorCode.java +++ b/src/main/java/com/krylov/refound/enums/ErrorCode.java @@ -6,5 +6,6 @@ public enum ErrorCode { INTERNAL_ERROR, BAD_REQUEST, UNAUTHORIZED, - FORBIDDEN + FORBIDDEN, + CONTENT_BLOCKED } diff --git a/src/main/java/com/krylov/refound/exception/ErrorResponse.java b/src/main/java/com/krylov/refound/exception/ErrorResponse.java index 5a31a52..0cd3598 100644 --- a/src/main/java/com/krylov/refound/exception/ErrorResponse.java +++ b/src/main/java/com/krylov/refound/exception/ErrorResponse.java @@ -1,6 +1,7 @@ package com.krylov.refound.exception; import java.time.LocalDateTime; +import java.util.List; import lombok.Builder; import lombok.Getter; @@ -12,4 +13,5 @@ public class ErrorResponse { private String error; private String message; private String path; + private Object details; } diff --git a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java index c687fc1..9afda02 100644 --- a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java @@ -1,8 +1,11 @@ package com.krylov.refound.exception; +import com.krylov.refound.ai.exception.ContentBlockedException; +import com.krylov.refound.dto.ModerationErrorDetails; import com.krylov.refound.enums.ErrorCode; import jakarta.servlet.http.HttpServletRequest; import java.time.LocalDateTime; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -53,4 +56,44 @@ public class GlobalExceptionHandler { return new ResponseEntity<>(response, status); } + + private ResponseEntity buildResponse( + HttpStatus status, + ErrorCode code, + String message, + HttpServletRequest request, + Object details + ) { + + ErrorResponse response = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status.value()) + .error(code.name()) + .message(message) + .path(request.getRequestURI()) + .details(details) + .build(); + + return ResponseEntity + .status(status) + .body(response); + } + + @ExceptionHandler(ContentBlockedException.class) + public ResponseEntity handleBlocked( + ContentBlockedException ex, + HttpServletRequest request + ) { + + return buildResponse( + HttpStatus.BAD_REQUEST, + ErrorCode.CONTENT_BLOCKED, + ex.getReason(), + request, + ModerationErrorDetails.builder() + .labels(ex.getDetectedLabels()) + .build() + ); + + } } diff --git a/src/main/java/com/krylov/refound/security/SecurityConfig.java b/src/main/java/com/krylov/refound/security/SecurityConfig.java index 136e93e..31667ec 100644 --- a/src/main/java/com/krylov/refound/security/SecurityConfig.java +++ b/src/main/java/com/krylov/refound/security/SecurityConfig.java @@ -1,5 +1,6 @@ package com.krylov.refound.security; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +13,9 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @@ -23,20 +27,19 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // Public endpoints .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/test/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/files/**").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/v1/files/**").permitAll() - .requestMatchers(org.springframework.http.HttpMethod.GET, "/api/v1/posts/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll() .requestMatchers("/api/v1/reviews/**").permitAll() .requestMatchers("/files/**", "/uploads/**").permitAll() .requestMatchers("/ws/**").permitAll() - // All other endpoints require authentication .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -44,6 +47,24 @@ public class SecurityConfig { return http.build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOriginPatterns(List.of("*")); + + config.setAllowedMethods(List.of( + "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" + )); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/com/krylov/refound/service/ModerationService.java b/src/main/java/com/krylov/refound/service/ModerationService.java new file mode 100644 index 0000000..7030e1c --- /dev/null +++ b/src/main/java/com/krylov/refound/service/ModerationService.java @@ -0,0 +1,8 @@ +package com.krylov.refound.service; + +import org.springframework.stereotype.Service; + +@Service +public class ModerationService { + +} diff --git a/src/main/java/com/krylov/refound/service/PostService.java b/src/main/java/com/krylov/refound/service/PostService.java index 28285d1..550419d 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -1,5 +1,6 @@ package com.krylov.refound.service; +import com.krylov.refound.ai.facade.ContentModerationFacade; import com.krylov.refound.dto.PostRequest; import com.krylov.refound.dto.PostResponse; import com.krylov.refound.entity.Image; @@ -48,9 +49,12 @@ public class PostService { private final UserService userService; private final FavoriteRepository favoriteRepository; private final FileStorageService fileStorageService; + private final ContentModerationFacade contentModerationFacade; public PostRequest create(PostRequest request, List images) { + contentModerationFacade.moderate(request.getTitle(), request.getDescription(), images); + Post post = mapper.toEntity(request); post.setCreatedAt(LocalDateTime.now()); post.setStatus(PostStatus.ACTIVE); @@ -154,6 +158,8 @@ public class PostService { HttpStatus.NOT_FOUND )); + contentModerationFacade.moderate(request.getTitle(), request.getDescription(), images); + post.setTitle(request.getTitle()); post.setDescription(request.getDescription()); if (request.getCategory() != null) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 11b7867..8fbcb71 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,6 +36,15 @@ minio: secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K region: us-east-1 # MinIO не проверяет регион, но SDK требует значение +ai: + url: http://localhost:8000 + timeout: 5 + + retry: + max-attempts: 3 + delay: 500 + multiplier: 2 + file: upload-dir: uploads/