feat : chat

This commit is contained in:
nguyennt1 2025-12-16 14:59:38 +07:00
parent 3c055be676
commit 593d3d6a19
13 changed files with 409 additions and 10 deletions

View File

@ -18,4 +18,17 @@
<module name="vega-hrm-core.test" target="21" />
</bytecodeTargetLevel>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="VegaHRM.Backend.vega-hrm-auth" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-auth.main" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-auth.test" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-core" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-core.main" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-core.test" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-report" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-report.main" options="-parameters" />
<module name="VegaHRM.Backend.vega-hrm-report.test" options="-parameters" />
</option>
</component>
</project>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/vega-hrm-auth/VegaHRM.Backend.vega-hrm-auth.main.iml" filepath="$PROJECT_DIR$/.idea/modules/vega-hrm-auth/VegaHRM.Backend.vega-hrm-auth.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/vega-hrm-core/VegaHRM.Backend.vega-hrm-core.main.iml" filepath="$PROJECT_DIR$/.idea/modules/vega-hrm-core/VegaHRM.Backend.vega-hrm-core.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/vega-hrm-report/VegaHRM.Backend.vega-hrm-report.main.iml" filepath="$PROJECT_DIR$/.idea/modules/vega-hrm-report/VegaHRM.Backend.vega-hrm-report.main.iml" />
</modules>
</component>
</project>

View File

@ -11,4 +11,5 @@ rootProject.name = 'VegaHRM.Backend'
include 'vega-hrm-auth'
include 'vega-hrm-core'
include 'vega-hrm-report'
include 'vega-hrm-chat'

View File

@ -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
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'
}
}
}

View File

@ -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);
}
}

View File

@ -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 thể dùng compact, tùy nhu cầu
.build();
}
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> 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<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}

View File

@ -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() {
// thể chuyển sang cấu hình qua application.properties (host, port, password)
return new LettuceConnectionFactory();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> 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;
}
}

View File

@ -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; 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();
}
}

View File

@ -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<String> createRoom(@RequestParam String roomId) {
// TODO: sau này 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ệ trả về cho FE dùng.
return ResponseEntity.ok(roomId);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,63 @@
package com.vega.hrm.chat.model;
import java.time.Instant;
public class ChatMessage {
private String id;
private String roomId; // dụ: theo phòng ban, project, conversation id
private String senderId;
private String receiverId; // optional, nếu 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;
}
}

View File

@ -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<String, String> kafkaTemplate;
private final RedisTemplate<String, Object> 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<String, String> kafkaTemplate,
RedisTemplate<String, Object> 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. thể update trạng thái unread/last_seen đây (tương lai)
return message;
}
}

View File

@ -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