feat: add 회의시작 API

This commit is contained in:
djeon
2025-10-24 15:44:55 +09:00
parent 48b760d850
commit 4f80189d57
12 changed files with 870 additions and 26 deletions
@@ -0,0 +1,70 @@
package com.unicorn.hgzero.meeting.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회의 세션 도메인 모델
* 회의가 시작되면 생성되고 회의 종료 시 종료됨
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Session {
/**
* 세션 ID (UUID)
*/
private String sessionId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 회의록 ID
*/
private String minutesId;
/**
* 세션 시작자 (사용자 ID)
*/
private String startedBy;
/**
* 세션 시작 시간
*/
private LocalDateTime startedAt;
/**
* 세션 종료 시간
*/
private LocalDateTime endedAt;
/**
* 세션 상태 (ACTIVE, CLOSED)
*/
@Builder.Default
private String status = "ACTIVE";
/**
* 세션 종료
*/
public void close() {
this.status = "CLOSED";
this.endedAt = LocalDateTime.now();
}
/**
* 세션이 활성 상태인지 확인
*/
public boolean isActive() {
return "ACTIVE".equals(this.status);
}
}
@@ -3,10 +3,16 @@ 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.Session;
import com.unicorn.hgzero.meeting.biz.usecase.in.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.MinutesWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -34,6 +40,9 @@ public class MeetingService implements
private final MeetingReader meetingReader;
private final MeetingWriter meetingWriter;
private final SessionReader sessionReader;
private final SessionWriter sessionWriter;
private final MinutesWriter minutesWriter;
private final CacheService cacheService;
private final EventPublisher eventPublisher;
@@ -123,36 +132,126 @@ public class MeetingService implements
*/
@Override
@Transactional
public Meeting startMeeting(String meetingId) {
public Session startMeeting(String meetingId) {
log.info("Starting meeting: {}", meetingId);
// Redis 캐시 조회 기능 필요
// 1. Redis 캐시 조회
Meeting meeting = cacheService.getCachedMeeting(meetingId, Meeting.class);
// 회의 조회
Meeting meeting = meetingReader.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
// 2. 캐시 미스시 DB 조회 및 캐싱
if (meeting == null) {
log.debug("Cache miss for meeting: {}", meetingId);
meeting = meetingReader.findById(meetingId)
.orElseThrow(() -> {
log.error("Meeting not found: {}", meetingId);
return new BusinessException(ErrorCode.ENTITY_NOT_FOUND);
});
// 권한 검증 (생성자 or 참석자)
// 회의 상태 검증
if (!"SCHEDULED".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
// 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, meeting, 600);
} catch (Exception e) {
log.warn("Failed to cache meeting: {}", meetingId, e);
}
} else {
log.debug("Cache hit for meeting: {}", meetingId);
}
// 세션 생성 기능 필요
// 3. 비즈니스 규칙 검증
// TODO: 권한 검증 (생성자 또는 참석자) - userId 파라미터 필요
// 회의 시작
// 4. 회의 상태 확인 (SCHEDULED만 시작 가능)
if (!"SCHEDULED".equals(meeting.getStatus())) {
log.warn("Meeting is not in SCHEDULED status: meetingId={}, status={}",
meetingId, meeting.getStatus());
if ("IN_PROGRESS".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
"이미 진행 중인 회의입니다");
}
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
"회의 상태가 올바르지 않습니다");
}
// 5. 회의 세션 생성
String sessionId = UUID.randomUUID().toString();
Session session = Session.builder()
.sessionId(sessionId)
.meetingId(meetingId)
.startedBy(meeting.getOrganizerId()) // TODO: 실제 사용자 ID 사용
.startedAt(LocalDateTime.now())
.status("ACTIVE")
.build();
// 6. 세션 저장
Session savedSession = sessionWriter.save(session);
log.debug("Session created: sessionId={}, meetingId={}", sessionId, meetingId);
// 7. 회의 상태를 IN_PROGRESS로 업데이트
meeting.start();
// 저장
Meeting updatedMeeting = meetingWriter.save(meeting);
log.debug("Meeting status updated to IN_PROGRESS: {}", meetingId);
// 회의록 초안 생성 필요
// 8. 캐시 무효화
try {
cacheService.evictCache("meeting:", meetingId);
log.debug("Meeting cache evicted: {}", meetingId);
} catch (Exception e) {
log.warn("Failed to evict meeting cache: {}", meetingId, e);
}
// 이벤트 발행 필요
// 9. 회의록 초안 생성 (빈 회의록)
String minutesId = UUID.randomUUID().toString();
Minutes minutesDraft = Minutes.builder()
.minutesId(minutesId)
.meetingId(meetingId)
.title(meeting.getTitle() + " - 회의록")
.sections(List.of())
.status("DRAFT")
.version(1)
.createdBy(meeting.getOrganizerId()) // TODO: 실제 사용자 ID 사용
.createdAt(LocalDateTime.now())
.build();
log.info("Meeting started successfully: {}", meetingId);
return updatedMeeting;
Minutes savedMinutes = minutesWriter.save(minutesDraft);
log.debug("Minutes draft created: minutesId={}, meetingId={}", minutesId, meetingId);
// 세션에 회의록 ID 연결
Session updatedSession = Session.builder()
.sessionId(savedSession.getSessionId())
.meetingId(savedSession.getMeetingId())
.minutesId(minutesId)
.startedBy(savedSession.getStartedBy())
.startedAt(savedSession.getStartedAt())
.status(savedSession.getStatus())
.build();
sessionWriter.save(updatedSession);
// 10. 비동기 이벤트 발행
try {
MeetingStartedEvent event = MeetingStartedEvent.builder()
.meetingId(meetingId)
.sessionId(sessionId)
.title(meeting.getTitle())
.startTime(meeting.getStartedAt())
.organizer(meeting.getOrganizerId())
.participants(meeting.getParticipants())
.minutesId(minutesId)
.eventTime(LocalDateTime.now())
.build();
eventPublisher.publishMeetingStarted(event);
log.debug("MeetingStarted event published: meetingId={}, sessionId={}",
meetingId, sessionId);
} catch (Exception e) {
log.error("Failed to publish MeetingStarted event: meetingId={}", meetingId, e);
// 이벤트 발행 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
log.info("Meeting started successfully: meetingId={}, sessionId={}, minutesId={}",
meetingId, sessionId, minutesId);
return updatedSession;
}
/**
@@ -1,6 +1,6 @@
package com.unicorn.hgzero.meeting.biz.usecase.in.meeting;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Session;
/**
* 회의 시작 UseCase
@@ -9,6 +9,8 @@ public interface StartMeetingUseCase {
/**
* 회의 시작
* @param meetingId 회의 ID
* @return 생성된 세션 정보
*/
Meeting startMeeting(String meetingId);
Session startMeeting(String meetingId);
}
@@ -0,0 +1,32 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.Session;
import java.util.List;
import java.util.Optional;
/**
* Session Reader Port
*/
public interface SessionReader {
/**
* ID로 세션 조회
*/
Optional<Session> findById(String sessionId);
/**
* 회의 ID로 세션 목록 조회
*/
List<Session> findByMeetingId(String meetingId);
/**
* 회의 ID로 활성 세션 조회
*/
Optional<Session> findActiveSesionByMeetingId(String meetingId);
/**
* 시작자 ID로 세션 목록 조회
*/
List<Session> findByStartedBy(String startedBy);
}
@@ -0,0 +1,19 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.Session;
/**
* Session Writer Port
*/
public interface SessionWriter {
/**
* 세션 저장
*/
Session save(Session session);
/**
* 세션 삭제
*/
void delete(String sessionId);
}
@@ -149,12 +149,12 @@ public class MeetingController {
// meeting id 유효성 검증 필요
var sessionData = startMeetingUseCase.startMeeting(meetingId);
var response = SessionResponse.from(sessionData);
log.info("회의 시작 완료 - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response));
var session = startMeetingUseCase.startMeeting(meetingId);
var response = SessionResponse.from(session, "ws://localhost:8080/ws/collaboration");
log.info("회의 시작 완료 - meetingId: {}, sessionId: {}", meetingId, session.getSessionId());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}
/**
@@ -21,6 +21,12 @@ public class SessionResponse {
@Schema(description = "회의 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private final String meetingId;
@Schema(description = "회의록 ID", example = "minutes-001")
private final String minutesId;
@Schema(description = "세션 상태", example = "IN_PROGRESS")
private final String status;
@Schema(description = "WebSocket URL", example = "ws://localhost:8080/ws/collaboration")
private final String websocketUrl;
@@ -40,10 +46,28 @@ public class SessionResponse {
return SessionResponse.builder()
.sessionId("session-" + meeting.getMeetingId())
.meetingId(meeting.getMeetingId())
.minutesId(null) // 실제로는 세션에서 가져와야 함
.status(meeting.getStatus())
.websocketUrl("ws://localhost:8080/ws/collaboration")
.sessionToken("session-token-" + System.currentTimeMillis())
.startedAt(meeting.getStartedAt() != null ? meeting.getStartedAt() : LocalDateTime.now())
.expiresAt(LocalDateTime.now().plusHours(4))
.build();
}
/**
* Session 객체로부터 SessionResponse 생성
*/
public static SessionResponse from(com.unicorn.hgzero.meeting.biz.domain.Session session, String websocketUrl) {
return SessionResponse.builder()
.sessionId(session.getSessionId())
.meetingId(session.getMeetingId())
.minutesId(session.getMinutesId())
.status(session.getStatus())
.websocketUrl(websocketUrl != null ? websocketUrl : "ws://localhost:8080/ws/collaboration")
.sessionToken("session-token-" + System.currentTimeMillis())
.startedAt(session.getStartedAt())
.expiresAt(session.getStartedAt().plusHours(4))
.build();
}
}
@@ -18,6 +18,11 @@ public class MeetingStartedEvent {
*/
private final String meetingId;
/**
* 세션 ID
*/
private final String sessionId;
/**
* 회의 제목
*/
@@ -0,0 +1,64 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.Session;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.SessionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.SessionJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 세션 Gateway 구현체
* SessionReader, SessionWriter 인터페이스 구현
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionGateway implements SessionReader, SessionWriter {
private final SessionJpaRepository sessionJpaRepository;
@Override
public Optional<Session> findById(String sessionId) {
return sessionJpaRepository.findById(sessionId)
.map(SessionEntity::toDomain);
}
@Override
public List<Session> findByMeetingId(String meetingId) {
return sessionJpaRepository.findByMeetingId(meetingId).stream()
.map(SessionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Session> findActiveSesionByMeetingId(String meetingId) {
return sessionJpaRepository.findActiveSessionByMeetingId(meetingId)
.map(SessionEntity::toDomain);
}
@Override
public List<Session> findByStartedBy(String startedBy) {
return sessionJpaRepository.findByStartedBy(startedBy).stream()
.map(SessionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Session save(Session session) {
SessionEntity entity = SessionEntity.fromDomain(session);
SessionEntity savedEntity = sessionJpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public void delete(String sessionId) {
sessionJpaRepository.deleteById(sessionId);
}
}
@@ -0,0 +1,75 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.meeting.biz.domain.Session;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회의 세션 Entity
*/
@Entity
@Table(name = "sessions")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionEntity extends BaseTimeEntity {
@Id
@Column(name = "session_id", length = 50)
private String sessionId;
@Column(name = "meeting_id", length = 50, nullable = false)
private String meetingId;
@Column(name = "minutes_id", length = 50)
private String minutesId;
@Column(name = "started_by", length = 50, nullable = false)
private String startedBy;
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;
@Column(name = "ended_at")
private LocalDateTime endedAt;
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "ACTIVE";
public Session toDomain() {
return Session.builder()
.sessionId(this.sessionId)
.meetingId(this.meetingId)
.minutesId(this.minutesId)
.startedBy(this.startedBy)
.startedAt(this.startedAt)
.endedAt(this.endedAt)
.status(this.status)
.build();
}
public static SessionEntity fromDomain(Session session) {
return SessionEntity.builder()
.sessionId(session.getSessionId())
.meetingId(session.getMeetingId())
.minutesId(session.getMinutesId())
.startedBy(session.getStartedBy())
.startedAt(session.getStartedAt())
.endedAt(session.getEndedAt())
.status(session.getStatus())
.build();
}
public void close() {
this.status = "CLOSED";
this.endedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,31 @@
package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.SessionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 세션 JPA Repository
*/
public interface SessionJpaRepository extends JpaRepository<SessionEntity, String> {
/**
* 회의 ID로 세션 목록 조회
*/
List<SessionEntity> findByMeetingId(String meetingId);
/**
* 회의 ID로 활성 세션 조회
*/
@Query("SELECT s FROM SessionEntity s WHERE s.meetingId = :meetingId AND s.status = 'ACTIVE'")
Optional<SessionEntity> findActiveSessionByMeetingId(@Param("meetingId") String meetingId);
/**
* 시작자 ID로 세션 목록 조회
*/
List<SessionEntity> findByStartedBy(String startedBy);
}