Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting

This commit is contained in:
cyjadela
2025-10-27 11:31:18 +09:00
75 changed files with 30507 additions and 10309 deletions
+3
View File
@@ -18,6 +18,9 @@ task printEnv {
}
dependencies {
// Module dependencies
implementation project(':notification')
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
}
@@ -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);
}
@@ -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,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;
}
}
@@ -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()
@@ -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);
}
@@ -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();
}
@@ -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();
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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())
@@ -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 '참석 여부';