added ai-moderation image and text
This commit is contained in:
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-aop'
|
||||||
implementation 'org.liquibase:liquibase-core'
|
implementation 'org.liquibase:liquibase-core'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
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-impl:0.12.5'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson: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'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
|
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
package com.krylov.refound;
|
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 com.krylov.refound.config.MinioProperties;
|
||||||
import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration;
|
import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.retry.annotation.EnableRetry;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication(
|
@SpringBootApplication(
|
||||||
scanBasePackages = "com.krylov.refound",
|
scanBasePackages = "com.krylov.refound",
|
||||||
exclude = { S3AutoConfiguration.class }
|
exclude = { S3AutoConfiguration.class }
|
||||||
)@EnableScheduling
|
)@EnableScheduling
|
||||||
@EnableConfigurationProperties(MinioProperties.class)
|
@EnableConfigurationProperties({MinioProperties.class, AiProperties.class, AiRetryProperties.class})
|
||||||
|
@EnableRetry
|
||||||
public class ReFoundApplication {
|
public class ReFoundApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
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;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public PostRequest create(@ModelAttribute PostRequest request,
|
public PostRequest create(@ModelAttribute PostRequest request, @RequestParam(value = "images", required = false) List<MultipartFile> images) {
|
||||||
@RequestParam(value = "images", required = false) List<MultipartFile> images) {
|
|
||||||
log.info(request.toString());
|
log.info(request.toString());
|
||||||
return service.create(request, images);
|
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,
|
INTERNAL_ERROR,
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
FORBIDDEN
|
FORBIDDEN,
|
||||||
|
CONTENT_BLOCKED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.krylov.refound.exception;
|
package com.krylov.refound.exception;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@@ -12,4 +13,5 @@ public class ErrorResponse {
|
|||||||
private String error;
|
private String error;
|
||||||
private String message;
|
private String message;
|
||||||
private String path;
|
private String path;
|
||||||
|
private Object details;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.krylov.refound.exception;
|
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 com.krylov.refound.enums.ErrorCode;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -53,4 +56,44 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
return new ResponseEntity<>(response, status);
|
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;
|
package com.krylov.refound.security;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
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
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -23,20 +27,19 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Public endpoints
|
|
||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
.requestMatchers("/api/v1/test/**").permitAll()
|
.requestMatchers("/api/v1/test/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/v1/files/**").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/v1/files/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.DELETE, "/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("/api/v1/reviews/**").permitAll()
|
||||||
.requestMatchers("/files/**", "/uploads/**").permitAll()
|
.requestMatchers("/files/**", "/uploads/**").permitAll()
|
||||||
.requestMatchers("/ws/**").permitAll()
|
.requestMatchers("/ws/**").permitAll()
|
||||||
// All other endpoints require authentication
|
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
@@ -44,6 +47,24 @@ public class SecurityConfig {
|
|||||||
return http.build();
|
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
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
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;
|
package com.krylov.refound.service;
|
||||||
|
|
||||||
|
import com.krylov.refound.ai.facade.ContentModerationFacade;
|
||||||
import com.krylov.refound.dto.PostRequest;
|
import com.krylov.refound.dto.PostRequest;
|
||||||
import com.krylov.refound.dto.PostResponse;
|
import com.krylov.refound.dto.PostResponse;
|
||||||
import com.krylov.refound.entity.Image;
|
import com.krylov.refound.entity.Image;
|
||||||
@@ -48,9 +49,12 @@ public class PostService {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final FavoriteRepository favoriteRepository;
|
private final FavoriteRepository favoriteRepository;
|
||||||
private final FileStorageService fileStorageService;
|
private final FileStorageService fileStorageService;
|
||||||
|
private final ContentModerationFacade contentModerationFacade;
|
||||||
|
|
||||||
public PostRequest create(PostRequest request, List<MultipartFile> images) {
|
public PostRequest create(PostRequest request, List<MultipartFile> images) {
|
||||||
|
|
||||||
|
contentModerationFacade.moderate(request.getTitle(), request.getDescription(), images);
|
||||||
|
|
||||||
Post post = mapper.toEntity(request);
|
Post post = mapper.toEntity(request);
|
||||||
post.setCreatedAt(LocalDateTime.now());
|
post.setCreatedAt(LocalDateTime.now());
|
||||||
post.setStatus(PostStatus.ACTIVE);
|
post.setStatus(PostStatus.ACTIVE);
|
||||||
@@ -154,6 +158,8 @@ public class PostService {
|
|||||||
HttpStatus.NOT_FOUND
|
HttpStatus.NOT_FOUND
|
||||||
));
|
));
|
||||||
|
|
||||||
|
contentModerationFacade.moderate(request.getTitle(), request.getDescription(), images);
|
||||||
|
|
||||||
post.setTitle(request.getTitle());
|
post.setTitle(request.getTitle());
|
||||||
post.setDescription(request.getDescription());
|
post.setDescription(request.getDescription());
|
||||||
if (request.getCategory() != null) {
|
if (request.getCategory() != null) {
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ minio:
|
|||||||
secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K
|
secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K
|
||||||
region: us-east-1 # MinIO не проверяет регион, но SDK требует значение
|
region: us-east-1 # MinIO не проверяет регион, но SDK требует значение
|
||||||
|
|
||||||
|
ai:
|
||||||
|
url: http://localhost:8000
|
||||||
|
timeout: 5
|
||||||
|
|
||||||
|
retry:
|
||||||
|
max-attempts: 3
|
||||||
|
delay: 500
|
||||||
|
multiplier: 2
|
||||||
|
|
||||||
file:
|
file:
|
||||||
upload-dir: uploads/
|
upload-dir: uploads/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user