mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 11:26:25 +00:00
Merge pull request #6 from hwanny1128/feat/meeting-confirmed
Feat/meeting confirmed
This commit is contained in:
commit
e667c7f7ba
File diff suppressed because it is too large
Load Diff
BIN
meeting/logs/meeting-service.log.2025-10-24.0.gz
Normal file
BIN
meeting/logs/meeting-service.log.2025-10-24.0.gz
Normal file
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -202,8 +203,16 @@ public class MinutesController {
|
||||
// 응답 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()
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user