mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-13 07:09:09 +00:00
Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
+6
-6
@@ -156,12 +156,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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+24
@@ -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();
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user