Merge pull request #1 from SlimusMinus/start-backend

start backend
This commit is contained in:
SlimusMinus
2026-03-26 00:22:18 +03:00
committed by GitHub
56 changed files with 1524 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.4'
id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.7'
}
@@ -25,22 +25,26 @@ repositories {
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-liquibase'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-session-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.liquibase:liquibase-core'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-liquibase-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-session-data-redis-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {

View File

@@ -1 +1,16 @@
services:
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: nahodka
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
ports:
- "5432:5432"
redis:
image: redis:7
ports:
- "6379:6379"

View File

@@ -0,0 +1,10 @@
package com.krylov.refound.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@EnableCaching
@Configuration
public class CacheConfig {
}

View File

@@ -0,0 +1,27 @@
package com.krylov.refound.config;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
@Configuration
public class RedisConfig {
@Value("${cache.ttl}")
private long ttl;
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(ttl))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}

View File

@@ -1,4 +1,39 @@
package com.krylov.refound.config;
import com.krylov.refound.service.security.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,19 @@
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.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);
}
}

View File

@@ -0,0 +1,27 @@
package com.krylov.refound.config;
import com.krylov.refound.util.JwtHandshakeInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.addInterceptors(new JwtHandshakeInterceptor()) // 🔥 JWT
.withSockJS();
}
}

View File

@@ -1,4 +1,27 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.AuthRequest;
import com.krylov.refound.dto.AuthResponse;
import com.krylov.refound.service.security.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService service;
@PostMapping("/register")
public AuthResponse register(@RequestBody AuthRequest request) {
return service.register(request);
}
@PostMapping("/login")
public AuthResponse login(@RequestBody AuthRequest request) {
return service.login(request);
}
}

View File

@@ -0,0 +1,37 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.ChatMessage;
import com.krylov.refound.entity.Message;
import com.krylov.refound.entity.User;
import com.krylov.refound.repository.UserRepository;
import com.krylov.refound.service.MessageService;
import java.security.Principal;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
@Controller
@RequiredArgsConstructor
public class ChatController {
private final MessageService service;
private final SimpMessagingTemplate messagingTemplate;
private final UserRepository userRepository;
@MessageMapping("/chat")
public void send(@Payload ChatMessage message, Principal principal) {
Optional<User> sender = userRepository.findByEmail(principal.getName());
Message saved = new Message();
if(sender.isPresent()){
saved = service.send(message.getReceiverId(), message.getContent());
}
// отправка получателю
messagingTemplate.convertAndSend("/topic/messages/" + message.getReceiverId(), saved);
}
}

View File

@@ -0,0 +1,34 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.PostResponse;
import com.krylov.refound.mapper.PostMapper;
import com.krylov.refound.service.FavoriteService;
import java.util.List;
import lombok.RequiredArgsConstructor;
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.RestController;
@RestController
@RequestMapping("/api/v1/favorites")
@RequiredArgsConstructor
public class FavoriteController {
private final FavoriteService service;
private final PostMapper mapper;
@PostMapping("/{postId}/toggle")
public void toggle(@PathVariable Long postId) {
service.toggle(postId);
}
@GetMapping
public List<PostResponse> getAll() {
return service.getFavorites()
.stream()
.map(mapper::toResponse)
.toList();
}
}

View File

@@ -0,0 +1,21 @@
package com.krylov.refound.controller;
import com.krylov.refound.service.FileStorageService;
import lombok.RequiredArgsConstructor;
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;
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileController {
private final FileStorageService storageService;
@PostMapping
public String upload(@RequestParam("file") MultipartFile file) {
return storageService.save(file);
}
}

View File

@@ -0,0 +1,41 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.DialogResponse;
import com.krylov.refound.entity.Message;
import com.krylov.refound.service.MessageService;
import java.util.List;
import lombok.RequiredArgsConstructor;
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;
@RestController
@RequestMapping("/api/v1/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService service;
@PostMapping
public void send(@RequestParam Long receiverId,
@RequestParam String content) {
service.send(receiverId, content);
}
@GetMapping("/{userId}")
public List<Message> chat(@PathVariable Long userId) {
return service.getChat(userId);
}
@GetMapping("/dialogs")
public List<DialogResponse> getDialogs() {
return service.getDialogs();
}
@PostMapping("/read/{userId}")
public void markAsRead(@PathVariable Long userId) {
service.markAsRead(userId);
}
}

