added ai-moderation image and text
This commit is contained in:
@@ -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) {
|
||||
|
||||
52
src/main/java/com/krylov/refound/ai/client/AiHttpClient.java
Normal file
52
src/main/java/com/krylov/refound/ai/client/AiHttpClient.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
20
src/main/java/com/krylov/refound/ai/config/AiProperties.java
Normal file
20
src/main/java/com/krylov/refound/ai/config/AiProperties.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.ai.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class TextModerationRequest {
|
||||
private final String text;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.ai.exception;
|
||||
|
||||
public class AiBadRequestException extends AiException {
|
||||
|
||||
public AiBadRequestException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.ai.exception;
|
||||
|
||||
public class AiServerException extends AiException {
|
||||
|
||||
public AiServerException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.ai.exception;
|
||||
|
||||
public class AiTimeoutException extends AiException {
|
||||
|
||||
public AiTimeoutException(Throwable cause) {
|
||||
super("AI service timeout", cause);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.krylov.refound.ai.pipeline;
|
||||
|
||||
public interface ModerationProcessor {
|
||||
|
||||
void process(ModerationContext context);
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
10
src/main/java/com/krylov/refound/config/RetryConfig.java
Normal file
10
src/main/java/com/krylov/refound/config/RetryConfig.java
Normal 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 {
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,5 +6,6 @@ public enum ErrorCode {
|
||||
INTERNAL_ERROR,
|
||||
BAD_REQUEST,
|
||||
UNAUTHORIZED,
|
||||
FORBIDDEN
|
||||
FORBIDDEN,
|
||||
CONTENT_BLOCKED
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.krylov.refound.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ModerationService {
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user