added ai-moderation image and text

This commit is contained in:
SlimusMinus
2026-07-29 00:56:32 +03:00
parent 7899ebcfb0
commit a6fb187212
34 changed files with 708 additions and 8 deletions

View File

@@ -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) {

View File

@@ -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 <T, R> R post(String uri, T request, Class<R> 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);
}
}
}

View File

@@ -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> R post(String uri, MultipartFile file, Class<R> 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);
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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<String> detectedLabels;
}

View File

@@ -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<String> detectedLabels;
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.ai.dto;
import lombok.Data;
@Data
public class TextModerationRequest {
private final String text;
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.ai.exception;
public class AiBadRequestException extends AiException {
public AiBadRequestException(String message) {
super(message);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.ai.exception;
public class AiServerException extends AiException {
public AiServerException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.ai.exception;
public class AiTimeoutException extends AiException {
public AiTimeoutException(Throwable cause) {
super("AI service timeout", cause);
}
}

View File

@@ -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);
}
}

View File

@@ -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<String> detectedLabels;
public ContentBlockedException(String reason, List<String> detectedLabels) {
super(reason);
this.reason = reason;
this.detectedLabels = detectedLabels;
}
}

View File

@@ -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<MultipartFile> images) {
log.info("moderate ContentModerationFacade get");
ModerationContext context =
ModerationContext.builder()
.title(title)
.description(description)
.images(images)
.build();
pipeline.execute(context);
}
}

View File

@@ -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<ModerationProcessor> processors;
public void execute(ModerationContext context) {
for (ModerationProcessor processor : processors) {
processor.process(context);
}
}
}

View File

@@ -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<MultipartFile> 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());
}
}
}
}

View File

@@ -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<MultipartFile> images;
}

View File

@@ -0,0 +1,7 @@
package com.krylov.refound.ai.pipeline;
public interface ModerationProcessor {
void process(ModerationContext context);
}

View File

@@ -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();
}
}

View File

@@ -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 {
}

View File

@@ -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<MultipartFile> images) {
public PostRequest create(@ModelAttribute PostRequest request, @RequestParam(value = "images", required = false) List<MultipartFile> images) {
log.info(request.toString());
return service.create(request, images);
}

View File

@@ -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<String> labels;
private List<String> blockedWords;
private Double score;
}

View File

@@ -6,5 +6,6 @@ public enum ErrorCode {
INTERNAL_ERROR,
BAD_REQUEST,
UNAUTHORIZED,
FORBIDDEN
FORBIDDEN,
CONTENT_BLOCKED
}

View File

@@ -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;
}

View File

@@ -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<ErrorResponse> 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<ErrorResponse> handleBlocked(
ContentBlockedException ex,
HttpServletRequest request
) {
return buildResponse(
HttpStatus.BAD_REQUEST,
ErrorCode.CONTENT_BLOCKED,
ex.getReason(),
request,
ModerationErrorDetails.builder()
.labels(ex.getDetectedLabels())
.build()
);
}
}

View File

@@ -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();

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.service;
import org.springframework.stereotype.Service;
@Service
public class ModerationService {
}

View File

@@ -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<MultipartFile> 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) {