View File

@@ -1,4 +1,50 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.PostFilter;
import com.krylov.refound.dto.PostRequest;
import com.krylov.refound.dto.PostResponse;
import com.krylov.refound.repository.PostRepository;
import com.krylov.refound.service.PostService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService service;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public PostResponse create(@RequestPart("data") PostRequest request, @RequestPart(value = "files", required = false) List<MultipartFile> files) {
return service.create(request, files);
}
@GetMapping
public Page<PostResponse> getFeed(PostFilter filter, Pageable pageable) {
return service.getFeed(filter, pageable);
}
@PutMapping("/{id}")
public PostResponse update(@PathVariable Long id, @RequestBody PostRequest request) {
return service.update(id, request);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
service.delete(id);
}
}

View File

@@ -1,4 +1,9 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class AuthRequest {
private String email;
private String password;
}

View File

@@ -1,4 +1,11 @@
package com.krylov.refound.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AuthResponse {
private String token;
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class ChatMessage {
private Long receiverId;
private String content;
}

View File

@@ -0,0 +1,15 @@
package com.krylov.refound.dto;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class DialogResponse {
private Long userId;
private String userEmail;
private String lastMessage;
private LocalDateTime lastMessageTime;
private Long unreadCount;
}

View File

@@ -0,0 +1,29 @@
package com.krylov.refound.dto;
import com.krylov.refound.enums.PostType;
import lombok.Data;
@Data
public class PostFilter {
private PostType type;
private String city;
private String category;
private String district;
private String search;
private Double lat;
private Double lng;
private Double radius; // в км
@Override
public String toString() {
return "type=" + type +
",city=" + city +
",category=" + category +
",district=" + district +
",search=" + search +
",lat=" + lat +
",lng=" + lng +
",radius=" + radius;
}
}

View File

@@ -1,4 +1,25 @@
package com.krylov.refound.dto;
import com.krylov.refound.enums.PostType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class PostRequest {
@NotNull
private PostType type;
@NotBlank
private String title;
private String description;
private String category;
@NotBlank
private String city;
private String district;
}

View File

@@ -1,4 +1,22 @@
package com.krylov.refound.dto;
import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.enums.PostType;
import java.time.LocalDateTime;
import lombok.Data;
@Data
public class PostResponse {
private Long id;
private String title;
private String description;
private String category;
private String city;
private String district;
private PostType type;
private PostStatus status;
private LocalDateTime createdAt;
private String userEmail;
private boolean isFavorite;
}

View File

@@ -0,0 +1,27 @@
package com.krylov.refound.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "favorites", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"}))
@Getter
@Setter
public class Favorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private User user;
@ManyToOne
private Post post;
}

View File

@@ -1,4 +1,25 @@
package com.krylov.refound.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "images")
@Getter
@Setter
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String url;
@ManyToOne
private Post post;
}

View File

@@ -0,0 +1,35 @@
package com.krylov.refound.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "messages")
@Getter
@Setter
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private User sender;
@ManyToOne
private User receiver;
@Column(length = 2000)
private String content;
private LocalDateTime createdAt;
private boolean isRead;
}

View File

@@ -1,4 +1,51 @@
package com.krylov.refound.entity;
import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.enums.PostType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "posts")
@Getter
@Setter
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private PostType type;
private String title;
@Column(length = 2000)
private String description;
private String category;
private String city;
private String district;
private Double latitude;
private Double longitude;
private LocalDateTime createdAt;
@Enumerated(EnumType.STRING)
private PostStatus status;
@ManyToOne
private User user;
}

View File

@@ -1,4 +1,27 @@
package com.krylov.refound.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "users")
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String password;
private LocalDateTime createdAt;
}

View File

