Merge pull request #6 from hwanny1128/feat/meeting-confirmed

Feat/meeting confirmed
This commit is contained in:
Daewoong Jeon 2025-10-25 13:00:07 +09:00 committed by GitHub
commit e667c7f7ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1942 additions and 10026 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,11 +1,15 @@
package com.unicorn.hgzero.meeting.biz.domain; 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.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
@ -77,6 +81,101 @@ public class Minutes {
*/ */
private LocalDateTime finalizedAt; 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.title = title;
this.version++; this.version++;
} }
/**
* 모든 섹션 잠금
*/
public void lockAllSections(String userId) {
if (this.sections != null) {
for (MinutesSection section : this.sections) {
if (!section.isLocked()) {
section.lock(userId);
}
}
}
}
} }

View File

@ -2,11 +2,17 @@ package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.common.exception.BusinessException; import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode; 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.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO; import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.minutes.*; 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.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.biz.usecase.out.MinutesWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -34,6 +40,10 @@ public class MinutesService implements
private final MinutesReader minutesReader; private final MinutesReader minutesReader;
private final MinutesWriter minutesWriter; 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 @Override
@Transactional @Transactional
public Minutes finalizeMinutes(String minutesId, String userId) { 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) Minutes minutes = minutesReader.findById(minutesId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다."));
// 상태 검증 // 2. 회의 정보 조회
if ("FINALIZED".equals(minutes.getStatus())) { Meeting meeting = meetingReader.findById(minutes.getMeetingId())
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); .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); minutes.finalize(userId);
// 저장 // 7. 회의록 저장
Minutes finalizedMinutes = minutesWriter.save(minutes); 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; return finalizedMinutes;
} }

View File

@ -1,6 +1,7 @@
package com.unicorn.hgzero.meeting.infra.controller; package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse; 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.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.service.MinutesService; import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService; import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
@ -202,8 +203,16 @@ public class MinutesController {
// 응답 DTO 생성 // 응답 DTO 생성
MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes); MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes);
// 캐시 무효화 // 캐시 저장 (TTL: 10분)
cacheService.evictCacheMinutesDetail(minutesId); try {
cacheService.cacheMinutesDetail(minutesId, response);
log.debug("캐시에 확정된 회의록 저장 완료 - minutesId: {}", minutesId);
} catch (Exception cacheEx) {
log.warn("회의록 캐시 저장 실패 - minutesId: {}", minutesId, cacheEx);
// 캐시 저장 실패는 무시하고 진행
}
// 캐시 무효화 (목록 캐시)
cacheService.evictCacheMinutesList(userId); cacheService.evictCacheMinutesList(userId);
// 회의록 확정 이벤트 발행 // 회의록 확정 이벤트 발행
@ -212,6 +221,10 @@ public class MinutesController {
log.info("회의록 확정 성공 - minutesId: {}", minutesId); log.info("회의록 확정 성공 - minutesId: {}", minutesId);
return ResponseEntity.ok(ApiResponse.success(response)); 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) { } catch (Exception e) {
log.error("회의록 확정 실패 - minutesId: {}", minutesId, e); log.error("회의록 확정 실패 - minutesId: {}", minutesId, e);
return ResponseEntity.badRequest() return ResponseEntity.badRequest()

View File

@ -66,7 +66,24 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
@Override @Override
public Minutes save(Minutes minutes) { 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); MinutesEntity savedEntity = minutesJpaRepository.save(entity);
return savedEntity.toDomain(); return savedEntity.toDomain();
} }

View File

@ -67,7 +67,25 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
@Override @Override
public MinutesSection save(MinutesSection section) { 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); MinutesSectionEntity savedEntity = sectionJpaRepository.save(entity);
return savedEntity.toDomain(); return savedEntity.toDomain();
} }

View File

@ -76,6 +76,11 @@ public class MinutesEntity extends BaseTimeEntity {
.minutesId(minutes.getMinutesId()) .minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId()) .meetingId(minutes.getMeetingId())
.title(minutes.getTitle()) .title(minutes.getTitle())
.sections(minutes.getSections() != null
? minutes.getSections().stream()
.map(MinutesSectionEntity::fromDomain)
.collect(Collectors.toList())
: new ArrayList<>())
.status(minutes.getStatus()) .status(minutes.getStatus())
.version(minutes.getVersion()) .version(minutes.getVersion())
.createdBy(minutes.getCreatedBy()) .createdBy(minutes.getCreatedBy())