fixed controllers

This commit is contained in:
SlimusMinus
2026-07-31 01:08:53 +03:00
parent 74f7c88843
commit 2468c26b84
32 changed files with 536 additions and 432 deletions

View File

@@ -10,7 +10,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientRequestException; import org.springframework.web.reactive.function.client.WebClientRequestException;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -19,7 +18,7 @@ public class AiHttpClient {
private final WebClient aiWebClient; private final WebClient aiWebClient;
public <T, R> R post(String uri, T request, Class<R> responseType) { public <T, R> R post(String uri, T request, Class<R> responseType) {
log.info("AI POST {}", uri); log.info("AI TEXT POST {}", uri);
try { try {
R response = aiWebClient.post() R response = aiWebClient.post()
.uri(uri) .uri(uri)

View File

@@ -21,18 +21,14 @@ public class AiMultipartHttpClient {
private final WebClient aiWebClient; private final WebClient aiWebClient;
public <R> R post(String uri, MultipartFile file, Class<R> responseType) { public <R> R post(String uri, MultipartFile file, Class<R> responseType) {
log.info("AI POST {}", uri); log.info("AI IMAGE POST {}", uri);
try { try {
MultipartBodyBuilder builder = new MultipartBodyBuilder(); MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part( builder.part("file", new ByteArrayResource(file.getBytes()) {
"file",
new ByteArrayResource(file.getBytes()) {
@Override @Override
public String getFilename() { public String getFilename() {
return file.getOriginalFilename(); return file.getOriginalFilename();
} }
} }
); );
@@ -56,8 +52,10 @@ public class AiMultipartHttpClient {
log.info("AI image response received"); log.info("AI image response received");
return response; return response;
} catch (WebClientRequestException ex) { } catch (WebClientRequestException ex) {
log.error("WebClientRequestException in AiMultipartHttpClient {}", ex.getMessage(), ex);
throw new AiUnavailableException(ex); throw new AiUnavailableException(ex);
} catch (Exception ex) { } catch (Exception ex) {
log.error("Exception in AiMultipartHttpClient {}", ex.getMessage(), ex);
throw new RuntimeException("Cannot send image to AI", ex); throw new RuntimeException("Cannot send image to AI", ex);
} }
} }

View File

@@ -1,40 +1,40 @@
package com.krylov.refound.ai.client; package com.krylov.refound.ai.client;
import com.krylov.refound.ai.dto.ImageModerationResponse; import com.krylov.refound.ai.dto.ImageModerationResponse;
import com.krylov.refound.ai.dto.ModerationResponse;
import com.krylov.refound.ai.exception.AiServerException; import com.krylov.refound.ai.exception.AiServerException;
import com.krylov.refound.ai.exception.AiUnavailableException; import com.krylov.refound.ai.exception.AiUnavailableException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable; import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class ImageModerationClient { public class ImageModerationClient {
private final AiMultipartHttpClient client; private final AiMultipartHttpClient client;
@Retryable( @Retryable(
retryFor = { retryFor = {AiUnavailableException.class, AiServerException.class},
AiUnavailableException.class,
AiServerException.class
},
maxAttempts = 3, maxAttempts = 3,
backoff = @Backoff( backoff = @Backoff(delay = 500, multiplier = 2)
delay = 500,
multiplier = 2
) )
) public ImageModerationResponse moderate(MultipartFile file) {
public ImageModerationResponse moderate( log.info("Calling AI ImageModeration service");
MultipartFile file return client.post("/api/v1/moderation/image", file, ImageModerationResponse.class);
) { }
return client.post( @Recover
"/api/v1/moderation/image", public ModerationResponse recover(AiUnavailableException e, String text) {
file, log.error("AI ImageModeration unavailable after retries", e);
ImageModerationResponse.class
);
return ModerationResponse.builder()
.approved(false)
.reason("AI_UNAVAILABLE")
.build();
} }
} }

View File

@@ -19,29 +19,18 @@ public class TextModerationClient {
private final AiHttpClient client; private final AiHttpClient client;
@Retryable( @Retryable(
retryFor = { retryFor = {AiUnavailableException.class, AiServerException.class},
AiUnavailableException.class,
AiServerException.class
},
maxAttempts = 3, maxAttempts = 3,
backoff = @Backoff( backoff = @Backoff(delay = 500, multiplier = 2)
delay = 500,
multiplier = 2
)
) )
public ModerationResponse moderate(String text) { public ModerationResponse moderate(String text) {
log.info("Calling AI service"); log.info("Calling AI TextModeration service");
return client.post( return client.post("/api/v1/moderation/text", new TextModerationRequest(text), ModerationResponse.class);
"/api/v1/moderation/text",
new TextModerationRequest(text),
ModerationResponse.class
);
} }
@Recover @Recover
public ModerationResponse recover(AiUnavailableException e, String text) { public ModerationResponse recover(AiUnavailableException e, String text) {
log.error("AI unavailable after retries", e); log.error("AI TextModeration unavailable after retries", e);
return ModerationResponse.builder() return ModerationResponse.builder()
.approved(false) .approved(false)

View File

@@ -2,11 +2,13 @@ package com.krylov.refound.ai.config;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter @Getter
@Setter @Setter
@ConfigurationProperties(prefix = "ai") @ConfigurationProperties(prefix = "ai")
@ToString
public class AiProperties { public class AiProperties {
/** /**
* http://localhost:8000 * http://localhost:8000

View File

@@ -1,6 +1,7 @@
package com.krylov.refound.ai.config; package com.krylov.refound.ai.config;
import java.time.Duration; import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.HttpClient;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -8,26 +9,23 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
@Slf4j
@Configuration @Configuration
@EnableConfigurationProperties(AiProperties.class) @EnableConfigurationProperties(AiProperties.class)
public class WebClientConfig { public class WebClientConfig {
@Bean @Bean
public WebClient aiWebClient(AiProperties properties) { public WebClient aiWebClient(AiProperties properties) {
log.info("aiWebClient get for properties {}", properties);
HttpClient httpClient = HttpClient httpClient =
HttpClient.create() HttpClient.create()
.responseTimeout( .responseTimeout(
Duration.ofSeconds( Duration.ofSeconds(properties.getTimeout())
properties.getTimeout()
)
); );
return WebClient.builder() return WebClient.builder()
.baseUrl(properties.getUrl()) .baseUrl(properties.getUrl())
.clientConnector( .clientConnector(new ReactorClientHttpConnector(httpClient))
new ReactorClientHttpConnector(httpClient)
)
.build(); .build();
} }

View File

@@ -2,8 +2,10 @@ package com.krylov.refound.ai.pipeline;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ContentModerationPipeline { public class ContentModerationPipeline {
@@ -11,6 +13,7 @@ public class ContentModerationPipeline {
private final List<ModerationProcessor> processors; private final List<ModerationProcessor> processors;
public void execute(ModerationContext context) { public void execute(ModerationContext context) {
log.info("execute ContentModerationPipeline");
for (ModerationProcessor processor : processors) { for (ModerationProcessor processor : processors) {
processor.process(context); processor.process(context);
} }

View File

@@ -3,9 +3,11 @@ package com.krylov.refound.ai.pipeline;
import java.util.List; import java.util.List;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.ToString;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@Data @Data
@ToString
@Builder @Builder
public class ModerationContext { public class ModerationContext {
private String title; private String title;

View File

@@ -18,32 +18,22 @@ public class TextModerationProcessor implements ModerationProcessor {
@Override @Override
public void process(ModerationContext context) { public void process(ModerationContext context) {
log.info("process TextModerationProcessor for context {}", context);
String text = buildText( String text = buildText(
context.getTitle(), context.getTitle(),
context.getDescription() context.getDescription()
); );
ModerationResponse response = client.moderate(text); ModerationResponse response = client.moderate(text);
log.info("AI moderation result approved={}, score={}, reason={}", response.getApproved(), response.getScore(), response.getReason());
log.info(
"AI moderation result approved={}, score={}, reason={}",
response.getApproved(),
response.getScore(),
response.getReason()
);
if (!response.getApproved()) { if (!response.getApproved()) {
throw new ContentBlockedException( throw new ContentBlockedException(response.getReason(), response.getDetectedLabels());
response.getReason(),
response.getDetectedLabels()
);
} }
} }
private String buildText(String title, String description) { private String buildText(String title, String description) {
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
if (title != null) { if (title != null) {
@@ -51,8 +41,7 @@ public class TextModerationProcessor implements ModerationProcessor {
} }
if (description != null) { if (description != null) {
builder.append("\n") builder.append("\n").append(description);
.append(description);
} }
return builder.toString(); return builder.toString();

View File

@@ -1,29 +0,0 @@
package com.krylov.refound.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${file.upload-dir}")
private String uploadDir;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry
.addResourceHandler("/files/**")
.addResourceLocations("file:" + uploadDir);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*") // 🔥 важно
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}

View File

@@ -1,16 +0,0 @@
package com.krylov.refound.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry
.addResourceHandler("/uploads/**")
.addResourceLocations("file:uploads/"); // папка uploads/ в корне проекта
}
}

View File

@@ -4,15 +4,12 @@ import com.krylov.refound.dto.AuthRequest;
import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.AuthResponse;
import com.krylov.refound.dto.CheckLoginDto; import com.krylov.refound.dto.CheckLoginDto;
import com.krylov.refound.dto.RegisterRequest; import com.krylov.refound.dto.RegisterRequest;
import com.krylov.refound.dto.UserDto; import com.krylov.refound.dto.UserRegisterDto;
import com.krylov.refound.entity.User; import com.krylov.refound.service.AuthService;
import com.krylov.refound.repository.UserRepository;
import com.krylov.refound.security.JwtService;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -24,73 +21,28 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthController { public class AuthController {
private final UserRepository userRepository; private final AuthService authService;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) { public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
log.info("register user {}", request);
// Проверка что логин не занят UserRegisterDto userRegisterDto = authService.registerUser(request);
if (userRepository.findByLogin(request.getLogin()).isPresent()) { return ResponseEntity.ok(
return ResponseEntity new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto()));
.badRequest()
.body(Map.of("message", "Логин уже занят"));
}
// Создание пользователя
User user = new User();
user.setName(request.getName());
user.setLogin(request.getLogin());
user.setPassword(passwordEncoder.encode(request.getPassword()));
userRepository.save(user);
String token = jwtService.generateToken(user.getLogin());
UserDto userDto = new UserDto(
user.getId(),
user.getName(),
user.getLastName(),
user.getLogin(),
user.getEmail(),
user.getPhone(),
user.getAvatarUrl(),
user.getRole()
);
return ResponseEntity.ok(new AuthResponse(token, userDto));
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AuthRequest request) { public ResponseEntity<?> login(@RequestBody AuthRequest request) {
log.info("user {} logging", request.getLogin());
User user = userRepository.findByLogin(request.getLogin()) UserRegisterDto userRegisterDto = authService.loginUser(request);
.orElse(null); return ResponseEntity.ok(
new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto()));
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
return ResponseEntity
.badRequest()
.body(Map.of("message", "Неверный логин или пароль"));
}
String token = jwtService.generateToken(user.getLogin());
UserDto userDto = new UserDto(
user.getId(),
user.getName(),
user.getLastName(),
user.getLogin(),
user.getEmail(),
user.getPhone(),
user.getAvatarUrl(),
user.getRole()
);
return ResponseEntity.ok(new AuthResponse(token, userDto));
} }
@PostMapping("/check-login") @PostMapping("/check-login")
public ResponseEntity<?> checkLogin(@RequestBody CheckLoginDto request) { public ResponseEntity<?> checkLogin(@RequestBody CheckLoginDto request) {
boolean exists = userRepository.existsByLogin(request.getLogin()); log.info("checkLogin for {}", request.getLogin());
boolean exists = authService.existsByLogin(request);
return ResponseEntity.ok(Map.of("exists", exists)); return ResponseEntity.ok(Map.of("exists", exists));
} }

View File

@@ -4,11 +4,17 @@ import com.krylov.refound.dto.*;
import com.krylov.refound.service.ChatService; import com.krylov.refound.service.ChatService;
import com.krylov.refound.service.UserService; import com.krylov.refound.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@Slf4j
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/v1/chats") @RequestMapping("/api/v1/chats")
@@ -20,40 +26,46 @@ public class ChatController {
// Создать чат с пользователем или получить существующий // Создать чат с пользователем или получить существующий
@PostMapping("/with/{otherUserId}") @PostMapping("/with/{otherUserId}")
public ChatDto createOrGetChat(@PathVariable Long otherUserId, @RequestParam(required = false) Long postId, Authentication auth) { public ChatDto createOrGetChat(@PathVariable Long otherUserId, @RequestParam(required = false) Long postId, Authentication auth) {
Long currentUserId = extractUserId(auth); // см. ниже как достать id Long currentUserId = extractUserId(auth);
log.info("createOrGetChat currentUserId {}, otherUserId {}, postId {}", currentUserId, otherUserId, postId);
return chatService.getOrCreateChat(currentUserId, otherUserId, postId); return chatService.getOrCreateChat(currentUserId, otherUserId, postId);
} }
// Список всех чатов текущего пользователя // Список всех чатов текущего пользователя
@GetMapping @GetMapping
public List<ChatDto> getMyChats(Authentication auth) { public List<ChatDto> getMyChats(Authentication auth) {
return chatService.getUserChats(extractUserId(auth)); Long currentUserId = extractUserId(auth);
log.info("getMyChats for user {}", currentUserId);
return chatService.getUserChats(currentUserId);
} }
// История сообщений конкретного чата // История сообщений конкретного чата, с пагинацией
@GetMapping("/{chatId}/messages") @GetMapping("/{chatId}/messages")
public List<MessageDto> getMessages(@PathVariable Long chatId) { public Page<MessageDto> getMessages(@PathVariable Long chatId, @PageableDefault(size = 30, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, Authentication auth) {
return chatService.getMessages(chatId); Long currentUserId = extractUserId(auth);
} log.info("getMessages for chatId {} page {} by user {}", chatId, pageable.getPageNumber(), currentUserId);
return chatService.getMessages(chatId, currentUserId, pageable);
private Long extractUserId(Authentication auth) {
// Подставь свою логику получения userId из JWT/Authentication
// Например если в токене login, а не id - подтяни через UserService
String login = auth.getName();
return userService.getUserIdByLogin(login);
} }
@DeleteMapping("/{chatId}") @DeleteMapping("/{chatId}")
public void deleteChat(@PathVariable Long chatId, Authentication auth) { public void deleteChat(@PathVariable Long chatId, Authentication auth) {
Long currentUserId = extractUserId(auth); Long currentUserId = extractUserId(auth);
chatService.deleteChat(chatId); log.info("user {} deleteChat {}", currentUserId, chatId);
chatService.deleteChat(chatId, currentUserId);
} }
@PostMapping("/api/v1/chats/{chatId}/read") @PostMapping("/{chatId}/read")
public void markAsRead(@PathVariable Long chatId) { public void markAsRead(@PathVariable Long chatId, Authentication auth) {
Long userId = (Long) SecurityContextHolder.getContext() Long currentUserId = extractUserId(auth);
.getAuthentication().getPrincipal(); // или твой способ log.info("markAsRead chatId {} by user {}", chatId, currentUserId);
chatService.markAsRead(chatId, userId); chatService.markAsRead(chatId, currentUserId);
}
private Long extractUserId(Authentication auth) {
// Подставь свою логику получения userId из JWT/Authentication
String login = auth.getName();
log.debug("extractUserId by login {}", login);
return userService.getUserIdByLogin(login);
} }
} }

View File

@@ -2,21 +2,37 @@ package com.krylov.refound.controller;
import com.krylov.refound.dto.SendMessageRequest; import com.krylov.refound.dto.SendMessageRequest;
import com.krylov.refound.service.ChatService; import com.krylov.refound.service.ChatService;
import java.util.Objects;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
public class ChatWebSocketController { public class ChatWebSocketController {
private final ChatService chatService; private final ChatService chatService;
private final SimpMessagingTemplate messagingTemplate;
// Клиент шлёт сюда: /app/chat.send // Клиент шлёт сюда: /app/chat.send
@MessageMapping("/chat.send") @MessageMapping("/chat.send")
public void sendMessage(SendMessageRequest request, SimpMessageHeaderAccessor headerAccessor) { public void sendMessage(SendMessageRequest request, SimpMessageHeaderAccessor headerAccessor) {
Long senderId = (Long) headerAccessor.getSessionAttributes().get("userId"); Long senderId = (Long) Objects.requireNonNull(headerAccessor.getSessionAttributes()).get("userId");
if (senderId == null) {
log.warn("sendMessage: senderId is null, session attrs missing userId");
return; // или отправить ошибку клиенту
}
if (request.getContent() == null || request.getContent().isBlank()) {
return;
}
try {
chatService.sendMessage(senderId, request.getChatId(), request.getContent()); chatService.sendMessage(senderId, request.getChatId(), request.getContent());
} catch (SecurityException | IllegalArgumentException e) {
messagingTemplate.convertAndSendToUser(senderId.toString(), "/queue/errors", e.getMessage());
}
// ChatService сам отправит сообщение всем подписчикам через messagingTemplate // ChatService сам отправит сообщение всем подписчикам через messagingTemplate
} }
} }

View File

@@ -1,34 +1,33 @@
package com.krylov.refound.controller; package com.krylov.refound.controller;
import com.krylov.refound.dto.PostResponse; import com.krylov.refound.dto.PostResponse;
import com.krylov.refound.mapper.PostMapper;
import com.krylov.refound.service.FavoriteService; import com.krylov.refound.service.FavoriteService;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController @RestController
@RequestMapping("/api/v1/favorites") @RequestMapping("/api/v1/favorites")
@RequiredArgsConstructor @RequiredArgsConstructor
public class FavoriteController { public class FavoriteController {
private final FavoriteService service; private final FavoriteService service;
private final PostMapper mapper;
@PostMapping("/{postId}/toggle") @PostMapping("/{postId}/toggle")
public void toggle(@PathVariable Long postId) { public void toggle(@PathVariable Long postId) {
log.info("toggle post {}", postId);
service.toggle(postId); service.toggle(postId);
} }
@GetMapping @GetMapping
public List<PostResponse> getAll() { public List<PostResponse> getAll() {
return service.getFavorites() log.info("get all likes");
.stream() return service.getFavorites();
.map(mapper::toResponse)
.toList();
} }
} }

View File

@@ -2,15 +2,18 @@ package com.krylov.refound.controller;
import com.krylov.refound.service.FileStorageService; import com.krylov.refound.service.FileStorageService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
import java.nio.charset.StandardCharsets;
@Slf4j
@RestController @RestController
@RequestMapping("/api/v1/files") @RequestMapping("/api/v1/files")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -19,26 +22,21 @@ public class FileStorageController {
@PostMapping @PostMapping
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) { public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
log.info("uploadFile");
String objectName = fileStorageService.uploadFile(file); String objectName = fileStorageService.uploadFile(file);
return ResponseEntity.ok(objectName); // фронт сохранит objectName как ключ картинки return ResponseEntity.ok(objectName); // фронт сохранит objectName как ключ картинки
} }
@GetMapping("/{objectName}") @GetMapping("/{*objectName}")
public ResponseEntity<byte[]> downloadFile(@PathVariable String objectName) { public ResponseEntity<byte[]> downloadFile(@PathVariable String objectName) {
FileStorageService.DownloadedFile downloadedFile = fileStorageService.downloadFile(objectName); // objectName придёт как "/avatars/cf80aebe-...jpg" — обрежь ведущий слэш
String key = objectName.startsWith("/") ? objectName.substring(1) : objectName;
String encodedFilename = UriUtils.encode(downloadedFile.originalFilename(), StandardCharsets.UTF_8); return fileStorageService.downloadFile(key);
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename);
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.parseMediaType(downloadedFile.contentType()))
.body(downloadedFile.content());
} }
@DeleteMapping("/{objectName}") @DeleteMapping("/{objectName}")
public ResponseEntity<Void> deleteFile(@PathVariable String objectName) { public ResponseEntity<Void> deleteFile(@PathVariable String objectName) {
log.info("deleteFile {}", objectName);
fileStorageService.deleteFile(objectName); fileStorageService.deleteFile(objectName);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }

View File

@@ -1,22 +1,17 @@
package com.krylov.refound.controller; package com.krylov.refound.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
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.dto.StatusUpdateRequest; import com.krylov.refound.dto.StatusUpdateRequest;
import com.krylov.refound.enums.PostType;
import com.krylov.refound.service.PostService; import com.krylov.refound.service.PostService;
import java.util.Arrays;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
@@ -27,78 +22,53 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@Slf4j
@RestController @RestController
@RequestMapping("/api/v1/posts") @RequestMapping("/api/v1/posts")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class PostController { public class PostController {
private final PostService service; private final PostService service;
private final ObjectMapper objectMapper;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @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()); log.info("create post {}", request.toString());
return service.create(request, images); return service.create(request, images);
} }
@GetMapping @GetMapping
public Page<PostResponse> getFeed( public Page<PostResponse> getFeed(@RequestParam(required = false) String search, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
@RequestParam(required = false) String search, log.info("get all feed");
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
return service.getFeed(search, pageable); return service.getFeed(search, pageable);
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public List<PostResponse> getById(@PathVariable Long id) { public List<PostResponse> getById(@PathVariable Long id) {
log.info("get feed by id {}", id);
return service.getById(id); return service.getById(id);
} }
@PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public PostResponse update( public PostResponse update(@PathVariable Long id, @RequestParam String type, @RequestParam String title, @RequestParam(required = false) String address,
@PathVariable Long id, @RequestParam(required = false) Double latitude, @RequestParam(required = false) Double longitude, @RequestParam(required = false) String description,
@RequestParam String type, @RequestParam(required = false) String category, @RequestParam(required = false) String phone,
@RequestParam String title, @RequestParam(value = "images", required = false) List<MultipartFile> images, @RequestParam(value = "existingImages", required = false) String existingImagesJson) {
@RequestParam(required = false) String address, log.info("update post {}", id);
@RequestParam(required = false) Double latitude, return service.update(id, type, title, address, latitude, longitude, description, category, phone, images, existingImagesJson);
@RequestParam(required = false) Double longitude,
@RequestParam(required = false) String description,
@RequestParam(required = false) String category,
@RequestParam(required = false) String phone,
@RequestParam(value = "images", required = false) List<MultipartFile> images,
@RequestParam(value = "existingImages", required = false) String existingImagesJson) {
PostRequest request = new PostRequest();
request.setType(PostType.from(type));
request.setTitle(title);
request.setDescription(description);
request.setCategory(category);
request.setPhone(phone);
request.setLatitude(latitude);
request.setLongitude(longitude);
request.setAddress(address);
List<String> existingUrls;
try {
existingUrls = existingImagesJson != null ? Arrays.asList(
objectMapper.readValue(existingImagesJson, String[].class)) : List.of();
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return service.update(id, request, images, existingUrls);
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public void delete(@PathVariable Long id) { public void delete(@PathVariable Long id) {
log.info("delete feed by id {}", id);
service.delete(id); service.delete(id);
} }
@PatchMapping("/{id}/status") @PatchMapping("/{id}/status")
public PostResponse updateStatus(@PathVariable Long id, public PostResponse updateStatus(@PathVariable Long id, @RequestBody StatusUpdateRequest request) {
@RequestBody StatusUpdateRequest request) { log.info("update feed {} on status {}", id, request.getStatus());
return service.updateStatus(id, request.getStatus()); return service.updateStatus(id, request.getStatus());
} }
} }

View File

@@ -15,25 +15,24 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController @RestController
@RequestMapping("/api/v1/reviews") @RequestMapping("/api/v1/reviews")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class ReviewsController { public class ReviewsController {
private final ReviewService reviewService; private final ReviewService reviewService;
@GetMapping @GetMapping
public List<ReviewResponseDto> getAllReviews() { public List<ReviewResponseDto> getAllReviews() {
List<ReviewResponseDto> allReviews = reviewService.getAllReviews(); log.info("get all reviews");
log.info("Got all reviews: {}", allReviews); return reviewService.getAllReviews();
return allReviews;
} }
@PostMapping @PostMapping
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ReviewResponseDto createReview(@Valid @RequestBody ReviewRequestDto request, Authentication authentication) { public ReviewResponseDto createReview(@Valid @RequestBody ReviewRequestDto request, Authentication authentication) {
// Взять имя пользователя из authentication log.info("user {} create review {}", authentication.getName(), request);
return reviewService.createReview(request, authentication.getName()); return reviewService.createReview(request, authentication.getName());
} }
} }

View File

@@ -3,10 +3,12 @@ package com.krylov.refound.controller;
import com.krylov.refound.entity.Statistics; import com.krylov.refound.entity.Statistics;
import com.krylov.refound.service.StatisticsService; import com.krylov.refound.service.StatisticsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/v1/statistics") @RequestMapping("/api/v1/statistics")
@@ -15,6 +17,7 @@ public class StatisticsController {
@GetMapping @GetMapping
public Statistics get() { public Statistics get() {
log.info("get all statistics");
return statisticsService.getStatistics(); return statisticsService.getStatistics();
} }
} }

View File

@@ -0,0 +1,13 @@
package com.krylov.refound.dto;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@RequiredArgsConstructor
public class UserRegisterDto {
private final UserDto userDto;
private final String token;
}

View File

@@ -16,6 +16,17 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice @RestControllerAdvice
@Slf4j @Slf4j
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<?> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage()));
}
@ExceptionHandler(LoginAlreadyExistsException.class)
public ResponseEntity<?> handleLoginAlreadyExists(LoginAlreadyExistsException ex) {
return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage()));
}
@ExceptionHandler(ApiException.class) @ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(ApiException ex, HttpServletRequest request) { public ResponseEntity<ErrorResponse> handleApiException(ApiException ex, HttpServletRequest request) {
return buildResponse(ex.getStatus(), ex.getErrorCode(), ex.getMessage(), request); return buildResponse(ex.getStatus(), ex.getErrorCode(), ex.getMessage(), request);

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.exception;
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.exception;
public class LoginAlreadyExistsException extends RuntimeException {
public LoginAlreadyExistsException(String message) {
super(message);
}
}

View File

@@ -1,8 +1,10 @@
package com.krylov.refound.repository; package com.krylov.refound.repository;
import com.krylov.refound.dto.DialogResponse;
import com.krylov.refound.entity.Message; import com.krylov.refound.entity.Message;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@@ -10,10 +12,14 @@ import org.springframework.data.repository.query.Param;
public interface MessageRepository extends JpaRepository<Message, Long> { public interface MessageRepository extends JpaRepository<Message, Long> {
List<Message> findByChatIdOrderByCreatedAtAsc(Long chatId); Page<Message> findByChatIdOrderByCreatedAtDesc(Long chatId, Pageable pageable);
long countByChatIdAndIsReadFalseAndSenderIdNot(Long chatId, Long currentUserId);
Optional<Message> findFirstByChatIdOrderByCreatedAtDesc(Long chatId);
long countByChatIdAndIsReadFalseAndSenderIdNot(Long chatId, Long userId);
@Modifying @Modifying
@Query("UPDATE Message m SET m.isRead = true WHERE m.chatId = :chatId AND m.senderId != :userId") @Query("update Message m set m.isRead = true where m.chatId = :chatId and m.senderId != :userId")
int markAsReadByChatIdAndRecipient(@Param("chatId") Long chatId, @Param("userId") Long userId); void markAsReadByChatIdAndRecipient(@Param("chatId") Long chatId, @Param("userId") Long userId);
} }

View File

@@ -0,0 +1,77 @@
package com.krylov.refound.service;
import com.krylov.refound.dto.AuthRequest;
import com.krylov.refound.dto.CheckLoginDto;
import com.krylov.refound.dto.RegisterRequest;
import com.krylov.refound.dto.UserDto;
import com.krylov.refound.dto.UserRegisterDto;
import com.krylov.refound.entity.User;
import com.krylov.refound.exception.InvalidCredentialsException;
import com.krylov.refound.exception.LoginAlreadyExistsException;
import com.krylov.refound.repository.UserRepository;
import com.krylov.refound.security.JwtService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public UserRegisterDto registerUser(RegisterRequest request){
if (userRepository.findByLogin(request.getLogin()).isPresent()) {
log.warn("registration failed: login {} already taken", request.getLogin());
throw new LoginAlreadyExistsException("Логин уже занят");
}
User user = new User();
user.setName(request.getName());
user.setLogin(request.getLogin());
user.setPassword(passwordEncoder.encode(request.getPassword()));
userRepository.save(user);
String token = jwtService.generateToken(user.getLogin());
UserDto userDto = getUserDto(user);
return new UserRegisterDto(userDto, token);
}
public UserRegisterDto loginUser(AuthRequest request){
User user = userRepository.findByLogin(request.getLogin()).orElse(null);
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
log.warn("login for user {} or password is incorrect", request.getLogin());
throw new InvalidCredentialsException("Неверный логин или пароль");
}
String token = jwtService.generateToken(user.getLogin());
UserDto userDto = getUserDto(user);
return new UserRegisterDto(userDto, token);
}
public boolean existsByLogin(CheckLoginDto request){
return userRepository.existsByLogin(request.getLogin());
}
private static UserDto getUserDto(User user) {
return new UserDto(
user.getId(),
user.getName(),
user.getLastName(),
user.getLogin(),
user.getEmail(),
user.getPhone(),
user.getAvatarUrl(),
user.getRole()
);
}
}

View File

@@ -1,26 +0,0 @@
package com.krylov.refound.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class AvatarService {
private final Path uploadDir = Paths.get("uploads/avatars");
public String save(MultipartFile file) {
try {
Files.createDirectories(uploadDir);
String filename = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path target = uploadDir.resolve(filename);
file.transferTo(target);
return "/uploads/avatars/" + filename; // или полный URL, если есть статик-сервер/CDN
} catch (IOException e) {
throw new RuntimeException("Failed to save avatar", e);
}
}
}

View File

@@ -11,17 +11,22 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class ChatService { public class ChatService {
private final ChatRepository chatRepository; private final ChatRepository chatRepository;
private final MessageRepository messageRepository; private final MessageRepository messageRepository;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final UserRepository userRepository; // твой существующий репозиторий юзеров private final UserRepository userRepository;
// Создать чат с пользователем (или вернуть существующий) // Создать чат с пользователем (или вернуть существующий)
@Transactional @Transactional
@@ -46,28 +51,28 @@ public class ChatService {
} }
// Все чаты пользователя // Все чаты пользователя
@Transactional(readOnly = true)
public List<ChatDto> getUserChats(Long userId) { public List<ChatDto> getUserChats(Long userId) {
return chatRepository.findAllByUserId(userId).stream() return chatRepository.findAllByUserId(userId).stream()
.map(c -> toDto(c, userId)) .map(c -> toDto(c, userId))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
// История сообщений в чате // История сообщений в чате, с пагинацией и проверкой доступа
public List<MessageDto> getMessages(Long chatId) { @Transactional(readOnly = true)
return messageRepository.findByChatIdOrderByCreatedAtAsc(chatId).stream() public Page<MessageDto> getMessages(Long chatId, Long userId, Pageable pageable) {
.map(this::toMessageDto) Chat chat = getChatOrThrow(chatId);
.collect(Collectors.toList()); checkParticipant(chat, userId);
return messageRepository.findByChatIdOrderByCreatedAtDesc(chatId, pageable)
.map(this::toMessageDto);
} }
// Отправка сообщения (вызывается из WebSocket контроллера) // Отправка сообщения (вызывается из WebSocket контроллера)
@Transactional @Transactional
public MessageDto sendMessage(Long senderId, Long chatId, String content) { public MessageDto sendMessage(Long senderId, Long chatId, String content) {
Chat chat = chatRepository.findById(chatId) Chat chat = getChatOrThrow(chatId);
.orElseThrow(() -> new IllegalArgumentException("Чат не найден")); checkParticipant(chat, senderId);
if (!chat.getUserOneId().equals(senderId) && !chat.getUserTwoId().equals(senderId)) {
throw new SecurityException("Вы не участник этого чата");
}
Message message = new Message(); Message message = new Message();
message.setChatId(chatId); message.setChatId(chatId);
@@ -95,6 +100,33 @@ public class ChatService {
return dto; return dto;
} }
@Transactional
public void deleteChat(Long chatId, Long userId) {
Chat chat = getChatOrThrow(chatId);
checkParticipant(chat, userId);
chatRepository.deleteById(chatId);
}
@Transactional
public void markAsRead(Long chatId, Long userId) {
Chat chat = getChatOrThrow(chatId);
checkParticipant(chat, userId);
messageRepository.markAsReadByChatIdAndRecipient(chatId, userId);
}
// ---------- вспомогательные методы ----------
private Chat getChatOrThrow(Long chatId) {
return chatRepository.findById(chatId)
.orElseThrow(() -> new IllegalArgumentException("Чат не найден"));
}
private void checkParticipant(Chat chat, Long userId) {
if (!chat.getUserOneId().equals(userId) && !chat.getUserTwoId().equals(userId)) {
throw new SecurityException("Вы не участник этого чата");
}
}
private ChatDto toDto(Chat chat, Long currentUserId) { private ChatDto toDto(Chat chat, Long currentUserId) {
Long otherUserId = chat.getUserOneId().equals(currentUserId) Long otherUserId = chat.getUserOneId().equals(currentUserId)
? chat.getUserTwoId() : chat.getUserOneId(); ? chat.getUserTwoId() : chat.getUserOneId();
@@ -104,18 +136,17 @@ public class ChatService {
dto.setOtherUserId(otherUserId); dto.setOtherUserId(otherUserId);
dto.setPostId(chat.getPostId()); dto.setPostId(chat.getPostId());
// Подтягиваем имя/аватар собеседника из твоего UserRepository
userRepository.findById(otherUserId).ifPresent(user -> { userRepository.findById(otherUserId).ifPresent(user -> {
dto.setOtherUserName(user.getName() + " " + user.getLastName()); dto.setOtherUserName(user.getName() + " " + user.getLastName());
dto.setOtherUserAvatar(user.getAvatarUrl()); dto.setOtherUserAvatar(user.getAvatarUrl());
}); });
List<Message> messages = messageRepository.findByChatIdOrderByCreatedAtAsc(chat.getId()); // без N+1: берём только последнее сообщение, а не всю историю
if (!messages.isEmpty()) { messageRepository.findFirstByChatIdOrderByCreatedAtDesc(chat.getId())
Message last = messages.getLast(); .ifPresent(last -> {
dto.setLastMessage(last.getContent()); dto.setLastMessage(last.getContent());
dto.setLastMessageAt(last.getCreatedAt()); dto.setLastMessageAt(last.getCreatedAt());
} });
dto.setUnreadCount( dto.setUnreadCount(
messageRepository.countByChatIdAndIsReadFalseAndSenderIdNot(chat.getId(), currentUserId) messageRepository.countByChatIdAndIsReadFalseAndSenderIdNot(chat.getId(), currentUserId)
@@ -134,13 +165,4 @@ public class ChatService {
dto.setCreatedAt(m.getCreatedAt()); dto.setCreatedAt(m.getCreatedAt());
return dto; return dto;
} }
public void deleteChat(Long chatId) {
chatRepository.deleteById(chatId);
}
@Transactional
public void markAsRead(Long chatId, Long userId) {
messageRepository.markAsReadByChatIdAndRecipient(chatId, userId);
}
} }

View File

@@ -1,16 +1,19 @@
package com.krylov.refound.service; package com.krylov.refound.service;
import com.krylov.refound.dto.PostResponse;
import com.krylov.refound.entity.Favorite; import com.krylov.refound.entity.Favorite;
import com.krylov.refound.entity.Post; import com.krylov.refound.entity.Post;
import com.krylov.refound.entity.User; import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.exception.ApiException; import com.krylov.refound.exception.ApiException;
import com.krylov.refound.mapper.PostMapper;
import com.krylov.refound.repository.FavoriteRepository; import com.krylov.refound.repository.FavoriteRepository;
import com.krylov.refound.repository.PostRepository; import com.krylov.refound.repository.PostRepository;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -18,44 +21,19 @@ public class FavoriteService {
private final FavoriteRepository repository; private final FavoriteRepository repository;
private final PostRepository postRepository; private final PostRepository postRepository;
private final UserService userService; private final UserService userService;
private final PostMapper mapper;
public void addToFavorites(Long postId) { @Transactional(readOnly = true)
public List<PostResponse> getFavorites() {
User user = userService.getCurrentUser(); User user = userService.getCurrentUser();
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
if (repository.existsByUserAndPost(user, post)) {
return; // уже есть
}
Favorite favorite = new Favorite();
favorite.setUser(user);
favorite.setPost(post);
repository.save(favorite);
}
public void removeFromFavorites(Long postId) {
User user = userService.getCurrentUser();
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
repository.deleteByUserAndPost(user, post);
}
public List<Post> getFavorites() {
User user = userService.getCurrentUser();
return repository.findByUser(user) return repository.findByUser(user)
.stream() .stream()
.map(Favorite::getPost) .map(Favorite::getPost)
.map(mapper::toResponse)
.toList(); .toList();
} }
@Transactional
public void toggle(Long postId) { public void toggle(Long postId) {
User user = userService.getCurrentUser(); User user = userService.getCurrentUser();

View File

@@ -1,66 +1,82 @@
package com.krylov.refound.service; package com.krylov.refound.service;
import com.krylov.refound.config.MinioProperties; import com.krylov.refound.config.MinioProperties;
import lombok.RequiredArgsConstructor; import com.krylov.refound.enums.ErrorCode;
import lombok.extern.slf4j.Slf4j; import com.krylov.refound.exception.ApiException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import java.io.IOException; import java.io.IOException;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
@Service @Service
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class FileStorageService { public class FileStorageService {
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/png", "image/webp"
);
private final S3Client s3Client; private final S3Client s3Client;
private final MinioProperties properties; private final MinioProperties properties;
/** /**
* DTO для передачи данных скачанного файла. * DTO для передачи данных скачанного файла.
* @param originalFilename Оригинальное имя файла.
* @param contentType MIME-тип файла.
* @param content Содержимое файла в виде массива байт.
*/ */
public record DownloadedFile( private record DownloadedFile(
String originalFilename, String originalFilename,
String contentType, String contentType,
byte[] content byte[] content
) {} ) {}
/** /**
* Загружает файл в MinIO, сохраняя оригинальное имя в метаданных, * Загружает файл в MinIO в корень бакета.
* и возвращает его уникальное имя (включая расширение).
*
* @param file Загружаемый файл.
* @return Сгенерированное уникальное имя файла.
*/ */
public String uploadFile(MultipartFile file) { public String uploadFile(MultipartFile file) {
try { return uploadFile(file, null);
if (file.isEmpty()) {
log.error("Попытка загрузить пустой файл.");
throw new RuntimeException("Файл не может быть пустым.");
} }
/**
* Загружает файл в MinIO в указанную "папку" (префикс ключа),
* например "avatars" — сохранится как avatars/<uuid>.ext
*/
public String uploadFile(MultipartFile file, String folder) {
validateFile(file);
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String extension = ""; String extension = extractExtension(originalFilename);
if (originalFilename != null && originalFilename.contains(".")) { String prefix = (folder != null && !folder.isBlank())
extension = originalFilename.substring(originalFilename.lastIndexOf(".")); ? folder.replaceAll("/+$", "") + "/"
} : "";
String objectName = prefix + UUID.randomUUID() + extension;
String objectName = UUID.randomUUID().toString() + extension;
log.info("Загрузка файла '{}'. Новое имя: {}", originalFilename, objectName); log.info("Загрузка файла '{}'. Новое имя: {}", originalFilename, objectName);
try {
Map<String, String> metadata = new HashMap<>(); Map<String, String> metadata = new HashMap<>();
metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8)); metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8));
@@ -74,53 +90,38 @@ public class FileStorageService {
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
log.info("Файл '{}' успешно загружен.", objectName); log.info("Файл '{}' успешно загружен.", objectName);
return objectName; return objectName;
} catch (IOException e) { } catch (IOException e) {
log.error("Ошибка при чтении файла для загрузки: {}", e.getMessage()); log.error("Ошибка при чтении файла для загрузки: {}", e.getMessage(), e);
throw new RuntimeException("Ошибка при обработке файла.", e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при обработке файла.", HttpStatus.INTERNAL_SERVER_ERROR);
} catch (Exception e) { } catch (Exception e) {
log.error("Не удалось загрузить файл: {}", e.getMessage()); log.error("Не удалось загрузить файл: {}", e.getMessage(), e);
throw new RuntimeException("Ошибка при загрузке файла.", e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при загрузке файла.", HttpStatus.INTERNAL_SERVER_ERROR);
} }
} }
/** /**
* Скачивает файл из MinIO. * Скачивает файл из MinIO и собирает готовый HTTP-ответ с нужными заголовками.
* * Вся логика формирования Content-Disposition/Content-Type инкапсулирована здесь,
* @param objectName Уникальное имя файла в хранилище. * контроллеру остаётся только вернуть результат.
* @return DTO с содержимым файла и его метаданными.
*/ */
public DownloadedFile downloadFile(String objectName) { public ResponseEntity<byte[]> downloadFile(String objectName) {
try { DownloadedFile file = fetchFile(objectName);
// 1. Получаем метаданные объекта, чтобы извлечь оригинальное имя
HeadObjectRequest headRequest = HeadObjectRequest.builder()
.bucket(properties.bucket())
.key(objectName)
.build();
HeadObjectResponse headResponse = s3Client.headObject(headRequest);
// Декодируем имя файла из метаданных
String originalFilename = URLDecoder.decode(headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8);
String contentType = headResponse.contentType();
// 2. Получаем сам объект (его содержимое) String encodedFilename = UriUtils.encode(file.originalFilename(), StandardCharsets.UTF_8);
GetObjectRequest getRequest = GetObjectRequest.builder() String disposition = file.contentType().startsWith("image/") ? "inline" : "attachment";
.bucket(properties.bucket())
.key(objectName)
.build();
ResponseBytes<GetObjectResponse> responseBytes = s3Client.getObjectAsBytes(getRequest);
log.info("Файл '{}' (оригинальное имя: '{}') успешно скачан.", objectName, originalFilename); HttpHeaders headers = new HttpHeaders();
return new DownloadedFile(originalFilename, contentType, responseBytes.asByteArray()); headers.add(HttpHeaders.CONTENT_DISPOSITION, disposition + "; filename*=UTF-8''" + encodedFilename);
} catch (Exception e) { return ResponseEntity.ok()
log.error("Ошибка при скачивании файла '{}': {}", objectName, e.getMessage()); .headers(headers)
throw new RuntimeException("Ошибка при скачивании файла.", e); .contentType(MediaType.parseMediaType(file.contentType()))
} .body(file.content());
} }
/** /**
* Удаляет файл из MinIO по его имени. * Удаляет файл из MinIO по его имени.
*
* @param objectName Имя файла для удаления.
*/ */
public void deleteFile(String objectName) { public void deleteFile(String objectName) {
try { try {
@@ -133,8 +134,70 @@ public class FileStorageService {
s3Client.deleteObject(request); s3Client.deleteObject(request);
log.info("Файл '{}' успешно удален.", objectName); log.info("Файл '{}' успешно удален.", objectName);
} catch (Exception e) { } catch (Exception e) {
log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage()); log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage(), e);
throw new RuntimeException("Ошибка при удалении файла.", e); throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при удалении файла.", HttpStatus.INTERNAL_SERVER_ERROR);
} }
} }
// ---------- вспомогательные методы ----------
private DownloadedFile fetchFile(String objectName) {
try {
HeadObjectRequest headRequest = HeadObjectRequest.builder()
.bucket(properties.bucket())
.key(objectName)
.build();
HeadObjectResponse headResponse = s3Client.headObject(headRequest);
String originalFilename = URLDecoder.decode(
headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8);
String contentType = headResponse.contentType() != null
? headResponse.contentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
GetObjectRequest getRequest = GetObjectRequest.builder()
.bucket(properties.bucket())
.key(objectName)
.build();
ResponseBytes<GetObjectResponse> responseBytes = s3Client.getObjectAsBytes(getRequest);
log.info("Файл '{}' (оригинальное имя: '{}') успешно скачан.", objectName, originalFilename);
return new DownloadedFile(originalFilename, contentType, responseBytes.asByteArray());
} catch (NoSuchKeyException e) {
log.warn("Файл не найден: {}", objectName);
throw new ApiException(ErrorCode.NOT_FOUND, "Файл не найден.", HttpStatus.NOT_FOUND);
} catch (ApiException e) {
throw e;
} catch (Exception e) {
log.error("Ошибка при скачивании файла '{}': {}", objectName, e.getMessage(), e);
throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при скачивании файла.", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
log.warn("Попытка загрузить пустой файл.");
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Файл не может быть пустым.", HttpStatus.BAD_REQUEST);
}
if (file.getSize() > MAX_FILE_SIZE) {
log.warn("Файл превышает допустимый размер: {} байт.", file.getSize());
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Файл слишком большой (максимум 10 МБ).", HttpStatus.BAD_REQUEST);
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
log.warn("Недопустимый тип файла: {}", contentType);
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Недопустимый тип файла.", HttpStatus.BAD_REQUEST);
}
}
private String extractExtension(String originalFilename) {
if (originalFilename != null && originalFilename.contains(".")) {
return originalFilename.substring(originalFilename.lastIndexOf("."));
}
return "";
}
} }

View File

@@ -1,5 +1,7 @@
package com.krylov.refound.service; package com.krylov.refound.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.krylov.refound.ai.facade.ContentModerationFacade; 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;
@@ -9,6 +11,7 @@ import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.PostCategory; import com.krylov.refound.enums.PostCategory;
import com.krylov.refound.enums.PostStatus; import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.enums.PostType;
import com.krylov.refound.exception.ApiException; import com.krylov.refound.exception.ApiException;
import com.krylov.refound.mapper.PostMapper; import com.krylov.refound.mapper.PostMapper;
import com.krylov.refound.repository.FavoriteRepository; import com.krylov.refound.repository.FavoriteRepository;
@@ -20,6 +23,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -52,6 +56,7 @@ public class PostService {
private final FileStorageService fileStorageService; private final FileStorageService fileStorageService;
private final ContentModerationFacade contentModerationFacade; private final ContentModerationFacade contentModerationFacade;
private final StatisticsService statisticsService; private final StatisticsService statisticsService;
private final ObjectMapper objectMapper;
public PostRequest create(PostRequest request, List<MultipartFile> images) { public PostRequest create(PostRequest request, List<MultipartFile> images) {
@@ -153,6 +158,35 @@ public class PostService {
.toList(); .toList();
} }
public PostResponse update(
Long id,
String type,
String title,
String address,
Double latitude,
Double longitude,
String description,
String category,
String phone,
List<MultipartFile> images,
String existingImagesJson) {
PostRequest request = new PostRequest();
request.setType(PostType.from(type));
request.setTitle(title);
request.setDescription(description);
request.setCategory(category);
request.setPhone(phone);
request.setLatitude(latitude);
request.setLongitude(longitude);
request.setAddress(address);
List<String> existingUrls = parseExistingImages(existingImagesJson);
return update(id, request, images, existingUrls);
}
// старый метод остаётся как есть — рефакторить будем позже
public PostResponse update(Long id, PostRequest request, List<MultipartFile> images, public PostResponse update(Long id, PostRequest request, List<MultipartFile> images,
List<String> existingUrls) { List<String> existingUrls) {
Post post = repository.findById(id) Post post = repository.findById(id)
@@ -217,6 +251,21 @@ public class PostService {
return mapper.toResponse(updated); return mapper.toResponse(updated);
} }
private List<String> parseExistingImages(String existingImagesJson) {
if (existingImagesJson == null) {
return List.of();
}
try {
return Arrays.asList(objectMapper.readValue(existingImagesJson, String[].class));
} catch (JsonProcessingException e) {
throw new ApiException(
ErrorCode.VALIDATION_ERROR,
"Некорректный формат existingImages",
HttpStatus.BAD_REQUEST
);
}
}
@CacheEvict(value = {"posts", "post"}, allEntries = true) @CacheEvict(value = {"posts", "post"}, allEntries = true)
public void delete(Long id) { public void delete(Long id) {
Post post = repository.findById(id) Post post = repository.findById(id)

View File

@@ -20,7 +20,7 @@ import org.springframework.web.multipart.MultipartFile;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserService { public class UserService {
private final UserRepository repository; private final UserRepository repository;
private final AvatarService avatarService; private final FileStorageService fileStorageService;
public User getCurrentUser() { public User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -55,8 +55,19 @@ public class UserService {
MultipartFile avatar = dto.getAvatar(); MultipartFile avatar = dto.getAvatar();
if (avatar != null && !avatar.isEmpty()) { if (avatar != null && !avatar.isEmpty()) {
String url = avatarService.save(avatar); // сохранение на диск/в S3/Yandex Object Storage String oldAvatarKey = user.getAvatarUrl();
user.setAvatarUrl(url);
String newAvatarKey = fileStorageService.uploadFile(avatar, "avatars");
user.setAvatarUrl(newAvatarKey);
// подчищаем старый файл, если он был и это реально ключ MinIO
if (oldAvatarKey != null && !oldAvatarKey.isBlank()) {
try {
fileStorageService.deleteFile(oldAvatarKey);
} catch (Exception e) {
log.warn("Не удалось удалить старый аватар из хранилища: {}", oldAvatarKey, e);
}
}
} }
repository.save(user); repository.save(user);