@@ -1,4 +1,8 @@
package com.krylov.refound.enums;
public enum ErrorCode {
VALIDATION_ERROR,
NOT_FOUND,
INTERNAL_ERROR,
BAD_REQUEST
}

View File

@@ -1,4 +1,6 @@
package com.krylov.refound.enums;
public enum PostStatus {
ACTIVE,
CLOSED
}

View File

@@ -1,4 +1,6 @@
package com.krylov.refound.enums;
public enum PostType {
LOST,
FOUND
}

View File

@@ -1,4 +1,18 @@
package com.krylov.refound.exception;
public class ApiException {
import com.krylov.refound.enums.ErrorCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class ApiException extends RuntimeException {
private final ErrorCode errorCode;
private final HttpStatus status;
public ApiException(ErrorCode errorCode, String message, HttpStatus status) {
super(message);
this.errorCode = errorCode;
this.status = status;
}
}

View File

@@ -1,4 +1,15 @@
package com.krylov.refound.exception;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
}

View File

@@ -1,4 +1,56 @@
package com.krylov.refound.exception;
import com.krylov.refound.enums.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(ApiException ex, HttpServletRequest request) {
return buildResponse(ex.getStatus(), ex.getErrorCode(), ex.getMessage(), request);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
String message = ex.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(e -> e.getField() + " " + e.getDefaultMessage())
.orElse("Validation error");
return buildResponse(HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR, message, request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception ex, HttpServletRequest request) {
log.error("Unexpected error", ex);
return buildResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
ErrorCode.INTERNAL_ERROR,
"Internal server error",
request
);
}
private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, ErrorCode code, String message, HttpServletRequest request) {
ErrorResponse response = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(status.value())
.error(code.name())
.message(message)
.path(request.getRequestURI())
.build();
return new ResponseEntity<>(response, status);
}
}

View File

@@ -1,4 +1,16 @@
package com.krylov.refound.mapper;
public class PostMapper {
import com.krylov.refound.dto.PostRequest;
import com.krylov.refound.dto.PostResponse;
import com.krylov.refound.entity.Post;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface PostMapper {
Post toEntity(PostRequest request);
@Mapping(source = "user.email", target = "userEmail")
PostResponse toResponse(Post post);
}

View File

@@ -0,0 +1,24 @@
package com.krylov.refound.repository;
import com.krylov.refound.entity.Favorite;
import com.krylov.refound.entity.Post;
import com.krylov.refound.entity.User;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
boolean existsByUserAndPost(User user, Post post);
List<Favorite> findByUser(User user);
void deleteByUserAndPost(User user, Post post);
@Query("""
SELECT f.post.id FROM Favorite f
WHERE f.user.id = :userId
AND f.post.id IN :postIds
""")
List<Long> findFavoritePostIds(Long userId, List<Long> postIds);
}

View File

@@ -0,0 +1,7 @@
package com.krylov.refound.repository;
import com.krylov.refound.entity.Image;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageRepository extends JpaRepository<Image, Long> {
}

View File

