mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-13 05:59:11 +00:00
Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
package com.unicorn.hgzero.meeting.biz.domain;
|
||||
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -77,6 +81,101 @@ public class Minutes {
|
||||
*/
|
||||
private LocalDateTime finalizedAt;
|
||||
|
||||
/**
|
||||
* 회의록 확정 가능 여부 검증
|
||||
*
|
||||
* @param meeting 회의 정보
|
||||
* @param userId 확정 요청자 ID
|
||||
* @throws BusinessException 검증 실패 시
|
||||
*/
|
||||
public void validateCanConfirm(Meeting meeting, String userId) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
// 1. 상태 검증
|
||||
if (!"DRAFT".equals(this.status)) {
|
||||
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "회의록이 작성중 상태가 아닙니다. 현재 상태: " + this.status);
|
||||
}
|
||||
|
||||
if (!"COMPLETED".equals(meeting.getStatus())) {
|
||||
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "회의가 종료되지 않았습니다. 현재 회의 상태: " + meeting.getStatus());
|
||||
}
|
||||
|
||||
// 2. 권한 검증
|
||||
boolean isOrganizer = meeting.getOrganizerId().equals(userId);
|
||||
boolean isParticipant = meeting.getParticipants() != null && meeting.getParticipants().contains(userId);
|
||||
|
||||
if (!isOrganizer && !isParticipant) {
|
||||
throw new BusinessException(ErrorCode.ACCESS_DENIED, "회의록 확정 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 3. 필수 항목 검증
|
||||
if (this.title == null || this.title.trim().isEmpty()) {
|
||||
errors.add("회의록 제목이 없습니다.");
|
||||
} else if (this.title.trim().length() < 5) {
|
||||
errors.add("회의록 제목은 최소 5자 이상이어야 합니다.");
|
||||
}
|
||||
|
||||
if (meeting.getParticipants() == null || meeting.getParticipants().isEmpty()) {
|
||||
errors.add("참석자가 최소 1명 이상 있어야 합니다.");
|
||||
}
|
||||
|
||||
// 섹션 검증
|
||||
if (this.sections != null && !this.sections.isEmpty()) {
|
||||
boolean hasDiscussionContent = false;
|
||||
boolean hasDecisionContent = false;
|
||||
|
||||
for (MinutesSection section : this.sections) {
|
||||
if ("DISCUSSION".equals(section.getType()) && section.getContent() != null && section.getContent().trim().length() >= 20) {
|
||||
hasDiscussionContent = true;
|
||||
}
|
||||
if ("DECISION".equals(section.getType())) {
|
||||
if (section.getContent() != null && !section.getContent().trim().isEmpty()) {
|
||||
hasDecisionContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDiscussionContent) {
|
||||
errors.add("주요 논의 내용이 없거나 20자 미만입니다.");
|
||||
}
|
||||
|
||||
if (!hasDecisionContent) {
|
||||
errors.add("결정 사항이 없습니다. (결정사항이 없는 경우 '결정사항 없음'을 명시해주세요)");
|
||||
}
|
||||
} else {
|
||||
errors.add("회의록 섹션이 없습니다.");
|
||||
}
|
||||
|
||||
// 4. 데이터 무결성 검증
|
||||
if (this.sections != null) {
|
||||
for (MinutesSection section : this.sections) {
|
||||
// 필수 필드 검증
|
||||
if (section.getTitle() == null || section.getTitle().trim().isEmpty()) {
|
||||
errors.add("섹션 제목이 비어있는 섹션이 있습니다. (섹션 ID: " + section.getSectionId() + ")");
|
||||
}
|
||||
|
||||
if (section.getContent() == null || section.getContent().trim().isEmpty()) {
|
||||
errors.add("섹션 내용이 비어있는 섹션이 있습니다. (섹션 ID: " + section.getSectionId() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 검증 오류가 있으면 예외 발생
|
||||
if (!errors.isEmpty()) {
|
||||
String errorMessage = "회의록 확정 검증 실패:\n" + String.join("\n", errors);
|
||||
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, errorMessage);
|
||||
}
|
||||
|
||||
// 5. 이력 검증 (경고)
|
||||
if (this.lastModifiedAt != null) {
|
||||
Duration duration = Duration.between(this.lastModifiedAt, LocalDateTime.now());
|
||||
if (duration.toHours() > 24) {
|
||||
// 로그로 경고만 출력 (진행 가능)
|
||||
// TODO: 경고 메시지를 응답에 포함시키는 방법 고려
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 확정
|
||||
*/
|
||||
@@ -114,4 +213,17 @@ public class Minutes {
|
||||
this.title = title;
|
||||
this.version++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 섹션 잠금
|
||||
*/
|
||||
public void lockAllSections(String userId) {
|
||||
if (this.sections != null) {
|
||||
for (MinutesSection section : this.sections) {
|
||||
if (!section.isLocked()) {
|
||||
section.lock(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ public class MeetingService implements
|
||||
private final MeetingAnalysisWriter meetingAnalysisWriter;
|
||||
private final CacheService cacheService;
|
||||
private final EventPublisher eventPublisher;
|
||||
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter participantWriter;
|
||||
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader participantReader;
|
||||
|
||||
/**
|
||||
* 회의 생성
|
||||
@@ -104,6 +106,12 @@ public class MeetingService implements
|
||||
// 5. 회의 저장
|
||||
Meeting savedMeeting = meetingWriter.save(meeting);
|
||||
|
||||
// 5-1. 참석자 목록 저장
|
||||
if (command.participants() != null && !command.participants().isEmpty()) {
|
||||
participantWriter.saveParticipants(meetingId, command.participants());
|
||||
log.debug("Participants saved: meetingId={}, count={}", meetingId, command.participants().size());
|
||||
}
|
||||
|
||||
// 6. 캐시 저장 (TTL: 10분)
|
||||
try {
|
||||
cacheService.cacheMeeting(meetingId, savedMeeting, 600);
|
||||
@@ -525,22 +533,19 @@ public class MeetingService implements
|
||||
}
|
||||
|
||||
// 이미 참석자로 등록되었는지 확인
|
||||
if (meeting.getParticipants() != null && meeting.getParticipants().contains(command.email())) {
|
||||
if (participantReader.existsParticipant(command.meetingId(), command.email())) {
|
||||
log.warn("Email {} is already a participant of meeting {}", command.email(), command.meetingId());
|
||||
throw new BusinessException(ErrorCode.DUPLICATE_RESOURCE);
|
||||
}
|
||||
|
||||
// 참석자 목록에 추가
|
||||
meeting.addParticipant(command.email());
|
||||
|
||||
// 저장
|
||||
meetingWriter.save(meeting);
|
||||
// 참석자 저장
|
||||
participantWriter.saveParticipant(command.meetingId(), command.email());
|
||||
|
||||
// TODO: 실제 이메일 발송 구현 필요
|
||||
// 이메일 발송 서비스 호출
|
||||
// emailService.sendInvitation(command.email(), meeting, command.frontendUrl());
|
||||
// 현재는 로그만 남기고 성공으로 처리
|
||||
log.info("Invitation email would be sent to {} for meeting {} (Frontend URL: {})",
|
||||
log.info("Invitation email would be sent to {} for meeting {} (Frontend URL: {})",
|
||||
command.email(), meeting.getTitle(), command.frontendUrl());
|
||||
|
||||
log.info("Participant invited successfully: {} to meeting {}", command.email(), command.meetingId());
|
||||
|
||||
@@ -2,11 +2,17 @@ package com.unicorn.hgzero.meeting.biz.service;
|
||||
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.in.minutes.*;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter;
|
||||
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -34,6 +40,10 @@ public class MinutesService implements
|
||||
|
||||
private final MinutesReader minutesReader;
|
||||
private final MinutesWriter minutesWriter;
|
||||
private final MeetingReader meetingReader;
|
||||
private final MinutesSectionReader minutesSectionReader;
|
||||
private final MinutesSectionWriter minutesSectionWriter;
|
||||
private final CacheService cacheService;
|
||||
|
||||
/**
|
||||
* 회의록 생성
|
||||
@@ -118,24 +128,55 @@ public class MinutesService implements
|
||||
@Override
|
||||
@Transactional
|
||||
public Minutes finalizeMinutes(String minutesId, String userId) {
|
||||
log.info("Finalizing minutes: {}", minutesId);
|
||||
log.info("Finalizing minutes: {} by user: {}", minutesId, userId);
|
||||
|
||||
// 회의록 조회
|
||||
// 1. 회의록 조회
|
||||
Minutes minutes = minutesReader.findById(minutesId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다."));
|
||||
|
||||
// 상태 검증
|
||||
if ("FINALIZED".equals(minutes.getStatus())) {
|
||||
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
|
||||
}
|
||||
// 2. 회의 정보 조회
|
||||
Meeting meeting = meetingReader.findById(minutes.getMeetingId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의 정보를 찾을 수 없습니다."));
|
||||
|
||||
// 회의록 확정
|
||||
// 3. 회의록 섹션 조회 및 설정
|
||||
List<MinutesSection> sections = minutesSectionReader.findByMinutesIdOrderByOrder(minutesId);
|
||||
minutes = Minutes.builder()
|
||||
.minutesId(minutes.getMinutesId())
|
||||
.meetingId(minutes.getMeetingId())
|
||||
.title(minutes.getTitle())
|
||||
.sections(sections)
|
||||
.status(minutes.getStatus())
|
||||
.version(minutes.getVersion())
|
||||
.createdBy(minutes.getCreatedBy())
|
||||
.createdAt(minutes.getCreatedAt())
|
||||
.lastModifiedAt(minutes.getLastModifiedAt())
|
||||
.lastModifiedBy(minutes.getLastModifiedBy())
|
||||
.finalizedBy(minutes.getFinalizedBy())
|
||||
.finalizedAt(minutes.getFinalizedAt())
|
||||
.build();
|
||||
|
||||
// 4. 회의록 확정 가능 여부 검증
|
||||
minutes.validateCanConfirm(meeting, userId);
|
||||
|
||||
// 5. 모든 섹션 잠금
|
||||
minutes.lockAllSections(userId);
|
||||
|
||||
// 6. 회의록 확정
|
||||
minutes.finalize(userId);
|
||||
|
||||
// 저장
|
||||
// 7. 회의록 저장
|
||||
Minutes finalizedMinutes = minutesWriter.save(minutes);
|
||||
|
||||
log.info("Minutes finalized successfully: {}", minutesId);
|
||||
// 8. 섹션 잠금 상태 저장 (기존 엔티티 조회 후 업데이트하므로 연관관계 유지됨)
|
||||
if (sections != null) {
|
||||
for (MinutesSection section : sections) {
|
||||
minutesSectionWriter.save(section);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 캐시에 저장 (TTL: 10분) - 컨트롤러에서 처리됨
|
||||
|
||||
log.info("Minutes finalized successfully: {}, version: {}", minutesId, finalizedMinutes.getVersion());
|
||||
return finalizedMinutes;
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.unicorn.hgzero.meeting.biz.usecase.out;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 참석자 조회 인터페이스
|
||||
*/
|
||||
public interface ParticipantReader {
|
||||
|
||||
/**
|
||||
* 회의 ID로 참석자 목록 조회
|
||||
*/
|
||||
List<String> findParticipantsByMeetingId(String meetingId);
|
||||
|
||||
/**
|
||||
* 사용자 ID로 참여 회의 목록 조회
|
||||
*/
|
||||
List<String> findMeetingsByParticipant(String userId);
|
||||
|
||||
/**
|
||||
* 특정 회의에 특정 사용자가 참석자로 등록되어 있는지 확인
|
||||
*/
|
||||
boolean existsParticipant(String meetingId, String userId);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.unicorn.hgzero.meeting.biz.usecase.out;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 참석자 저장 인터페이스
|
||||
*/
|
||||
public interface ParticipantWriter {
|
||||
|
||||
/**
|
||||
* 회의에 참석자 추가
|
||||
*/
|
||||
void saveParticipant(String meetingId, String userId);
|
||||
|
||||
/**
|
||||
* 회의에 참석자 목록 일괄 저장
|
||||
*/
|
||||
void saveParticipants(String meetingId, List<String> userIds);
|
||||
|
||||
/**
|
||||
* 회의에서 참석자 삭제
|
||||
*/
|
||||
void deleteParticipant(String meetingId, String userId);
|
||||
|
||||
/**
|
||||
* 회의의 모든 참석자 삭제
|
||||
*/
|
||||
void deleteAllParticipants(String meetingId);
|
||||
}
|
||||
+2
-55
@@ -2,71 +2,18 @@ package com.unicorn.hgzero.meeting.infra.cache;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 캐시 설정
|
||||
*
|
||||
* RedisConnectionFactory와 RedisTemplate은 RedisConfig에서 정의됨
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class CacheConfig {
|
||||
|
||||
@Value("${spring.data.redis.host:localhost}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port:6379}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.data.redis.password:}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.database:1}")
|
||||
private int database;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리
|
||||
*/
|
||||
@Bean
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
var factory = new LettuceConnectionFactory(redisHost, redisPort);
|
||||
factory.setDatabase(database);
|
||||
|
||||
// 비밀번호가 설정된 경우에만 적용
|
||||
if (redisPassword != null && !redisPassword.isEmpty()) {
|
||||
factory.setPassword(redisPassword);
|
||||
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}, password: ****", redisHost, redisPort, database);
|
||||
} else {
|
||||
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
|
||||
}
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 템플릿
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// String 직렬화 설정
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setValueSerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
template.setHashValueSerializer(new StringRedisSerializer());
|
||||
|
||||
template.afterPropertiesSet();
|
||||
log.info("Redis 템플릿 설정 완료");
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 직렬화용 ObjectMapper
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.unicorn.hgzero.meeting.infra.config;
|
||||
|
||||
import io.lettuce.core.ClientOptions;
|
||||
import io.lettuce.core.SocketOptions;
|
||||
import io.lettuce.core.TimeoutOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 설정
|
||||
* Standalone 모드로 연결
|
||||
*
|
||||
* - ReadFrom 설정 제거: Master-Replica 자동 탐색 비활성화
|
||||
* - 로컬 개발 환경에서 Kubernetes 내부 DNS 해석 오류 방지
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class RedisConfig {
|
||||
|
||||
@Value("${spring.data.redis.host}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.data.redis.password}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.database:0}")
|
||||
private int redisDatabase;
|
||||
|
||||
/**
|
||||
* Lettuce 클라이언트 설정
|
||||
* - Standalone 모드: ReadFrom 설정 제거로 Master-Replica 자동 탐색 비활성화
|
||||
* - AutoReconnect: 연결 끊김 시 자동 재연결
|
||||
* - DisconnectedBehavior.REJECT_COMMANDS: 연결 끊김 시 명령 거부
|
||||
*/
|
||||
@Bean
|
||||
public LettuceClientConfiguration lettuceClientConfiguration() {
|
||||
|
||||
// 소켓 옵션 설정
|
||||
SocketOptions socketOptions = SocketOptions.builder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.keepAlive(true)
|
||||
.build();
|
||||
|
||||
// 타임아웃 옵션 설정
|
||||
TimeoutOptions timeoutOptions = TimeoutOptions.builder()
|
||||
.fixedTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
// 클라이언트 옵션 설정
|
||||
ClientOptions clientOptions = ClientOptions.builder()
|
||||
.socketOptions(socketOptions)
|
||||
.timeoutOptions(timeoutOptions)
|
||||
.autoReconnect(true)
|
||||
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
|
||||
.build();
|
||||
|
||||
// Lettuce 클라이언트 설정
|
||||
// ReadFrom 설정 제거: Standalone 모드로 동작, Master-Replica 자동 탐색 비활성화
|
||||
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
|
||||
.clientOptions(clientOptions)
|
||||
.commandTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
log.info("Redis Lettuce Client 설정 완료 - Standalone 모드 (Master-Replica 자동 탐색 비활성화)");
|
||||
|
||||
return clientConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* LettuceConnectionFactory 설정
|
||||
* Standalone 설정과 Lettuce Client 설정 결합
|
||||
*/
|
||||
@Bean
|
||||
public LettuceConnectionFactory redisConnectionFactory(LettuceClientConfiguration lettuceClientConfiguration) {
|
||||
|
||||
// Standalone 설정
|
||||
RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration();
|
||||
standaloneConfig.setHostName(redisHost);
|
||||
standaloneConfig.setPort(redisPort);
|
||||
standaloneConfig.setPassword(redisPassword);
|
||||
standaloneConfig.setDatabase(redisDatabase);
|
||||
|
||||
// LettuceConnectionFactory 생성
|
||||
LettuceConnectionFactory factory = new LettuceConnectionFactory(standaloneConfig, lettuceClientConfiguration);
|
||||
|
||||
log.info("LettuceConnectionFactory 설정 완료 - Host: {}:{}, Database: {}",
|
||||
redisHost, redisPort, redisDatabase);
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* RedisTemplate 설정
|
||||
* String Serializer 사용
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// String Serializer 사용
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setValueSerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
template.setHashValueSerializer(stringSerializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
|
||||
log.info("RedisTemplate 설정 완료");
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -1,6 +1,7 @@
|
||||
package com.unicorn.hgzero.meeting.infra.controller;
|
||||
|
||||
import com.unicorn.hgzero.common.dto.ApiResponse;
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
|
||||
@@ -198,12 +199,20 @@ public class MinutesController {
|
||||
try {
|
||||
// 회의록 확정
|
||||
MinutesDTO finalizedMinutes = minutesService.finalizeMinutesDTO(minutesId, userId);
|
||||
|
||||
|
||||
// 응답 DTO 생성
|
||||
MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes);
|
||||
|
||||
// 캐시 무효화
|
||||
cacheService.evictCacheMinutesDetail(minutesId);
|
||||
// 캐시 저장 (TTL: 10분)
|
||||
try {
|
||||
cacheService.cacheMinutesDetail(minutesId, response);
|
||||
log.debug("캐시에 확정된 회의록 저장 완료 - minutesId: {}", minutesId);
|
||||
} catch (Exception cacheEx) {
|
||||
log.warn("회의록 캐시 저장 실패 - minutesId: {}", minutesId, cacheEx);
|
||||
// 캐시 저장 실패는 무시하고 진행
|
||||
}
|
||||
|
||||
// 캐시 무효화 (목록 캐시)
|
||||
cacheService.evictCacheMinutesList(userId);
|
||||
|
||||
// 회의록 확정 이벤트 발행
|
||||
@@ -212,6 +221,10 @@ public class MinutesController {
|
||||
log.info("회의록 확정 성공 - minutesId: {}", minutesId);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
} catch (BusinessException e) {
|
||||
log.error("회의록 확정 비즈니스 오류 - minutesId: {}, error: {}", minutesId, e.getMessage());
|
||||
return ResponseEntity.status(e.getErrorCode().getHttpStatus())
|
||||
.body(ApiResponse.errorWithType(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("회의록 확정 실패 - minutesId: {}", minutesId, e);
|
||||
return ResponseEntity.badRequest()
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ public class EventHubPublisher implements EventPublisher {
|
||||
|
||||
@Override
|
||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||
publishEvent(event, event.getRecipientId(),
|
||||
publishEvent(event, event.getRecipientEmail(),
|
||||
EventHubConstants.TOPIC_NOTIFICATION,
|
||||
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ public class NoOpEventPublisher implements EventPublisher {
|
||||
|
||||
@Override
|
||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
|
||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientEmail());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.unicorn.hgzero.meeting.infra.gateway;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -24,48 +25,72 @@ import java.util.stream.Collectors;
|
||||
public class MeetingGateway implements MeetingReader, MeetingWriter {
|
||||
|
||||
private final MeetingJpaRepository meetingJpaRepository;
|
||||
private final ParticipantReader participantReader;
|
||||
|
||||
@Override
|
||||
public Optional<Meeting> findById(String meetingId) {
|
||||
return meetingJpaRepository.findById(meetingId)
|
||||
.map(MeetingEntity::toDomain);
|
||||
.map(this::enrichWithParticipants);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByOrganizerId(String organizerId) {
|
||||
return meetingJpaRepository.findByOrganizerId(organizerId).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByStatus(String status) {
|
||||
return meetingJpaRepository.findByStatus(status).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByOrganizerIdAndStatus(String organizerId, String status) {
|
||||
return meetingJpaRepository.findByOrganizerIdAndStatus(organizerId, status).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByScheduledTimeBetween(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByTemplateId(String templateId) {
|
||||
return meetingJpaRepository.findByTemplateId(templateId).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting 엔티티를 도메인으로 변환하면서 participants 정보 추가
|
||||
*/
|
||||
private Meeting enrichWithParticipants(MeetingEntity entity) {
|
||||
Meeting meeting = entity.toDomain();
|
||||
List<String> participants = participantReader.findParticipantsByMeetingId(entity.getMeetingId());
|
||||
return Meeting.builder()
|
||||
.meetingId(meeting.getMeetingId())
|
||||
.title(meeting.getTitle())
|
||||
.purpose(meeting.getPurpose())
|
||||
.description(meeting.getDescription())
|
||||
.scheduledAt(meeting.getScheduledAt())
|
||||
.endTime(meeting.getEndTime())
|
||||
.location(meeting.getLocation())
|
||||
.startedAt(meeting.getStartedAt())
|
||||
.endedAt(meeting.getEndedAt())
|
||||
.status(meeting.getStatus())
|
||||
.organizerId(meeting.getOrganizerId())
|
||||
.participants(participants)
|
||||
.templateId(meeting.getTemplateId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Meeting save(Meeting meeting) {
|
||||
MeetingEntity entity = MeetingEntity.fromDomain(meeting);
|
||||
|
||||
@@ -66,7 +66,24 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
|
||||
|
||||
@Override
|
||||
public Minutes save(Minutes minutes) {
|
||||
MinutesEntity entity = MinutesEntity.fromDomain(minutes);
|
||||
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
|
||||
MinutesEntity entity = minutesJpaRepository.findById(minutes.getMinutesId())
|
||||
.orElse(null);
|
||||
|
||||
if (entity != null) {
|
||||
// 기존 엔티티 업데이트 (연관관계 유지)
|
||||
if (minutes.getStatus() != null && minutes.getStatus().equals("FINALIZED")) {
|
||||
entity.finalize(minutes.getFinalizedBy());
|
||||
}
|
||||
if (minutes.getVersion() != null) {
|
||||
entity.updateVersion();
|
||||
}
|
||||
// sections는 cascade로 자동 업데이트됨
|
||||
} else {
|
||||
// 새 엔티티 생성
|
||||
entity = MinutesEntity.fromDomain(minutes);
|
||||
}
|
||||
|
||||
MinutesEntity savedEntity = minutesJpaRepository.save(entity);
|
||||
return savedEntity.toDomain();
|
||||
}
|
||||
|
||||
+19
-1
@@ -67,7 +67,25 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
|
||||
|
||||
@Override
|
||||
public MinutesSection save(MinutesSection section) {
|
||||
MinutesSectionEntity entity = MinutesSectionEntity.fromDomain(section);
|
||||
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
|
||||
MinutesSectionEntity entity = sectionJpaRepository.findById(section.getSectionId())
|
||||
.orElse(null);
|
||||
|
||||
if (entity != null) {
|
||||
// 기존 엔티티 업데이트 (minutes 연관관계 유지)
|
||||
if (section.getLocked() != null && section.getLocked()) {
|
||||
entity.lock(section.getLockedBy());
|
||||
} else if (section.getLocked() != null && !section.getLocked()) {
|
||||
entity.unlock();
|
||||
}
|
||||
if (section.getVerified() != null && section.getVerified()) {
|
||||
entity.verify();
|
||||
}
|
||||
} else {
|
||||
// 새 엔티티 생성
|
||||
entity = MinutesSectionEntity.fromDomain(section);
|
||||
}
|
||||
|
||||
MinutesSectionEntity savedEntity = sectionJpaRepository.save(entity);
|
||||
return savedEntity.toDomain();
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway;
|
||||
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingParticipantJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 참석자 Gateway 구현체
|
||||
* ParticipantReader, ParticipantWriter 인터페이스 구현
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ParticipantGateway implements ParticipantReader, ParticipantWriter {
|
||||
|
||||
private final MeetingParticipantJpaRepository participantRepository;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> findParticipantsByMeetingId(String meetingId) {
|
||||
return participantRepository.findByMeetingId(meetingId).stream()
|
||||
.map(MeetingParticipantEntity::getUserId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> findMeetingsByParticipant(String userId) {
|
||||
return participantRepository.findByUserId(userId).stream()
|
||||
.map(MeetingParticipantEntity::getMeetingId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public boolean existsParticipant(String meetingId, String userId) {
|
||||
return participantRepository.existsByMeetingIdAndUserId(meetingId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveParticipant(String meetingId, String userId) {
|
||||
if (!participantRepository.existsByMeetingIdAndUserId(meetingId, userId)) {
|
||||
MeetingParticipantEntity participant = MeetingParticipantEntity.builder()
|
||||
.meetingId(meetingId)
|
||||
.userId(userId)
|
||||
.invitationStatus("PENDING")
|
||||
.attended(false)
|
||||
.build();
|
||||
participantRepository.save(participant);
|
||||
log.debug("Participant saved: meetingId={}, userId={}", meetingId, userId);
|
||||
} else {
|
||||
log.debug("Participant already exists: meetingId={}, userId={}", meetingId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveParticipants(String meetingId, List<String> userIds) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<MeetingParticipantEntity> participants = userIds.stream()
|
||||
.filter(userId -> !participantRepository.existsByMeetingIdAndUserId(meetingId, userId))
|
||||
.map(userId -> MeetingParticipantEntity.builder()
|
||||
.meetingId(meetingId)
|
||||
.userId(userId)
|
||||
.invitationStatus("PENDING")
|
||||
.attended(false)
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!participants.isEmpty()) {
|
||||
participantRepository.saveAll(participants);
|
||||
log.debug("Participants saved: meetingId={}, count={}", meetingId, participants.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteParticipant(String meetingId, String userId) {
|
||||
participantRepository.deleteById(new com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId(meetingId, userId));
|
||||
log.debug("Participant deleted: meetingId={}, userId={}", meetingId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteAllParticipants(String meetingId) {
|
||||
participantRepository.deleteByMeetingId(meetingId);
|
||||
log.debug("All participants deleted: meetingId={}", meetingId);
|
||||
}
|
||||
}
|
||||
+13
-20
@@ -9,7 +9,7 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -59,12 +59,16 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
@Column(name = "organizer_id", length = 50, nullable = false)
|
||||
private String organizerId;
|
||||
|
||||
@Column(name = "participants", columnDefinition = "TEXT")
|
||||
private String participants;
|
||||
|
||||
@Column(name = "template_id", length = 50)
|
||||
private String templateId;
|
||||
|
||||
/**
|
||||
* 회의 참석자 목록 (일대다 관계)
|
||||
*/
|
||||
@OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<MeetingParticipantEntity> participants = new ArrayList<>();
|
||||
|
||||
public Meeting toDomain() {
|
||||
return Meeting.builder()
|
||||
.meetingId(this.meetingId)
|
||||
@@ -78,7 +82,11 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
.endedAt(this.endedAt)
|
||||
.status(this.status)
|
||||
.organizerId(this.organizerId)
|
||||
.participants(parseParticipants(this.participants))
|
||||
.participants(this.participants != null
|
||||
? this.participants.stream()
|
||||
.map(MeetingParticipantEntity::getUserId)
|
||||
.collect(Collectors.toList())
|
||||
: List.of())
|
||||
.templateId(this.templateId)
|
||||
.build();
|
||||
}
|
||||
@@ -96,7 +104,6 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
.endedAt(meeting.getEndedAt())
|
||||
.status(meeting.getStatus())
|
||||
.organizerId(meeting.getOrganizerId())
|
||||
.participants(formatParticipants(meeting.getParticipants()))
|
||||
.templateId(meeting.getTemplateId())
|
||||
.build();
|
||||
}
|
||||
@@ -110,18 +117,4 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
this.status = "COMPLETED";
|
||||
this.endedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
private static List<String> parseParticipants(String participants) {
|
||||
if (participants == null || participants.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.asList(participants.split(","));
|
||||
}
|
||||
|
||||
private static String formatParticipants(List<String> participants) {
|
||||
if (participants == null || participants.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return String.join(",", participants);
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway.entity;
|
||||
|
||||
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 회의 참석자 Entity
|
||||
* meeting_id와 user_id를 복합키로 사용하여 다대다 관계 표현
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "meeting_participants")
|
||||
@IdClass(MeetingParticipantId.class)
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MeetingParticipantEntity extends BaseTimeEntity {
|
||||
|
||||
/**
|
||||
* 회의 ID (복합키)
|
||||
*/
|
||||
@Id
|
||||
@Column(name = "meeting_id", length = 50)
|
||||
private String meetingId;
|
||||
|
||||
/**
|
||||
* 사용자 ID (이메일) (복합키)
|
||||
*/
|
||||
@Id
|
||||
@Column(name = "user_id", length = 100)
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 초대 상태 (PENDING, ACCEPTED, DECLINED)
|
||||
*/
|
||||
@Column(name = "invitation_status", length = 20)
|
||||
@Builder.Default
|
||||
private String invitationStatus = "PENDING";
|
||||
|
||||
/**
|
||||
* 참석 여부
|
||||
*/
|
||||
@Column(name = "attended")
|
||||
@Builder.Default
|
||||
private Boolean attended = false;
|
||||
|
||||
/**
|
||||
* 회의 엔티티와의 관계
|
||||
*/
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "meeting_id", insertable = false, updatable = false)
|
||||
private MeetingEntity meeting;
|
||||
|
||||
/**
|
||||
* 초대 수락
|
||||
*/
|
||||
public void accept() {
|
||||
this.invitationStatus = "ACCEPTED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 초대 거절
|
||||
*/
|
||||
public void decline() {
|
||||
this.invitationStatus = "DECLINED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 참석 처리
|
||||
*/
|
||||
public void markAsAttended() {
|
||||
this.attended = true;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 회의 참석자 복합키
|
||||
* meeting_id와 user_id를 복합키로 사용
|
||||
*/
|
||||
@Getter
|
||||
@EqualsAndHashCode
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MeetingParticipantId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 회의 ID
|
||||
*/
|
||||
private String meetingId;
|
||||
|
||||
/**
|
||||
* 사용자 ID (이메일)
|
||||
*/
|
||||
private String userId;
|
||||
}
|
||||
+5
@@ -76,6 +76,11 @@ public class MinutesEntity extends BaseTimeEntity {
|
||||
.minutesId(minutes.getMinutesId())
|
||||
.meetingId(minutes.getMeetingId())
|
||||
.title(minutes.getTitle())
|
||||
.sections(minutes.getSections() != null
|
||||
? minutes.getSections().stream()
|
||||
.map(MinutesSectionEntity::fromDomain)
|
||||
.collect(Collectors.toList())
|
||||
: new ArrayList<>())
|
||||
.status(minutes.getStatus())
|
||||
.version(minutes.getVersion())
|
||||
.createdBy(minutes.getCreatedBy())
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway.repository;
|
||||
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 회의 참석자 JPA Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface MeetingParticipantJpaRepository extends JpaRepository<MeetingParticipantEntity, MeetingParticipantId> {
|
||||
|
||||
/**
|
||||
* 회의 ID로 참석자 목록 조회
|
||||
*/
|
||||
List<MeetingParticipantEntity> findByMeetingId(String meetingId);
|
||||
|
||||
/**
|
||||
* 사용자 ID로 참여 회의 목록 조회
|
||||
*/
|
||||
List<MeetingParticipantEntity> findByUserId(String userId);
|
||||
|
||||
/**
|
||||
* 회의 ID와 초대 상태로 참석자 목록 조회
|
||||
*/
|
||||
List<MeetingParticipantEntity> findByMeetingIdAndInvitationStatus(String meetingId, String invitationStatus);
|
||||
|
||||
/**
|
||||
* 회의 ID로 참석자 전체 삭제
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM MeetingParticipantEntity p WHERE p.meetingId = :meetingId")
|
||||
void deleteByMeetingId(@Param("meetingId") String meetingId);
|
||||
|
||||
/**
|
||||
* 회의 ID와 사용자 ID로 참석자 존재 여부 확인
|
||||
*/
|
||||
boolean existsByMeetingIdAndUserId(String meetingId, String userId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 회의 참석자 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS meeting_participants (
|
||||
meeting_id VARCHAR(50) NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
invitation_status VARCHAR(20) DEFAULT 'PENDING',
|
||||
attended BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (meeting_id, user_id),
|
||||
CONSTRAINT fk_meeting_participants_meeting
|
||||
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 기존 meetings 테이블의 participants 데이터를 meeting_participants 테이블로 마이그레이션
|
||||
INSERT INTO meeting_participants (meeting_id, user_id, invitation_status, attended, created_at, updated_at)
|
||||
SELECT
|
||||
m.meeting_id,
|
||||
TRIM(participant) as user_id,
|
||||
'PENDING' as invitation_status,
|
||||
FALSE as attended,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM meetings m
|
||||
CROSS JOIN LATERAL unnest(string_to_array(m.participants, ',')) AS participant
|
||||
WHERE m.participants IS NOT NULL AND m.participants != '';
|
||||
|
||||
-- meetings 테이블에서 participants 컬럼 삭제
|
||||
ALTER TABLE meetings DROP COLUMN IF EXISTS participants;
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_meeting_participants_user_id ON meeting_participants(user_id);
|
||||
CREATE INDEX idx_meeting_participants_invitation_status ON meeting_participants(invitation_status);
|
||||
CREATE INDEX idx_meeting_participants_meeting_id_status ON meeting_participants(meeting_id, invitation_status);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON TABLE meeting_participants IS '회의 참석자 정보';
|
||||
COMMENT ON COLUMN meeting_participants.meeting_id IS '회의 ID';
|
||||
COMMENT ON COLUMN meeting_participants.user_id IS '사용자 ID (이메일)';
|
||||
COMMENT ON COLUMN meeting_participants.invitation_status IS '초대 상태 (PENDING, ACCEPTED, DECLINED)';
|
||||
COMMENT ON COLUMN meeting_participants.attended IS '참석 여부';
|
||||
Reference in New Issue
Block a user