Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
0d13c3f
fix/#286: 폰트 미반영 convert μˆ˜μ •
djlim2425 Aug 19, 2025
55edb61
feat/#286: Meeting Entity wordFile KeyName μΆ”κ°€
djlim2425 Aug 19, 2025
21ffa4b
feat/#286: Proceeding λ‹€μš΄λ‘œλ“œμ‹œ 포맷 선택 μΆ”κ°€
djlim2425 Aug 19, 2025
10f2b12
feat/#286: MarkdownToWordConverter μΆ”κ°€
djlim2425 Aug 19, 2025
3264dd0
feat/#286: createOrUpdateWord μΆ”κ°€
djlim2425 Aug 19, 2025
537e2fd
feat/#286: AI회의 μ’…λ£Œ ν›„ word Fileμƒμ„±λ‘œμ§ μΆ”κ°€
djlim2425 Aug 19, 2025
07e9901
feat/#286: AI회의둝 format에 맞게 λ‹€μš΄λ‘œλ“œ url λ°˜ν™˜
djlim2425 Aug 19, 2025
d0dc2e1
feat/#286: AI회의둝 잘λͺ»λœ format μ˜ˆμ™Έμ²˜λ¦¬
djlim2425 Aug 19, 2025
a30a839
fix/#286: Pdf convertμ—μ„œ 폰트 경둜 μˆ˜μ •
djlim2425 Aug 19, 2025
d0fd453
fix/#286: 폰트 반영 였λ₯˜ ν•΄κ²°
djlim2425 Aug 19, 2025
ed8c47b
fix/#286: Meeting μ‚­μ œμ‹œ μ˜μ†μ„±μ»¨ν…μŠ€νŠΈ ν™œμš©ν•˜λ„λ‘ μˆ˜μ •
djlim2425 Aug 19, 2025
0e6a378
Merge branch 'dev' into feat/#286-ai-meeting-download-doctype
djlim2425 Aug 19, 2025
76eb065
fix/#286: updateMeeting λ©”μ„œλ“œλͺ… 였λ₯˜ μˆ˜μ •
djlim2425 Aug 19, 2025
f803594
Merge pull request #308 from HaRu-Developers/feat/#286-ai-meeting-dow…
djlim2425 Aug 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.haru.api.domain.meeting.entity.Meeting;
import com.haru.api.domain.meeting.service.MeetingCommandService;
import com.haru.api.domain.meeting.service.MeetingQueryService;
import com.haru.api.domain.snsEvent.entity.enums.Format;
import com.haru.api.domain.snsEvent.entity.enums.ListType;
import com.haru.api.domain.user.entity.User;
import com.haru.api.domain.workspace.entity.Workspace;
import com.haru.api.global.annotation.AuthMeeting;
Expand Down Expand Up @@ -170,11 +172,12 @@ public ApiResponse<String> endMeeting(
@GetMapping("{meetingId}/ai-proceeding/download")
public ApiResponse<MeetingResponseDTO.proceedingDownLoadLinkResponse> downloadMeeting(
@PathVariable("meetingId") String meetingId,
@RequestParam Format format,
@Parameter(hidden = true) @AuthUser User user,
@Parameter(hidden = true) @AuthMeeting Meeting meeting
){

MeetingResponseDTO.proceedingDownLoadLinkResponse response = meetingQueryService.downloadMeeting(user, meeting);
MeetingResponseDTO.proceedingDownLoadLinkResponse response = meetingQueryService.downloadMeeting(user, meeting, format);

return ApiResponse.onSuccess(response);

Expand Down
8 changes: 6 additions & 2 deletions src/main/java/com/haru/api/domain/meeting/entity/Meeting.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ public class Meeting extends BaseEntity implements Documentable {

// AI 회의둝 정리본 파일
@Column(columnDefinition = "TEXT")
private String proceedingKeyName;
private String proceedingPdfKeyName;

@Column(columnDefinition = "TEXT")
private String proceedingWordKeyName;

@Column(columnDefinition = "TEXT")
private String thumbnailKeyName;
Expand Down Expand Up @@ -77,7 +80,8 @@ public void updateTitle(String title) {
public void updateProceeding(String proceeding) {
this.proceeding = proceeding;
}
public void initProceedingKeyName(String proceedingKeyName) { this.proceedingKeyName = proceedingKeyName; }
public void initProceedingPdfKeyName(String proceedingPdfKeyName) { this.proceedingPdfKeyName = proceedingPdfKeyName; }
public void initProceedingWordKeyName(String proceedingWordKeyName) { this.proceedingWordKeyName = proceedingWordKeyName; }
public void initThumbnailKeyName(String thumbnailKeyName) { this.thumbnailKeyName = thumbnailKeyName; }
public void initStartTime(LocalDateTime startTime) {
this.startTime = startTime;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.upd

meeting.updateTitle(request.getTitle());

markdownFileUploader.updateFileTitle(meeting.getProceedingKeyName(), request.getTitle());
markdownFileUploader.updateFileTitle(meeting.getProceedingPdfKeyName(), request.getTitle());

meetingRepository.save(meeting);
}
Expand All @@ -148,18 +148,22 @@ public void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.upd
@Transactional
@DeleteDocument
public void deleteMeeting(User user, Meeting meeting) {
Meeting foundMeeting = meetingRepository.findById(meeting.getId())
.orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND));

UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(user.getId(), meeting.getWorkspace().getId())
UserWorkspace foundUserWorkspace = userWorkspaceRepository.findByUserIdAndWorkspaceId(user.getId(), foundMeeting.getWorkspace().getId())
.orElseThrow(() -> new UserWorkspaceHandler(ErrorStatus.USER_WORKSPACE_NOT_FOUND));

if (!meeting.getCreator().getId().equals(user.getId()) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) {
if (!foundMeeting.getCreator().getId().equals(user.getId()) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) {
throw new MemberHandler(ErrorStatus.MEMBER_NO_AUTHORITY);
}

markdownFileUploader.deleteFileAndThumbnail(meeting.getProceedingKeyName(), meeting.getThumbnailKeyName());
markdownFileUploader.deleteS3File(meeting.getAudioFileKey());
markdownFileUploader.deleteS3File(foundMeeting.getProceedingPdfKeyName());
markdownFileUploader.deleteS3File(foundMeeting.getThumbnailKeyName());
markdownFileUploader.deleteS3File(foundMeeting.getProceedingWordKeyName());
markdownFileUploader.deleteS3File(foundMeeting.getAudioFileKey());

meetingRepository.delete(meeting);
meetingRepository.delete(foundMeeting);
}

@Override
Expand Down Expand Up @@ -237,11 +241,13 @@ public void processAfterMeeting(AudioSessionBuffer sessionBuffer) {
// --- PDF 및 썸넀일 생성/μ—…λ°μ΄νŠΈ 둜직 μ‹œμž‘ ---
try {
// μƒμ„±λœ PDFλ₯Ό S3에 μ—…λ‘œλ“œ
String pdfKey = markdownFileUploader.createOrUpdatePdf(analysisResult, "proceedings/", currentMeeting.getProceedingKeyName(), currentMeeting.getTitle());
currentMeeting.initProceedingKeyName(pdfKey);
String pdfKey = markdownFileUploader.createOrUpdatePdf(analysisResult, "meeting/pdf", currentMeeting.getProceedingPdfKeyName(), currentMeeting.getTitle());
String wordKey = markdownFileUploader.createOrUpdateWord(analysisResult, "meeting/word", currentMeeting.getProceedingWordKeyName(), currentMeeting.getTitle());
currentMeeting.initProceedingPdfKeyName(pdfKey);
currentMeeting.initProceedingWordKeyName(wordKey);

// 썸넀일 생성 및 μ—…λ°μ΄νŠΈ
String newThumbnailKey = markdownFileUploader.createOrUpdateThumbnail(pdfKey, "meetings/" + currentMeeting.getId(), currentMeeting.getThumbnailKeyName());
String newThumbnailKey = markdownFileUploader.createOrUpdateThumbnail(pdfKey, "meeting" + currentMeeting.getId(), currentMeeting.getThumbnailKeyName());
currentMeeting.initThumbnailKeyName(newThumbnailKey); // Meeting 엔티티에 썸넀일 ν‚€ μ €μž₯
log.info("회의둝 썸넀일 생성/μ—…λ°μ΄νŠΈ μ™„λ£Œ. Key: {}", newThumbnailKey);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.haru.api.domain.meeting.dto.MeetingResponseDTO;
import com.haru.api.domain.meeting.entity.Meeting;
import com.haru.api.domain.snsEvent.entity.enums.Format;
import com.haru.api.domain.user.entity.User;
import com.haru.api.domain.workspace.entity.Workspace;

Expand All @@ -15,7 +16,7 @@ public interface MeetingQueryService {

MeetingResponseDTO.TranscriptResponse getTranscript(User user, Meeting meeting);

MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting);
MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting, Format format);

MeetingResponseDTO.proceedingVoiceLinkResponse getMeetingVoiceFile(User user, Meeting meeting);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.haru.api.domain.meeting.dto.MeetingResponseDTO;
import com.haru.api.domain.meeting.entity.Meeting;
import com.haru.api.domain.meeting.repository.MeetingRepository;
import com.haru.api.domain.snsEvent.entity.enums.Format;
import com.haru.api.domain.user.entity.User;
import com.haru.api.domain.workspace.entity.Workspace;
import com.haru.api.global.annotation.TrackLastOpened;
Expand Down Expand Up @@ -64,9 +65,18 @@ public MeetingResponseDTO.TranscriptResponse getTranscript(User user, Meeting me
}

@Override
public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting){

String proceedingKeyName = meeting.getProceedingKeyName();
public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting, Format format){
String proceedingKeyName;
switch (format) {
case PDF:
proceedingKeyName = meeting.getProceedingPdfKeyName();
break;
case DOCX:
proceedingKeyName = meeting.getProceedingWordKeyName();
break;
default:
throw new MeetingHandler(ErrorStatus.MEETING_INVALID_FILE_FORMAT);
}

if (proceedingKeyName == null || proceedingKeyName.isBlank()) {
throw new MeetingHandler(ErrorStatus.MEETING_PROCEEDING_NOT_FOUND);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public enum ErrorStatus implements BaseErrorCode {
MEETING_AUDIO_FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "MEETING4003", "μŒμ„± νŒŒμΌμ„ s3에 μ—…λ‘œλ“œν•˜λŠ”λ° 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."),
MEETING_FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "MEETING4004", "μŒμ„± νŒŒμΌμ„ s3에 μ—…λ‘œλ“œν•˜λŠ”λ° 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."),
MEETING_PROCEEDING_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEETING4005", "AI회의둝이 μ—†μŠ΅λ‹ˆλ‹€"),
MEETING_INVALID_FILE_FORMAT(HttpStatus.BAD_REQUEST, "MEETING4006", "잘λͺ»λœ λ‹€μš΄λ‘œλ“œ 파일 ν˜•μ‹μž…λ‹ˆλ‹€."),

// 인가 κ΄€λ ¨ μ—λŸ¬
AUTHORIZATION_EXCEPTION(HttpStatus.UNAUTHORIZED, "AUTHORIZATION4001", "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€."),
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
public class MarkdownFileUploader {

private final MarkdownToPdfConverter markdownToPdfConverter;
private final MarkdownToWordConverter markdownToWordConverter;
private final ThumbnailGeneratorService thumbnailGeneratorService;
private final AmazonS3Manager amazonS3Manager;

Expand Down Expand Up @@ -48,6 +49,29 @@ public String createOrUpdatePdf(String markdownText, String featurePath, String
return pdfKeyToUse;
}

public String createOrUpdateWord(String markdownText, String featurePath, String existingWordKey, String fileTitle) {
// 1. Markdown을 Word λ°μ΄ν„°λ‘œ λ³€ν™˜
byte[] wordBytes = markdownToWordConverter.convert(markdownText);

// 2. μ‚¬μš©ν•  Word ν‚€ κ²°μ •
String wordKeyToUse;
if (existingWordKey != null && !existingWordKey.isBlank()) {
wordKeyToUse = existingWordKey;
log.info("κΈ°μ‘΄ Word 파일 갱신을 μ‹œμž‘ν•©λ‹ˆλ‹€. Key: {}", wordKeyToUse);
} else {
wordKeyToUse = amazonS3Manager.generateKeyName(featurePath) + ".docx";
log.info("μƒˆλ‘œμš΄ Word 파일 생성을 μ‹œμž‘ν•©λ‹ˆλ‹€. New Key: {}", wordKeyToUse);
}

// 3. κ²°μ •λœ ν‚€λ‘œ Word νŒŒμΌμ„ S3에 μ—…λ‘œλ“œ
String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
amazonS3Manager.uploadFileWithTitle(wordKeyToUse, wordBytes, contentType, fileTitle);
log.info("Word 파일 μ—…λ‘œλ“œ/κ°±μ‹  성곡. Key: {}", wordKeyToUse);

// 4. μ‚¬μš©λœ Word의 keyλ₯Ό λ°˜ν™˜
return wordKeyToUse;
}

/**
* μΈλ„€μΌλ§Œ 생성 및 μ—…λ‘œλ“œ
* κΈ°μ‘΄ PDF 파일의 keyλ₯Ό λ°›μ•„ 썸넀일을 μƒμ„±ν•˜κ³  S3에 μ—…λ‘œλ“œ
Expand Down
50 changes: 43 additions & 7 deletions src/main/java/com/haru/api/infra/s3/MarkdownToPdfConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,29 @@
import org.commonmark.renderer.html.HtmlRenderer;
import org.springframework.stereotype.Component;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.*;

@Slf4j
@Component
public class MarkdownToPdfConverter {

// Markdown λ¬Έμžμ—΄ -> HTML , HTML -> PDF byte[] λ³€ν™˜
private static final byte[] FONT_BYTES;

// ν΄λž˜μŠ€κ°€ λ‘œλ“œλ  λ•Œ 폰트 νŒŒμΌμ„ λ”± ν•œ 번만 μ½μ–΄μ„œ byte 배열에 μ €μž₯ν•©λ‹ˆλ‹€.
static {
String fontPath = "templates/NotoSansKR-Regular.ttf";
try (InputStream in = MarkdownToPdfConverter.class.getClassLoader().getResourceAsStream(fontPath)) {
if (in == null) {
throw new IOException("폰트 νŒŒμΌμ„ ν΄λž˜μŠ€νŒ¨μŠ€μ—μ„œ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: " + fontPath);
}
FONT_BYTES = in.readAllBytes();
log.info("폰트 파일 λ‘œλ“œ 성곡: {}, Size: {} bytes", fontPath, FONT_BYTES.length);
} catch (Exception e) {
log.error("μ΄ˆκΈ°ν™” 쀑 폰트 νŒŒμΌμ„ λ‘œλ“œν•˜λŠ” 데 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", e);
throw new RuntimeException("폰트 파일 λ‘œλ”© μ‹€νŒ¨", e);
}
}

public byte[] convert(String markdownText) {
try {
// 1. Markdown -> HTML
Expand All @@ -27,9 +42,31 @@ public byte[] convert(String markdownText) {
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
// 폰트
builder.useFont(new File(getClass().getClassLoader().getResource("/templates/NotoSansKR-Regular.ttf").getFile()), "NanumGothic");
builder.withHtmlContent(htmlContent, null);

builder.useFont(() -> new ByteArrayInputStream(FONT_BYTES), "NotoSansKR");

// [μˆ˜μ •] meta νƒœκ·Έλ₯Ό /> 둜 λ‹«μ•„μ£Όμ–΄ XML νŒŒμ‹± 였λ₯˜ ν•΄κ²°
String styledHtml = "<html>"
+ "<head>"
+ "<meta charset=\"UTF-8\" />"
+ "<style>"
+ "body { font-family: 'NotoSansKR'; font-size: 12px; }"
+ "h1, h2, h3, h4, h5, h6 { font-weight: bold; margin-top: 1.2em; margin-bottom: 0.6em; }"
+ "h1 { font-size: 2em; }"
+ "h2 { font-size: 1.5em; }"
+ "p { margin-bottom: 1em; line-height: 1.6; }"
+ "strong { font-weight: bold; }"
+ "ul, ol { padding-left: 25px; margin-bottom: 1em; }"
+ "li { margin-bottom: 0.5em; }"
+ "hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; }"
+ "</style>"
+ "</head>"
+ "<body>"
+ htmlContent
+ "</body>"
+ "</html>";

builder.withHtmlContent(styledHtml, null);
builder.toStream(os);
builder.run();
log.info("Markdown to PDF λ³€ν™˜ 성곡");
Expand All @@ -41,4 +78,3 @@ public byte[] convert(String markdownText) {
}
}
}

55 changes: 55 additions & 0 deletions src/main/java/com/haru/api/infra/s3/MarkdownToWordConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.haru.api.infra.s3;

import lombok.extern.slf4j.Slf4j;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.text.TextContentRenderer;
import org.springframework.stereotype.Component;

import java.io.ByteArrayOutputStream;

@Slf4j
@Component
public class MarkdownToWordConverter {

/**
* Markdown λ¬Έμžμ—΄μ„ .docx 파일의 byte λ°°μ—΄λ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.
*
* @param markdownText λ³€ν™˜ν•  Markdown λ‚΄μš©
* @return .docx 파일의 byte λ°°μ—΄
*/
public byte[] convert(String markdownText) {
try {
// 1. Markdown을 μ„œμ‹μ΄ μ—†λŠ” 일반 ν…μŠ€νŠΈλ‘œ 1μ°¨ λ³€ν™˜ν•©λ‹ˆλ‹€.
Parser parser = Parser.builder().build();
Node document = parser.parse(markdownText);
TextContentRenderer renderer = TextContentRenderer.builder().build();
String plainText = renderer.render(document);

// 2. Apache POIλ₯Ό μ‚¬μš©ν•˜μ—¬ μƒˆλ‘œμš΄ Word λ¬Έμ„œλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.
try (XWPFDocument wordDocument = new XWPFDocument();
ByteArrayOutputStream os = new ByteArrayOutputStream()) {

// 3. λ³€ν™˜λœ ν…μŠ€νŠΈλ₯Ό λ¬Έλ‹¨λ³„λ‘œ λ‚˜λˆ„μ–΄ Word λ¬Έμ„œμ— μΆ”κ°€ν•©λ‹ˆλ‹€.
String[] lines = plainText.split("\\r?\\n");
for (String line : lines) {
XWPFParagraph paragraph = wordDocument.createParagraph();
XWPFRun run = paragraph.createRun();
run.setText(line);
}

// 4. μƒμ„±λœ Word λ¬Έμ„œλ₯Ό byte λ°°μ—΄λ‘œ μ €μž₯ν•˜μ—¬ λ°˜ν™˜ν•©λ‹ˆλ‹€.
wordDocument.write(os);
log.info("Markdown to Word λ³€ν™˜ 성곡 (using Apache POI)");
return os.toByteArray();
}

} catch (Exception e) {
log.error("Markdown to Word λ³€ν™˜ 쀑 였λ₯˜ λ°œμƒ (using Apache POI)", e);
throw new RuntimeException("Word 데이터 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", e);
}
}
}