@@ -0,0 +1,81 @@
package com.krylov.refound.repository;
import com.krylov.refound.dto.DialogResponse;
import com.krylov.refound.entity.Message;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
public interface MessageRepository extends JpaRepository<Message, Long> {
@Query("""
SELECT m FROM Message m
WHERE (m.sender.id = :user1 AND m.receiver.id = :user2)
OR (m.sender.id = :user2 AND m.receiver.id = :user1)
ORDER BY m.createdAt
""")
List<Message> findChat(Long user1, Long user2);
@Query("""
SELECT m FROM Message m
WHERE m.id IN (
SELECT MAX(m2.id)
FROM Message m2
WHERE m2.sender.id = :userId OR m2.receiver.id = :userId
GROUP BY
CASE
WHEN m2.sender.id = :userId THEN m2.receiver.id
ELSE m2.sender.id
END
)
ORDER BY m.createdAt DESC
""")
List<Message> findDialogs(Long userId);
// 🔥 количество непрочитанных от пользователя
@Query("""
SELECT COUNT(m) FROM Message m
WHERE m.receiver.id = :userId
AND m.sender.id = :otherUserId
AND m.isRead = false
""")
int countUnread(Long userId, Long otherUserId);
// 🔥 пометить как прочитанные
@Modifying
@Query("""
UPDATE Message m
SET m.isRead = true
WHERE m.receiver.id = :userId
AND m.sender.id = :otherUserId
AND m.isRead = false
""")
void markAsRead(Long userId, Long otherUserId);
@Query("""
SELECT new com.krylov.refound.dto.DialogResponse(
CAST(
CASE
WHEN m.sender.id = :userId THEN m.receiver.id
ELSE m.sender.id
END AS long
),
CASE
WHEN m.sender.id = :userId THEN m.receiver.email
ELSE m.sender.email
END,
m.content,
m.createdAt,
SUM(CASE WHEN m.receiver.id = :userId AND m.isRead = false THEN 1 ELSE 0 END)
)
FROM Message m
WHERE m.sender.id = :userId OR m.receiver.id = :userId
GROUP BY
CASE WHEN m.sender.id = :userId THEN m.receiver.id ELSE m.sender.id END,
CASE WHEN m.sender.id = :userId THEN m.receiver.email ELSE m.sender.email END,
m.content,
m.createdAt
""")
List<DialogResponse> findDialogsWithUnread(Long userId);
}

View File

@@ -1,4 +1,45 @@
package com.krylov.refound.repository;
public interface PostRepository {
}
import com.krylov.refound.entity.Post;
import com.krylov.refound.enums.PostType;
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.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PostRepository extends JpaRepository<Post, Long>, JpaSpecificationExecutor<Post> {
Page<Post> findByType(PostType type, Pageable pageable);
@Query(value = """
SELECT * FROM Posts p
WHERE
(:type IS NULL OR p.type = :type)
AND (:city IS NULL OR p.city = :city)
AND (:category IS NULL OR p.category = :category)
AND (
:lat IS NULL OR :lng IS NULL OR :radius IS NULL OR
(
6371 * acos(
cos(radians(:lat)) * cos(radians(p.latitude)) *
cos(radians(p.longitude) - radians(:lng)) +
sin(radians(:lat)) * sin(radians(p.latitude))
)
) <= :radius
)
""",
countQuery = "SELECT count(*) FROM Posts p",
nativeQuery = true)
Page<Post> searchWithGeo(
@Param("type") String type,
@Param("city") String city,
@Param("category") String category,
@Param("lat") Double lat,
@Param("lng") Double lng,
@Param("radius") Double radius,
Pageable pageable
);
}

View File

@@ -1,4 +1,9 @@
package com.krylov.refound.repository;
public interface UserRepository {
import com.krylov.refound.entity.User;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}

View File

@@ -0,0 +1,79 @@
package com.krylov.refound.service;
import com.krylov.refound.entity.Favorite;
import com.krylov.refound.entity.Post;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.repository.FavoriteRepository;
import com.krylov.refound.repository.PostRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FavoriteService {
private final FavoriteRepository repository;
private final PostRepository postRepository;
private final UserService userService;
public void addToFavorites(Long postId) {
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)
.stream()
.map(Favorite::getPost)
.toList();
}
public void toggle(Long postId) {
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)) {
repository.deleteByUserAndPost(user, post);
} else {
Favorite f = new Favorite();
f.setUser(user);
f.setPost(post);
repository.save(f);
}
}
}

View File

@@ -0,0 +1,42 @@
package com.krylov.refound.service;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.exception.ApiException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class FileStorageService {
@Value("${file.upload-dir}")
private String uploadDir;
public String save(MultipartFile file) {
try {
if (file.isEmpty()) {
throw new RuntimeException("File is empty");
}
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path path = Paths.get(uploadDir).toAbsolutePath().normalize();
Files.createDirectories(path);
Path target = path.resolve(fileName);
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
return "/files/" + fileName; // URL
} catch (IOException e) {
throw new ApiException(ErrorCode.BAD_REQUEST, "Only image files are allowed", HttpStatus.BAD_REQUEST);
}
}
}

