Feat: 회의 중 메모 저장 API 구현

This commit is contained in:
cyjadela 2025-10-29 14:08:27 +09:00
parent 764a620980
commit d0aa6353da
12 changed files with 2462 additions and 5 deletions

View File

@ -650,8 +650,7 @@ code + .copy-button {
<script type="text/javascript">
function configurationCacheProblems() { return (
// begin-report-data
{"diagnostics":[{"locations":[{"path":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java uses or overrides a deprecated API."}],"severity":"ADVICE","problemDetails":[{"text":"Note: /Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java uses or overrides a deprecated API."}],"contextualLabel":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java uses or overrides a deprecated API.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.filename","displayName":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java uses or overrides a deprecated API."}]},{"locations":[{"path":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesSectionEntity.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"Recompile with -Xlint:deprecation for details."}],"severity":"ADVICE","problemDetails":[{"text":"Note: Recompile with -Xlint:deprecation for details."}],"contextualLabel":"Recompile with -Xlint:deprecation for details.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.recompile","displayName":"Recompile with -Xlint:deprecation for details."}]}],"problemsReport":{"totalProblemCount":2,"buildName":"hgzero","requestedTasks":":meeting:bootRun","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
{"diagnostics":[{"locations":[{"path":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/request/InviteParticipantRequest.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"Some input files use or override a deprecated API."}],"severity":"ADVICE","problemDetails":[{"text":"Note: Some input files use or override a deprecated API."}],"contextualLabel":"Some input files use or override a deprecated API.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.plural","displayName":"Some input files use or override a deprecated API."}]},{"locations":[{"path":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/request/InviteParticipantRequest.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"Recompile with -Xlint:deprecation for details."}],"severity":"ADVICE","problemDetails":[{"text":"Note: Recompile with -Xlint:deprecation for details."}],"contextualLabel":"Recompile with -Xlint:deprecation for details.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.recompile","displayName":"Recompile with -Xlint:deprecation for details."}]}],"problemsReport":{"totalProblemCount":2,"buildName":"hgzero","requestedTasks":":meeting:bootRun","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
{"diagnostics":[{"locations":[{"path":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/request/MeetingMemoRequest.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/request/MeetingMemoRequest.java uses or overrides a deprecated API."}],"severity":"ADVICE","problemDetails":[{"text":"Note: /Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/request/MeetingMemoRequest.java uses or overrides a deprecated API."}],"contextualLabel":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/request/MeetingMemoRequest.java uses or overrides a deprecated API.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.filename","displayName":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/request/MeetingMemoRequest.java uses or overrides a deprecated API."}]},{"locations":[{"path":"/Users/adela/home/workspace/recent/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/request/MeetingMemoRequest.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"Recompile with -Xlint:deprecation for details."}],"severity":"ADVICE","problemDetails":[{"text":"Note: Recompile with -Xlint:deprecation for details."}],"contextualLabel":"Recompile with -Xlint:deprecation for details.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.recompile","displayName":"Recompile with -Xlint:deprecation for details."}]}],"problemsReport":{"totalProblemCount":2,"buildName":"hgzero","requestedTasks":":meeting:compileJava","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
// end-report-data
);}
</script>

View File

@ -41,6 +41,8 @@ public enum ErrorCode {
INVALID_MEETING_TIME(HttpStatus.BAD_REQUEST, "M002", "회의 시작 시간이 종료 시간보다 늦습니다."),
MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "M003", "회의를 찾을 수 없습니다."),
INVALID_MEETING_STATUS(HttpStatus.BAD_REQUEST, "M004", "유효하지 않은 회의 상태입니다."),
MEETING_NOT_IN_PROGRESS(HttpStatus.BAD_REQUEST, "M005", "회의가 진행 중이 아닙니다."),
MINUTES_NOT_FOUND(HttpStatus.NOT_FOUND, "M006", "회의록을 찾을 수 없습니다."),
// 외부 시스템 에러 (4xxx)
EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
package com.unicorn.hgzero.meeting.biz.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의 메모 저장 요청")
public class MeetingMemoRequest {
@NotBlank(message = "회의 ID는 필수입니다.")
@Schema(description = "회의 ID", example = "meeting-123", required = true)
private String meetingId;
@NotBlank(message = "메모 내용은 필수입니다.")
@Schema(description = "메모 내용", example = "[00:15] 신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.", required = true)
private String memo;
}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의 메모 저장 응답")
public class MeetingMemoResponse {
@Schema(description = "회의 ID", example = "meeting-123")
private String meetingId;
@Schema(description = "저장된 섹션 ID", example = "section-456")
private String sectionId;
@Schema(description = "저장 완료 메시지", example = "메모가 성공적으로 저장되었습니다.")
private String message;
public static MeetingMemoResponse of(String meetingId, String sectionId) {
return MeetingMemoResponse.builder()
.meetingId(meetingId)
.sectionId(sectionId)
.message("메모가 성공적으로 저장되었습니다.")
.build();
}
}

View File

@ -0,0 +1,144 @@
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.usecase.in.meeting.SaveMeetingMemoUseCase;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
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.MinutesReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* 회의 메모 관리 Service
* 회의 작성되는 메모를 MinutesSection에 저장하고 관리합니다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MeetingMemoService implements SaveMeetingMemoUseCase {
private final MeetingReader meetingReader;
private final MinutesReader minutesReader;
private final MinutesSectionReader minutesSectionReader;
private final MinutesSectionWriter minutesSectionWriter;
/**
* 회의 AI 메모 저장
* @param command 메모 저장 명령
* @return 저장된 섹션 ID
*/
@Override
@Transactional
public String saveMemo(SaveMemoCommand command) {
log.info("Saving meeting memo for meetingId: {}", command.getMeetingId());
// 1. 회의 존재 여부 진행 상태 확인
Meeting meeting = meetingReader.findById(command.getMeetingId())
.orElseThrow(() -> {
log.error("Meeting not found: {}", command.getMeetingId());
return new BusinessException(ErrorCode.MEETING_NOT_FOUND);
});
// 2. 회의가 진행 중인지 확인
if (!meeting.isInProgress()) {
log.warn("Meeting is not in progress: meetingId={}", command.getMeetingId());
throw new BusinessException(ErrorCode.MEETING_NOT_IN_PROGRESS);
}
// 3. 해당 회의의 회의록 조회
List<Minutes> minutesList = minutesReader.findByMeetingId(command.getMeetingId());
if (minutesList.isEmpty()) {
log.error("Minutes not found for meetingId: {}", command.getMeetingId());
throw new BusinessException(ErrorCode.MINUTES_NOT_FOUND);
}
Minutes minutes = minutesList.get(0); // 번째 회의록 사용
// 4. 기존 메모 섹션 조회 또는 새로운 섹션 생성
MinutesSection memoSection = minutesSectionReader.findFirstByMinutesIdAndType(
minutes.getMinutesId(),
"AI_MEMO"
).orElseGet(() -> createNewMemoSection(minutes.getMinutesId()));
// 5. 메모 내용 업데이트 (기존 내용에 추가)
String updatedContent = updateMemoContent(
memoSection.getContent(),
command.getMemo(),
command.getUserId()
);
// 6. 섹션 업데이트 저장
MinutesSection updatedSection = MinutesSection.builder()
.sectionId(memoSection.getSectionId())
.minutesId(memoSection.getMinutesId())
.type("AI_MEMO")
.title("회의 메모")
.content(updatedContent)
.order(memoSection.getOrder())
.verified(false)
.locked(false)
.build();
minutesSectionWriter.save(updatedSection);
log.info("Meeting memo saved successfully: sectionId={}", updatedSection.getSectionId());
return updatedSection.getSectionId();
}
/**
* 새로운 메모 섹션 생성
*/
private MinutesSection createNewMemoSection(String minutesId) {
// 해당 회의록의 최대 order 조회
int maxOrder = minutesSectionReader.getMaxOrderByMinutesId(minutesId);
return MinutesSection.builder()
.sectionId(generateSectionId())
.minutesId(minutesId)
.type("AI_MEMO")
.title("회의 메모")
.content("")
.order(maxOrder + 1)
.verified(false)
.locked(false)
.build();
}
/**
* 메모 내용 업데이트
* 기존 내용이 있으면 추가하고, 없으면 새로 작성
*/
private String updateMemoContent(String existingContent, String newMemo, String userId) {
StringBuilder content = new StringBuilder();
if (existingContent != null && !existingContent.trim().isEmpty()) {
content.append(existingContent);
content.append("\n\n");
}
// 타임스탬프와 함께 새로운 메모 추가
content.append(String.format("[%s - %s]\n%s",
LocalDateTime.now().toString().substring(11, 19), // HH:mm:ss
userId,
newMemo));
return content.toString();
}
/**
* 섹션 ID 생성
*/
private String generateSectionId() {
return "section-" + UUID.randomUUID().toString();
}
}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.usecase.in.meeting;
import lombok.Builder;
import lombok.Getter;
/**
* 회의 AI 메모 저장 UseCase
* 회의 진행 사용자가 작성한 메모를 MinutesSection의 content에 저장합니다.
*/
public interface SaveMeetingMemoUseCase {
/**
* 회의 메모 저장
* @param command 메모 저장 명령
* @return 저장된 메모의 섹션 ID
*/
String saveMemo(SaveMemoCommand command);
/**
* 메모 저장 명령
*/
@Getter
@Builder
class SaveMemoCommand {
private final String meetingId;
private final String memo;
private final String userId;
}
}

View File

@ -39,4 +39,14 @@ public interface MinutesSectionReader {
* 잠금한 사용자 ID로 섹션 목록 조회
*/
List<MinutesSection> findByLockedBy(String lockedBy);
/**
* 회의록 ID와 타입으로 단일 섹션 조회
*/
Optional<MinutesSection> findFirstByMinutesIdAndType(String minutesId, String type);
/**
* 회의록 ID로 최대 order 조회
*/
int getMaxOrderByMinutesId(String minutesId);
}

View File

@ -13,6 +13,8 @@ import com.unicorn.hgzero.meeting.infra.dto.response.InviteParticipantResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingEndResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse;
import com.unicorn.hgzero.meeting.biz.dto.request.MeetingMemoRequest;
import com.unicorn.hgzero.meeting.biz.dto.response.MeetingMemoResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@ -44,6 +46,7 @@ public class MeetingController {
private final CancelMeetingUseCase cancelMeetingUseCase;
private final InviteParticipantUseCase inviteParticipantUseCase;
private final ApplyTemplateUseCase applyTemplateUseCase;
private final SaveMeetingMemoUseCase saveMeetingMemoUseCase;
/**
* 회의 예약
@ -319,4 +322,48 @@ public class MeetingController {
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 회의 AI 메모 저장
*
* @param meetingId 회의 ID
* @param userId 사용자 ID
* @param userName 사용자명
* @param userEmail 사용자 이메일
* @param request 메모 저장 요청
* @return 메모 저장 결과
*/
@Operation(
summary = "회의 중 AI 메모 저장",
description = "회의 진행 중 사용자가 작성한 메모를 MinutesSection에 저장합니다. AI가 감지한 주요 내용과 사용자가 직접 입력한 내용을 함께 저장할 수 있습니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@PostMapping("/{meetingId}/memo")
public ResponseEntity<ApiResponse<MeetingMemoResponse>> saveMeetingMemo(
@Parameter(description = "회의 ID", required = true)
@PathVariable String meetingId,
@Parameter(description = "사용자 ID", required = true)
@RequestHeader("X-User-Id") String userId,
@Parameter(description = "사용자명", required = true)
@RequestHeader("X-User-Name") String userName,
@Parameter(description = "사용자 이메일", required = true)
@RequestHeader("X-User-Email") String userEmail,
@Valid @RequestBody MeetingMemoRequest request) {
log.info("회의 메모 저장 요청 - meetingId: {}, userId: {}", meetingId, userId);
String sectionId = saveMeetingMemoUseCase.saveMemo(
SaveMeetingMemoUseCase.SaveMemoCommand.builder()
.meetingId(meetingId)
.memo(request.getMemo())
.userId(userId)
.build()
);
var response = MeetingMemoResponse.of(meetingId, sectionId);
log.info("회의 메모 저장 완료 - meetingId: {}, sectionId: {}", meetingId, sectionId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -4,7 +4,9 @@ import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@ -23,6 +25,7 @@ import java.util.stream.Collectors;
public class MinutesSectionGateway implements MinutesSectionReader, MinutesSectionWriter {
private final MinutesSectionJpaRepository sectionJpaRepository;
private final MinutesJpaRepository minutesJpaRepository;
@Override
public Optional<MinutesSection> findById(String sectionId) {
@ -37,7 +40,6 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
.collect(Collectors.toList());
}
@Override
public List<MinutesSection> findByMinutesIdAndType(String minutesId, String type) {
return sectionJpaRepository.findByMinutesIdAndType(minutesId, type).stream()
.map(MinutesSectionEntity::toDomain)
@ -73,6 +75,8 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
if (entity != null) {
// 기존 엔티티 업데이트 (minutes 연관관계 유지)
entity.updateContent(section.getContent());
if (section.getLocked() != null && section.getLocked()) {
entity.lock(section.getLockedBy());
} else if (section.getLocked() != null && !section.getLocked()) {
@ -82,14 +86,41 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
entity.verify();
}
} else {
// 엔티티 생성
// 엔티티 생성 - minutes 연관관계 설정 필요
entity = MinutesSectionEntity.fromDomain(section);
// Minutes 엔티티 조회하여 연관관계 설정
MinutesEntity minutesEntity = minutesJpaRepository.findById(section.getMinutesId())
.orElseThrow(() -> new IllegalArgumentException("Minutes not found: " + section.getMinutesId()));
entity = MinutesSectionEntity.builder()
.sectionId(section.getSectionId())
.id(section.getSectionId()) // id와 sectionId를 동일하게 설정
.minutes(minutesEntity) // 연관관계 설정
.type(section.getType())
.title(section.getTitle())
.content(section.getContent())
.order(section.getOrder())
.verified(section.getVerified())
.locked(section.getLocked())
.lockedBy(section.getLockedBy())
.build();
}
MinutesSectionEntity savedEntity = sectionJpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public Optional<MinutesSection> findFirstByMinutesIdAndType(String minutesId, String type) {
return sectionJpaRepository.findFirstByMinutesIdAndType(minutesId, type)
.map(MinutesSectionEntity::toDomain);
}
@Override
public int getMaxOrderByMinutesId(String minutesId) {
return sectionJpaRepository.getMaxOrderByMinutesId(minutesId);
}
@Override
public void delete(String sectionId) {
sectionJpaRepository.deleteById(sectionId);

View File

@ -20,8 +20,11 @@ import lombok.NoArgsConstructor;
public class MinutesSectionEntity extends BaseTimeEntity {
@Id
@Column(name = "id", length = 50)
@Column(name = "section_id", length = 50)
private String sectionId;
@Column(name = "id", length = 50)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "minutes_id", nullable = false)
@ -71,6 +74,7 @@ public class MinutesSectionEntity extends BaseTimeEntity {
public static MinutesSectionEntity fromDomain(MinutesSection section) {
return MinutesSectionEntity.builder()
.sectionId(section.getSectionId())
.id(section.getSectionId()) // id와 sectionId를 동일하게 설정
.minutesId(section.getMinutesId())
.type(section.getType())
.title(section.getTitle())
@ -95,4 +99,8 @@ public class MinutesSectionEntity extends BaseTimeEntity {
public void verify() {
this.verified = true;
}
public void updateContent(String content) {
this.content = content;
}
}

View File

@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 회의록 섹션 JPA Repository
@ -39,4 +40,15 @@ public interface MinutesSectionJpaRepository extends JpaRepository<MinutesSectio
* 잠금한 사용자 ID로 섹션 목록 조회
*/
List<MinutesSectionEntity> findByLockedBy(String lockedBy);
/**
* 회의록 ID와 타입으로 단일 섹션 조회
*/
Optional<MinutesSectionEntity> findFirstByMinutesIdAndType(String minutesId, String type);
/**
* 회의록 ID로 최대 order 조회
*/
@Query("SELECT COALESCE(MAX(m.order), 0) FROM MinutesSectionEntity m WHERE m.minutesId = :minutesId")
int getMaxOrderByMinutesId(@Param("minutesId") String minutesId);
}