From 593d3d6a199341eef193aba11c35713ed7afc5e5 Mon Sep 17 00:00:00 2001 From: nguyennt1 Date: Tue, 16 Dec 2025 14:59:38 +0700 Subject: [PATCH] feat : chat --- .idea/compiler.xml | 13 ++++ .idea/modules.xml | 10 --- settings.gradle | 1 + vega-hrm-chat/build.gradle | 61 ++++++++++++++++++ .../vega/hrm/chat/VegaHrmChatApplication.java | 14 +++++ .../com/vega/hrm/chat/config/KafkaConfig.java | 53 ++++++++++++++++ .../com/vega/hrm/chat/config/RedisConfig.java | 33 ++++++++++ .../vega/hrm/chat/config/WebSocketConfig.java | 29 +++++++++ .../hrm/chat/controller/ChatController.java | 30 +++++++++ .../chat/controller/ChatSocketController.java | 33 ++++++++++ .../com/vega/hrm/chat/model/ChatMessage.java | 63 +++++++++++++++++++ .../vega/hrm/chat/service/ChatService.java | 62 ++++++++++++++++++ .../src/main/resources/application.properties | 17 +++++ 13 files changed, 409 insertions(+), 10 deletions(-) delete mode 100644 .idea/modules.xml create mode 100644 vega-hrm-chat/build.gradle create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/VegaHrmChatApplication.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/KafkaConfig.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/RedisConfig.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/WebSocketConfig.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatController.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatSocketController.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/model/ChatMessage.java create mode 100644 vega-hrm-chat/src/main/java/com/vega/hrm/chat/service/ChatService.java create mode 100644 vega-hrm-chat/src/main/resources/application.properties diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 8ab19ff..5a7784a 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -18,4 +18,17 @@ + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index db3d380..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index e9d201e..9d230b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,4 +11,5 @@ rootProject.name = 'VegaHRM.Backend' include 'vega-hrm-auth' include 'vega-hrm-core' include 'vega-hrm-report' +include 'vega-hrm-chat' diff --git a/vega-hrm-chat/build.gradle b/vega-hrm-chat/build.gradle new file mode 100644 index 0000000..4a90552 --- /dev/null +++ b/vega-hrm-chat/build.gradle @@ -0,0 +1,61 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'com.vega.hrm.chat' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.4.0' + implementation 'org.springframework.boot:spring-boot-starter-web:3.4.0' + implementation 'org.projectlombok:lombok:1.18.38' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + implementation('org.springframework.boot:spring-boot-starter:3.4.0') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } + implementation 'org.springframework.boot:spring-boot-starter-log4j2:3.4.0' + implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.0' + // Kafka + implementation 'org.springframework.kafka:spring-kafka' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + annotationProcessor 'org.projectlombok:lombok:1.18.38' + implementation project(":vega-hrm-core") + implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1' + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.4.0")) + +} + +configurations { + all { + // Loại bỏ Logback + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + + // Loại bỏ logging mặc định + exclude group: 'ch.qos.logback', module: 'logback-classic' + + // Loại bỏ các thư viện Guava/Google Collections cũ + exclude group: 'com.google.guava', module: 'guava-jdk5' + exclude group: 'com.google.collections', module: 'google-collections' + + resolutionStrategy { + // Bắt buộc dùng Guava mới nhất + force 'com.google.guava:guava:32.1.3-jre' + force 'com.google.api-client:google-api-client:1.34.1' + force 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' + force 'com.google.http-client:google-http-client-jackson2:1.43.3' + } + } +} diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/VegaHrmChatApplication.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/VegaHrmChatApplication.java new file mode 100644 index 0000000..4155b46 --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/VegaHrmChatApplication.java @@ -0,0 +1,14 @@ +package com.vega.hrm.chat; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class VegaHrmChatApplication { + + public static void main(String[] args) { + SpringApplication.run(VegaHrmChatApplication.class, args); + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/KafkaConfig.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/KafkaConfig.java new file mode 100644 index 0000000..9fcc424 --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/KafkaConfig.java @@ -0,0 +1,53 @@ +package com.vega.hrm.chat.config; + +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Value("${chat.kafka.bootstrap-servers:localhost:9092}") + private String bootstrapServers; + + @Value("${chat.kafka.topic.chat:vega-hrm-chat}") + private String chatTopicName; + + @Bean + public NewTopic chatTopic() { + return TopicBuilder.name(chatTopicName) + .partitions(6) + .replicas(1) + .compact() // chat message log có thể dùng compact, tùy nhu cầu + .build(); + } + + @Bean + public ProducerFactory producerFactory() { + Map configProps = new HashMap<>(); + configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.ACKS_CONFIG, "all"); + configProps.put(ProducerConfig.LINGER_MS_CONFIG, 5); + configProps.put(ProducerConfig.BATCH_SIZE_CONFIG, 32_768); + return new DefaultKafkaProducerFactory<>(configProps); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/RedisConfig.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/RedisConfig.java new file mode 100644 index 0000000..eb5f043 --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/RedisConfig.java @@ -0,0 +1,33 @@ +package com.vega.hrm.chat.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + // Có thể chuyển sang cấu hình qua application.properties (host, port, password) + return new LettuceConnectionFactory(); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/WebSocketConfig.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/WebSocketConfig.java new file mode 100644 index 0000000..f46cc0e --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/config/WebSocketConfig.java @@ -0,0 +1,29 @@ +package com.vega.hrm.chat.config; + +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) { + // Broker đơn giản in-memory cho demo; có thể scale bằng Kafka/RabbitMQ sau + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws/chat") + .setAllowedOriginPatterns("*"); + // Nếu cần SockJS: + // .withSockJS(); + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatController.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatController.java new file mode 100644 index 0000000..ab39378 --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatController.java @@ -0,0 +1,30 @@ +package com.vega.hrm.chat.controller; + +import com.vega.hrm.chat.model.ChatMessage; +import com.vega.hrm.chat.service.ChatService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/chat") +public class ChatController { + + private final ChatService chatService; + + public ChatController(ChatService chatService) { + this.chatService = chatService; + } + + /** + * API REST chỉ dùng để tạo box chat / group lần đầu, + * gửi message realtime sẽ đi qua WebSocket. + */ + @PostMapping("/rooms") + public ResponseEntity createRoom(@RequestParam String roomId) { + // TODO: sau này có thể lưu meta vào DB (tên phòng, thành viên, loại phòng...) + // Hiện tại chỉ cần đảm bảo roomId hợp lệ và trả về cho FE dùng. + return ResponseEntity.ok(roomId); + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatSocketController.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatSocketController.java new file mode 100644 index 0000000..3f5ef31 --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/controller/ChatSocketController.java @@ -0,0 +1,33 @@ +package com.vega.hrm.chat.controller; + +import com.vega.hrm.chat.model.ChatMessage; +import com.vega.hrm.chat.service.ChatService; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + +@Controller +public class ChatSocketController { + + private final ChatService chatService; + + public ChatSocketController(ChatService chatService) { + this.chatService = chatService; + } + + /** + * Client gửi message vào: /app/chat/{roomId} + * Server publish ra topic: /topic/chat/{roomId} + */ + @MessageMapping("/chat/{roomId}") + @SendTo("/topic/chat/{roomId}") + public ChatMessage sendToRoom(@DestinationVariable String roomId, + @Payload ChatMessage message) { + message.setRoomId(roomId); + return chatService.sendMessage(message); + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/model/ChatMessage.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/model/ChatMessage.java new file mode 100644 index 0000000..b6cf5fa --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/model/ChatMessage.java @@ -0,0 +1,63 @@ +package com.vega.hrm.chat.model; + +import java.time.Instant; + +public class ChatMessage { + + private String id; + private String roomId; // ví dụ: theo phòng ban, project, conversation id + private String senderId; + private String receiverId; // optional, nếu là 1-1 chat + private String content; + private Instant sentAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRoomId() { + return roomId; + } + + public void setRoomId(String roomId) { + this.roomId = roomId; + } + + public String getSenderId() { + return senderId; + } + + public void setSenderId(String senderId) { + this.senderId = senderId; + } + + public String getReceiverId() { + return receiverId; + } + + public void setReceiverId(String receiverId) { + this.receiverId = receiverId; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } +} + + diff --git a/vega-hrm-chat/src/main/java/com/vega/hrm/chat/service/ChatService.java b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/service/ChatService.java new file mode 100644 index 0000000..3dc5d63 --- /dev/null +++ b/vega-hrm-chat/src/main/java/com/vega/hrm/chat/service/ChatService.java @@ -0,0 +1,62 @@ +package com.vega.hrm.chat.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vega.hrm.chat.model.ChatMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +public class ChatService { + + private final KafkaTemplate kafkaTemplate; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${chat.kafka.topic.chat:vega-hrm-chat}") + private String chatTopicName; + + @Value("${chat.redis.recent-ttl-minutes:1440}") + private long recentMessagesTtlMinutes; + + public ChatService(KafkaTemplate kafkaTemplate, + RedisTemplate redisTemplate) { + this.kafkaTemplate = kafkaTemplate; + this.redisTemplate = redisTemplate; + } + + public ChatMessage sendMessage(ChatMessage input) { + ChatMessage message = new ChatMessage(); + message.setId(UUID.randomUUID().toString()); + message.setRoomId(input.getRoomId()); + message.setSenderId(input.getSenderId()); + message.setReceiverId(input.getReceiverId()); + message.setContent(input.getContent()); + message.setSentAt(Instant.now()); + + // 1. Gửi vào Kafka để các service khác consume (log, phân tích, notify...) + try { + String payload = objectMapper.writeValueAsString(message); + kafkaTemplate.send(chatTopicName, message.getRoomId(), payload); + } catch (JsonProcessingException e) { + throw new RuntimeException("Cannot serialize chat message", e); + } + + // 2. Lưu recent messages trong Redis để load nhanh lịch sử gần đây + String redisKey = "chat:room:" + message.getRoomId() + ":recent"; + redisTemplate.opsForList().rightPush(redisKey, message); + redisTemplate.expire(redisKey, recentMessagesTtlMinutes, TimeUnit.MINUTES); + + // 3. Có thể update trạng thái “unread”/“last_seen” ở đây (tương lai) + + return message; + } +} + + diff --git a/vega-hrm-chat/src/main/resources/application.properties b/vega-hrm-chat/src/main/resources/application.properties new file mode 100644 index 0000000..6cd8009 --- /dev/null +++ b/vega-hrm-chat/src/main/resources/application.properties @@ -0,0 +1,17 @@ +spring.application.name=vega-hrm-chat + +# HTTP +server.port=8093 + +# Kafka +chat.kafka.bootstrap-servers=localhost:9092 +chat.kafka.topic.chat=vega-hrm-chat + +# Redis (tối ưu: chuyển sang config riêng theo môi trường) +spring.redis.host=localhost +spring.redis.port=6379 + +# TTL lịch sử chat gần nhất trong Redis (phút) +chat.redis.recent-ttl-minutes=1440 + +