View File

@@ -0,0 +1,58 @@
package com.krylov.refound.service;
import com.krylov.refound.dto.DialogResponse;
import com.krylov.refound.entity.Message;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.repository.MessageRepository;
import com.krylov.refound.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageRepository repository;
private final UserRepository userRepository;
private final UserService userService;
public Message send(Long receiverId, String content) {
User sender = userService.getCurrentUser();
User receiver = userRepository.findById(receiverId)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
Message message = new Message();
message.setSender(sender);
message.setReceiver(receiver);
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
message.setRead(false);
return repository.save(message);
}
public List<Message> getChat(Long userId) {
User current = userService.getCurrentUser();
return repository.findChat(current.getId(), userId);
}
public List<DialogResponse> getDialogs() {
User current = userService.getCurrentUser();
return repository.findDialogsWithUnread(current.getId());
}
@Transactional
public void markAsRead(Long otherUserId) {
User current = userService.getCurrentUser();
repository.markAsRead(current.getId(), otherUserId);
}
}

View File

@@ -1,4 +1,149 @@
package com.krylov.refound.service;
import com.krylov.refound.dto.PostFilter;
import com.krylov.refound.dto.PostRequest;
import com.krylov.refound.dto.PostResponse;
import com.krylov.refound.entity.Image;
import com.krylov.refound.entity.Post;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.mapper.PostMapper;
import com.krylov.refound.repository.FavoriteRepository;
import com.krylov.refound.repository.ImageRepository;
import com.krylov.refound.repository.PostRepository;
import com.krylov.refound.util.PostSpecification;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository repository;
private final ImageRepository imageRepository;
private final FileStorageService storageService;
private final PostMapper mapper;
private final UserService userService;
private final FavoriteRepository favoriteRepository;
@CacheEvict(value = "posts", allEntries = true)
public PostResponse create(PostRequest request, List<MultipartFile> files) {
User user = userService.getCurrentUser();
Post post = mapper.toEntity(request);
post.setCreatedAt(LocalDateTime.now());
post.setStatus(PostStatus.ACTIVE);
post.setUser(user);
Post savedPost = repository.save(post);
if (files != null) {
for (MultipartFile file : files) {
String url = storageService.save(file);
Image image = new Image();
image.setUrl(url);
image.setPost(savedPost);
imageRepository.save(image);
}
}
return mapper.toResponse(savedPost);
}
@Cacheable(value = "posts", key = "#filter.toString() + '_' + #pageable.pageNumber")
public Page<PostResponse> getFeed(PostFilter filter, Pageable pageable) {
User user = userService.getCurrentUser();
Page<Post> posts;
if (filter.getLat() != null) {
posts = repository.searchWithGeo(
filter.getType() != null ? filter.getType().name() : null,
filter.getCity(),
filter.getCategory(),
filter.getLat(),
filter.getLng(),
filter.getRadius(),
pageable
);
} else {
posts = repository.findAll(PostSpecification.withFilter(filter), pageable);
}
// 🔥 собираем id всех постов
List<Long> postIds = posts.getContent()
.stream()
.map(Post::getId)
.toList();
// 🔥 одним запросом получаем избранные
Set<Long> favoriteIds = new HashSet<>(
favoriteRepository.findFavoritePostIds(user.getId(), postIds)
);
return posts.map(post -> {
PostResponse response = mapper.toResponse(post);
response.setFavorite(favoriteIds.contains(post.getId()));
return response;
});
}
@Cacheable(value = "post", key = "#id")
public PostResponse getById(Long id) {
Post post = repository.findById(id)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
return mapper.toResponse(post);
}
@CacheEvict(value = {"posts", "post"}, allEntries = true)
public PostResponse update(Long id, PostRequest request) {
Post post = repository.findById(id)
.orElseThrow(() -> new ApiException(
ErrorCode.NOT_FOUND,
"Post not found",
HttpStatus.NOT_FOUND
));
post.setTitle(request.getTitle());
post.setDescription(request.getDescription());
post.setCategory(request.getCategory());
post.setCity(request.getCity());
post.setDistrict(request.getDistrict());
post.setType(request.getType());
Post updated = repository.save(post);
return mapper.toResponse(updated);
}
@CacheEvict(value = {"posts", "post"}, allEntries = true)
public void delete(Long id) {
Post post = repository.findById(id)
.orElseThrow(() -> new ApiException(
ErrorCode.NOT_FOUND,
"Post not found",
HttpStatus.NOT_FOUND
));
repository.delete(post);
}
}

