start backend
This commit is contained in:
28
build.gradle
28
build.gradle
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
10
src/main/java/com/krylov/refound/config/CacheConfig.java
Normal file
10
src/main/java/com/krylov/refound/config/CacheConfig.java
Normal 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 {
|
||||
|
||||
}
|
||||
27
src/main/java/com/krylov/refound/config/RedisConfig.java
Normal file
27
src/main/java/com/krylov/refound/config/RedisConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
19
src/main/java/com/krylov/refound/config/WebConfig.java
Normal file
19
src/main/java/com/krylov/refound/config/WebConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/krylov/refound/config/WebSocketConfig.java
Normal file
27
src/main/java/com/krylov/refound/config/WebSocketConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
package com.krylov.refound.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AuthRequest {
|
||||
private String email;
|
||||
private String password;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
package com.krylov.refound.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class AuthResponse {
|
||||
|
||||
private String token;
|
||||
}
|
||||
|
||||
9
src/main/java/com/krylov/refound/dto/ChatMessage.java
Normal file
9
src/main/java/com/krylov/refound/dto/ChatMessage.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChatMessage {
|
||||
private Long receiverId;
|
||||
private String content;
|
||||
}
|
||||
15
src/main/java/com/krylov/refound/dto/DialogResponse.java
Normal file
15
src/main/java/com/krylov/refound/dto/DialogResponse.java
Normal 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;
|
||||
}
|
||||
29
src/main/java/com/krylov/refound/dto/PostFilter.java
Normal file
29
src/main/java/com/krylov/refound/dto/PostFilter.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
27
src/main/java/com/krylov/refound/entity/Favorite.java
Normal file
27
src/main/java/com/krylov/refound/entity/Favorite.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
35
src/main/java/com/krylov/refound/entity/Message.java
Normal file
35
src/main/java/com/krylov/refound/entity/Message.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
package com.krylov.refound.enums;
|
||||
|
||||
public enum ErrorCode {
|
||||
VALIDATION_ERROR,
|
||||
NOT_FOUND,
|
||||
INTERNAL_ERROR,
|
||||
BAD_REQUEST
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package com.krylov.refound.enums;
|
||||
|
||||
public enum PostStatus {
|
||||
ACTIVE,
|
||||
CLOSED
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package com.krylov.refound.enums;
|
||||
|
||||
public enum PostType {
|
||||
LOST,
|
||||
FOUND
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/main/java/com/krylov/refound/service/MessageService.java
Normal file
58
src/main/java/com/krylov/refound/service/MessageService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.krylov.refound.service;
|
||||
package com.krylov.refound.service.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.krylov.refound.service;
|
||||
package com.krylov.refound.service.security;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
|
||||
@@ -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
|
||||
) { }
|
||||
}
|
||||
23
src/main/java/com/krylov/refound/util/JwtUtils.java
Normal file
23
src/main/java/com/krylov/refound/util/JwtUtils.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/main/java/com/krylov/refound/util/PostSpecification.java
Normal file
58
src/main/java/com/krylov/refound/util/PostSpecification.java
Normal 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]));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
spring.application.name=ReFound
|
||||
36
src/main/resources/application.yaml
Normal file
36
src/main/resources/application.yaml
Normal 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
|
||||
57
src/main/resources/db/changelog/001-init.yaml
Normal file
57
src/main/resources/db/changelog/001-init.yaml
Normal 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 # ✅
|
||||
37
src/main/resources/db/changelog/002-message.yaml
Normal file
37
src/main/resources/db/changelog/002-message.yaml
Normal 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 }
|
||||
31
src/main/resources/db/changelog/003-favorite.yaml
Normal file
31
src/main/resources/db/changelog/003-favorite.yaml
Normal 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
|
||||
9
src/main/resources/db/changelog/db.changelog-master.yaml
Normal file
9
src/main/resources/db/changelog/db.changelog-master.yaml
Normal 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
|
||||
Reference in New Issue
Block a user