View File

@@ -1,4 +1,27 @@
package com.krylov.refound.service;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.repository.UserRepository;
import com.krylov.refound.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository repository;
public User getCurrentUser() {
String email = SecurityUtils.getCurrentUserEmail();
return repository.findByEmail(email)
.orElseThrow(() -> new ApiException(
ErrorCode.NOT_FOUND,
"User not found",
HttpStatus.NOT_FOUND
));
}
}

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.service;
package com.krylov.refound.service.security;
import com.krylov.refound.dto.AuthRequest;
import com.krylov.refound.dto.AuthResponse;

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.service;
package com.krylov.refound.service.security;
import com.krylov.refound.entity.User;
import com.krylov.refound.repository.UserRepository;

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.service;
package com.krylov.refound.service.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.service;
package com.krylov.refound.service.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

View File

@@ -0,0 +1,46 @@
package com.krylov.refound.util;
import java.util.List;
import java.util.Map;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
public class JwtHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(
ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes
) throws Exception {
List<String> auth = request.getHeaders().get("Authorization");
if (auth == null || auth.isEmpty()) {
return false; // запретить подключение
}
String token = auth.get(0).replace("Bearer ", "");
// 🔥 проверяем JWT
String userEmail = JwtUtils.validateTokenAndGetEmail(token);
if (userEmail == null) {
return false;
}
attributes.put("userEmail", userEmail);
return true;
}
@Override
public void afterHandshake(
ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Exception exception
) { }
}

View File

@@ -0,0 +1,23 @@
package com.krylov.refound.util;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
@Component
public class JwtUtils {
private static final String SECRET = "SECRET_KEY"; // заменить на env var
public static String validateTokenAndGetEmail(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes()))
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,58 @@
package com.krylov.refound.util;
import com.krylov.refound.dto.PostFilter;
import com.krylov.refound.entity.Post;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.jpa.domain.Specification;
public class PostSpecification {
public static Specification<Post> withFilter(PostFilter filter) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (filter.getType() != null) {
predicates.add(cb.equal(root.get("type"), filter.getType()));
}
if (filter.getCity() != null) {
predicates.add(cb.equal(root.get("city"), filter.getCity()));
}
if (filter.getCategory() != null) {
predicates.add(cb.equal(root.get("category"), filter.getCategory()));
}
if (filter.getDistrict() != null) {
predicates.add(cb.equal(root.get("district"), filter.getDistrict()));
}
if (filter.getSearch() != null && !filter.getSearch().isBlank()) {
String search = "%" + filter.getSearch().toLowerCase() + "%";
predicates.add(cb.or(
cb.like(cb.lower(root.get("title")), search),
cb.like(cb.lower(root.get("description")), search)
));
}
if (filter.getLat() != null && filter.getLng() != null && filter.getRadius() != null) {
Expression<Double> distance = cb.function(
"6371 * acos(cos(radians(?1)) * cos(radians(latitude)) * " +
"cos(radians(longitude) - radians(?2)) + sin(radians(?1)) * sin(radians(latitude)))",
Double.class,
cb.literal(filter.getLat()),
cb.literal(filter.getLng())
);
predicates.add(cb.lessThanOrEqualTo(distance, filter.getRadius()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}

View File

@@ -1,4 +1,11 @@
package com.krylov.refound.util;
import org.springframework.security.core.context.SecurityContextHolder;
public class SecurityUtils {
public static String getCurrentUserEmail() {
return SecurityContextHolder.getContext()
.getAuthentication()
.getName();
}
}

View File

@@ -1 +0,0 @@
spring.application.name=ReFound

View File

@@ -0,0 +1,36 @@
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MB
datasource:
url: jdbc:postgresql://localhost:5432/nahodka
username: user
password: pass
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update # на старте удобно
show-sql: true
properties:
hibernate:
format_sql: true
data:
redis:
host: localhost
port: 6379
cache:
type: redis
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
file:
upload-dir: uploads/
cache:
ttl: 300

View File

@@ -0,0 +1,57 @@
databaseChangeLog:
- changeSet:
id: 001-create-users
author: you
changes:
- createTable:
tableName: users
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: email, type: VARCHAR(255), constraints: { nullable: false, unique: true } }
- column: { name: password, type: VARCHAR(255), constraints: { nullable: false } }
- column: { name: created_at, type: TIMESTAMP }
- changeSet:
id: 002-create-posts
author: you
changes:
- createTable:
tableName: posts
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: type, type: VARCHAR(20) }
- column: { name: title, type: VARCHAR(255) }
- column: { name: description, type: TEXT }
- column: { name: category, type: VARCHAR(100) }
- column: { name: city, type: VARCHAR(100) }
- column: { name: district, type: VARCHAR(100) }
- column: { name: latitude, type: DOUBLE }
- column: { name: longitude, type: DOUBLE }
- column: { name: status, type: VARCHAR(20) }
- column: { name: created_at, type: TIMESTAMP }
- column: { name: user_id, type: BIGINT }
- addForeignKeyConstraint:
baseTableName: posts
baseColumnNames: user_id
referencedTableName: users
referencedColumnNames: id
constraintName: fk_posts_user # ✅ ДОБАВИТЬ
- changeSet:
id: 003-create-images
author: you
changes:
- createTable:
tableName: images
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: url, type: VARCHAR(500) }
- column: { name: post_id, type: BIGINT }
- addForeignKeyConstraint:
baseTableName: images
baseColumnNames: post_id
referencedTableName: posts
referencedColumnNames: id
constraintName: fk_images_post # ✅

View File

@@ -0,0 +1,37 @@
databaseChangeLog:
- changeSet:
id: 004-create-messages
author: you
changes:
- createTable:
tableName: messages
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: sender_id, type: BIGINT }
- column: { name: receiver_id, type: BIGINT }
- column: { name: content, type: TEXT }
- column: { name: created_at, type: TIMESTAMP }
- column: { name: is_read, type: BOOLEAN }
- addForeignKeyConstraint:
baseTableName: messages
baseColumnNames: sender_id
referencedTableName: users
referencedColumnNames: id
constraintName: fk_messages_sender # ✅
- addForeignKeyConstraint:
baseTableName: messages
baseColumnNames: receiver_id
referencedTableName: users
referencedColumnNames: id
constraintName: fk_messages_receiver # ✅
# 🔥 индекс для чата
- createIndex:
tableName: messages
indexName: idx_messages_users
columns:
- column: { name: sender_id }
- column: { name: receiver_id }
- column: { name: created_at }

View File

@@ -0,0 +1,31 @@
databaseChangeLog:
- changeSet:
id: 005-create-favorites
author: you
changes:
- createTable:
tableName: favorites
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: user_id, type: BIGINT }
- column: { name: post_id, type: BIGINT }
- addForeignKeyConstraint:
baseTableName: favorites
baseColumnNames: user_id
referencedTableName: users
referencedColumnNames: id
constraintName: fk_favorites_user # ✅
- addForeignKeyConstraint:
baseTableName: favorites
baseColumnNames: post_id
referencedTableName: posts
referencedColumnNames: id
constraintName: fk_favorites_post # ✅
# 🔥 защита от дублей
- addUniqueConstraint:
tableName: favorites
columnNames: user_id, post_id
constraintName: unique_user_post

View File

@@ -0,0 +1,9 @@
databaseChangeLog:
- include:
file: db/changelog/001-init.yaml
- include:
file: db/changelog/002-message.yaml
- include:
file: db/changelog/003-favorite.yaml