Skip to content

Commit 548ba63

Browse files
authored
Merge pull request #312 from HaRu-Developers/dev
deploy 4.4
2 parents 35f0403 + f803594 commit 548ba63

File tree

9 files changed

+163
-23
lines changed

9 files changed

+163
-23
lines changed

src/main/java/com/haru/api/domain/meeting/controller/MeetingController.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.haru.api.domain.meeting.entity.Meeting;
66
import com.haru.api.domain.meeting.service.MeetingCommandService;
77
import com.haru.api.domain.meeting.service.MeetingQueryService;
8+
import com.haru.api.domain.snsEvent.entity.enums.Format;
9+
import com.haru.api.domain.snsEvent.entity.enums.ListType;
810
import com.haru.api.domain.user.entity.User;
911
import com.haru.api.domain.workspace.entity.Workspace;
1012
import com.haru.api.global.annotation.AuthMeeting;
@@ -170,11 +172,12 @@ public ApiResponse<String> endMeeting(
170172
@GetMapping("{meetingId}/ai-proceeding/download")
171173
public ApiResponse<MeetingResponseDTO.proceedingDownLoadLinkResponse> downloadMeeting(
172174
@PathVariable("meetingId") String meetingId,
175+
@RequestParam Format format,
173176
@Parameter(hidden = true) @AuthUser User user,
174177
@Parameter(hidden = true) @AuthMeeting Meeting meeting
175178
){
176179

177-
MeetingResponseDTO.proceedingDownLoadLinkResponse response = meetingQueryService.downloadMeeting(user, meeting);
180+
MeetingResponseDTO.proceedingDownLoadLinkResponse response = meetingQueryService.downloadMeeting(user, meeting, format);
178181

179182
return ApiResponse.onSuccess(response);
180183

src/main/java/com/haru/api/domain/meeting/entity/Meeting.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ public class Meeting extends BaseEntity implements Documentable {
4646

4747
// AI 회의록 정리본 파일
4848
@Column(columnDefinition = "TEXT")
49-
private String proceedingKeyName;
49+
private String proceedingPdfKeyName;
50+
51+
@Column(columnDefinition = "TEXT")
52+
private String proceedingWordKeyName;
5053

5154
@Column(columnDefinition = "TEXT")
5255
private String thumbnailKeyName;
@@ -77,7 +80,8 @@ public void updateTitle(String title) {
7780
public void updateProceeding(String proceeding) {
7881
this.proceeding = proceeding;
7982
}
80-
public void initProceedingKeyName(String proceedingKeyName) { this.proceedingKeyName = proceedingKeyName; }
83+
public void initProceedingPdfKeyName(String proceedingPdfKeyName) { this.proceedingPdfKeyName = proceedingPdfKeyName; }
84+
public void initProceedingWordKeyName(String proceedingWordKeyName) { this.proceedingWordKeyName = proceedingWordKeyName; }
8185
public void initThumbnailKeyName(String thumbnailKeyName) { this.thumbnailKeyName = thumbnailKeyName; }
8286
public void initStartTime(LocalDateTime startTime) {
8387
this.startTime = startTime;

src/main/java/com/haru/api/domain/meeting/service/MeetingCommandServiceImpl.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ public void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.upd
139139

140140
meeting.updateTitle(request.getTitle());
141141

142-
markdownFileUploader.updateFileTitle(meeting.getProceedingKeyName(), request.getTitle());
142+
markdownFileUploader.updateFileTitle(meeting.getProceedingPdfKeyName(), request.getTitle());
143143

144144
meetingRepository.save(meeting);
145145
}
@@ -148,18 +148,22 @@ public void updateMeetingTitle(User user, Meeting meeting, MeetingRequestDTO.upd
148148
@Transactional
149149
@DeleteDocument
150150
public void deleteMeeting(User user, Meeting meeting) {
151+
Meeting foundMeeting = meetingRepository.findById(meeting.getId())
152+
.orElseThrow(() -> new MeetingHandler(ErrorStatus.MEETING_NOT_FOUND));
151153

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

155-
if (!meeting.getCreator().getId().equals(user.getId()) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) {
157+
if (!foundMeeting.getCreator().getId().equals(user.getId()) && !foundUserWorkspace.getAuth().equals(Auth.ADMIN)) {
156158
throw new MemberHandler(ErrorStatus.MEMBER_NO_AUTHORITY);
157159
}
158160

159-
markdownFileUploader.deleteFileAndThumbnail(meeting.getProceedingKeyName(), meeting.getThumbnailKeyName());
160-
markdownFileUploader.deleteS3File(meeting.getAudioFileKey());
161+
markdownFileUploader.deleteS3File(foundMeeting.getProceedingPdfKeyName());
162+
markdownFileUploader.deleteS3File(foundMeeting.getThumbnailKeyName());
163+
markdownFileUploader.deleteS3File(foundMeeting.getProceedingWordKeyName());
164+
markdownFileUploader.deleteS3File(foundMeeting.getAudioFileKey());
161165

162-
meetingRepository.delete(meeting);
166+
meetingRepository.delete(foundMeeting);
163167
}
164168

165169
@Override
@@ -237,11 +241,13 @@ public void processAfterMeeting(AudioSessionBuffer sessionBuffer) {
237241
// --- PDF 및 썸네일 생성/업데이트 로직 시작 ---
238242
try {
239243
// 생성된 PDF를 S3에 업로드
240-
String pdfKey = markdownFileUploader.createOrUpdatePdf(analysisResult, "proceedings/", currentMeeting.getProceedingKeyName(), currentMeeting.getTitle());
241-
currentMeeting.initProceedingKeyName(pdfKey);
244+
String pdfKey = markdownFileUploader.createOrUpdatePdf(analysisResult, "meeting/pdf", currentMeeting.getProceedingPdfKeyName(), currentMeeting.getTitle());
245+
String wordKey = markdownFileUploader.createOrUpdateWord(analysisResult, "meeting/word", currentMeeting.getProceedingWordKeyName(), currentMeeting.getTitle());
246+
currentMeeting.initProceedingPdfKeyName(pdfKey);
247+
currentMeeting.initProceedingWordKeyName(wordKey);
242248

243249
// 썸네일 생성 및 업데이트
244-
String newThumbnailKey = markdownFileUploader.createOrUpdateThumbnail(pdfKey, "meetings/" + currentMeeting.getId(), currentMeeting.getThumbnailKeyName());
250+
String newThumbnailKey = markdownFileUploader.createOrUpdateThumbnail(pdfKey, "meeting" + currentMeeting.getId(), currentMeeting.getThumbnailKeyName());
245251
currentMeeting.initThumbnailKeyName(newThumbnailKey); // Meeting 엔티티에 썸네일 키 저장
246252
log.info("회의록 썸네일 생성/업데이트 완료. Key: {}", newThumbnailKey);
247253

src/main/java/com/haru/api/domain/meeting/service/MeetingQueryService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.haru.api.domain.meeting.dto.MeetingResponseDTO;
44
import com.haru.api.domain.meeting.entity.Meeting;
5+
import com.haru.api.domain.snsEvent.entity.enums.Format;
56
import com.haru.api.domain.user.entity.User;
67
import com.haru.api.domain.workspace.entity.Workspace;
78

@@ -15,7 +16,7 @@ public interface MeetingQueryService {
1516

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

18-
MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting);
19+
MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting, Format format);
1920

2021
MeetingResponseDTO.proceedingVoiceLinkResponse getMeetingVoiceFile(User user, Meeting meeting);
2122
}

src/main/java/com/haru/api/domain/meeting/service/MeetingQueryServiceImpl.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.haru.api.domain.meeting.dto.MeetingResponseDTO;
55
import com.haru.api.domain.meeting.entity.Meeting;
66
import com.haru.api.domain.meeting.repository.MeetingRepository;
7+
import com.haru.api.domain.snsEvent.entity.enums.Format;
78
import com.haru.api.domain.user.entity.User;
89
import com.haru.api.domain.workspace.entity.Workspace;
910
import com.haru.api.global.annotation.TrackLastOpened;
@@ -64,9 +65,18 @@ public MeetingResponseDTO.TranscriptResponse getTranscript(User user, Meeting me
6465
}
6566

6667
@Override
67-
public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting){
68-
69-
String proceedingKeyName = meeting.getProceedingKeyName();
68+
public MeetingResponseDTO.proceedingDownLoadLinkResponse downloadMeeting(User user, Meeting meeting, Format format){
69+
String proceedingKeyName;
70+
switch (format) {
71+
case PDF:
72+
proceedingKeyName = meeting.getProceedingPdfKeyName();
73+
break;
74+
case DOCX:
75+
proceedingKeyName = meeting.getProceedingWordKeyName();
76+
break;
77+
default:
78+
throw new MeetingHandler(ErrorStatus.MEETING_INVALID_FILE_FORMAT);
79+
}
7080

7181
if (proceedingKeyName == null || proceedingKeyName.isBlank()) {
7282
throw new MeetingHandler(ErrorStatus.MEETING_PROCEEDING_NOT_FOUND);

src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public enum ErrorStatus implements BaseErrorCode {
4545
MEETING_AUDIO_FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "MEETING4003", "음성 파일을 s3에 업로드하는데 오류가 발생했습니다."),
4646
MEETING_FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "MEETING4004", "음성 파일을 s3에 업로드하는데 오류가 발생했습니다."),
4747
MEETING_PROCEEDING_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEETING4005", "AI회의록이 없습니다"),
48+
MEETING_INVALID_FILE_FORMAT(HttpStatus.BAD_REQUEST, "MEETING4006", "잘못된 다운로드 파일 형식입니다."),
4849

4950
// 인가 관련 에러
5051
AUTHORIZATION_EXCEPTION(HttpStatus.UNAUTHORIZED, "AUTHORIZATION4001", "인증에 실패하였습니다."),

src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
public class MarkdownFileUploader {
1212

1313
private final MarkdownToPdfConverter markdownToPdfConverter;
14+
private final MarkdownToWordConverter markdownToWordConverter;
1415
private final ThumbnailGeneratorService thumbnailGeneratorService;
1516
private final AmazonS3Manager amazonS3Manager;
1617

@@ -48,6 +49,29 @@ public String createOrUpdatePdf(String markdownText, String featurePath, String
4849
return pdfKeyToUse;
4950
}
5051

52+
public String createOrUpdateWord(String markdownText, String featurePath, String existingWordKey, String fileTitle) {
53+
// 1. Markdown을 Word 데이터로 변환
54+
byte[] wordBytes = markdownToWordConverter.convert(markdownText);
55+
56+
// 2. 사용할 Word 키 결정
57+
String wordKeyToUse;
58+
if (existingWordKey != null && !existingWordKey.isBlank()) {
59+
wordKeyToUse = existingWordKey;
60+
log.info("기존 Word 파일 갱신을 시작합니다. Key: {}", wordKeyToUse);
61+
} else {
62+
wordKeyToUse = amazonS3Manager.generateKeyName(featurePath) + ".docx";
63+
log.info("새로운 Word 파일 생성을 시작합니다. New Key: {}", wordKeyToUse);
64+
}
65+
66+
// 3. 결정된 키로 Word 파일을 S3에 업로드
67+
String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
68+
amazonS3Manager.uploadFileWithTitle(wordKeyToUse, wordBytes, contentType, fileTitle);
69+
log.info("Word 파일 업로드/갱신 성공. Key: {}", wordKeyToUse);
70+
71+
// 4. 사용된 Word의 key를 반환
72+
return wordKeyToUse;
73+
}
74+
5175
/**
5276
* 썸네일만 생성 및 업로드
5377
* 기존 PDF 파일의 key를 받아 썸네일을 생성하고 S3에 업로드

src/main/java/com/haru/api/infra/s3/MarkdownToPdfConverter.java

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,29 @@
77
import org.commonmark.renderer.html.HtmlRenderer;
88
import org.springframework.stereotype.Component;
99

10-
import java.io.ByteArrayOutputStream;
11-
import java.io.File;
10+
import java.io.*;
1211

1312
@Slf4j
1413
@Component
1514
public class MarkdownToPdfConverter {
1615

17-
// Markdown 문자열 -> HTML , HTML -> PDF byte[] 변환
16+
private static final byte[] FONT_BYTES;
17+
18+
// 클래스가 로드될 때 폰트 파일을 딱 한 번만 읽어서 byte 배열에 저장합니다.
19+
static {
20+
String fontPath = "templates/NotoSansKR-Regular.ttf";
21+
try (InputStream in = MarkdownToPdfConverter.class.getClassLoader().getResourceAsStream(fontPath)) {
22+
if (in == null) {
23+
throw new IOException("폰트 파일을 클래스패스에서 찾을 수 없습니다: " + fontPath);
24+
}
25+
FONT_BYTES = in.readAllBytes();
26+
log.info("폰트 파일 로드 성공: {}, Size: {} bytes", fontPath, FONT_BYTES.length);
27+
} catch (Exception e) {
28+
log.error("초기화 중 폰트 파일을 로드하는 데 실패했습니다.", e);
29+
throw new RuntimeException("폰트 파일 로딩 실패", e);
30+
}
31+
}
32+
1833
public byte[] convert(String markdownText) {
1934
try {
2035
// 1. Markdown -> HTML
@@ -27,9 +42,31 @@ public byte[] convert(String markdownText) {
2742
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
2843
PdfRendererBuilder builder = new PdfRendererBuilder();
2944
builder.useFastMode();
30-
// 폰트
31-
builder.useFont(new File(getClass().getClassLoader().getResource("/templates/NotoSansKR-Regular.ttf").getFile()), "NanumGothic");
32-
builder.withHtmlContent(htmlContent, null);
45+
46+
builder.useFont(() -> new ByteArrayInputStream(FONT_BYTES), "NotoSansKR");
47+
48+
// [수정] meta 태그를 /> 로 닫아주어 XML 파싱 오류 해결
49+
String styledHtml = "<html>"
50+
+ "<head>"
51+
+ "<meta charset=\"UTF-8\" />"
52+
+ "<style>"
53+
+ "body { font-family: 'NotoSansKR'; font-size: 12px; }"
54+
+ "h1, h2, h3, h4, h5, h6 { font-weight: bold; margin-top: 1.2em; margin-bottom: 0.6em; }"
55+
+ "h1 { font-size: 2em; }"
56+
+ "h2 { font-size: 1.5em; }"
57+
+ "p { margin-bottom: 1em; line-height: 1.6; }"
58+
+ "strong { font-weight: bold; }"
59+
+ "ul, ol { padding-left: 25px; margin-bottom: 1em; }"
60+
+ "li { margin-bottom: 0.5em; }"
61+
+ "hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; }"
62+
+ "</style>"
63+
+ "</head>"
64+
+ "<body>"
65+
+ htmlContent
66+
+ "</body>"
67+
+ "</html>";
68+
69+
builder.withHtmlContent(styledHtml, null);
3370
builder.toStream(os);
3471
builder.run();
3572
log.info("Markdown to PDF 변환 성공");
@@ -41,4 +78,3 @@ public byte[] convert(String markdownText) {
4178
}
4279
}
4380
}
44-
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.haru.api.infra.s3;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.apache.poi.xwpf.usermodel.XWPFDocument;
5+
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
6+
import org.apache.poi.xwpf.usermodel.XWPFRun;
7+
import org.commonmark.node.Node;
8+
import org.commonmark.parser.Parser;
9+
import org.commonmark.renderer.text.TextContentRenderer;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.io.ByteArrayOutputStream;
13+
14+
@Slf4j
15+
@Component
16+
public class MarkdownToWordConverter {
17+
18+
/**
19+
* Markdown 문자열을 .docx 파일의 byte 배열로 변환합니다.
20+
*
21+
* @param markdownText 변환할 Markdown 내용
22+
* @return .docx 파일의 byte 배열
23+
*/
24+
public byte[] convert(String markdownText) {
25+
try {
26+
// 1. Markdown을 서식이 없는 일반 텍스트로 1차 변환합니다.
27+
Parser parser = Parser.builder().build();
28+
Node document = parser.parse(markdownText);
29+
TextContentRenderer renderer = TextContentRenderer.builder().build();
30+
String plainText = renderer.render(document);
31+
32+
// 2. Apache POI를 사용하여 새로운 Word 문서를 생성합니다.
33+
try (XWPFDocument wordDocument = new XWPFDocument();
34+
ByteArrayOutputStream os = new ByteArrayOutputStream()) {
35+
36+
// 3. 변환된 텍스트를 문단별로 나누어 Word 문서에 추가합니다.
37+
String[] lines = plainText.split("\\r?\\n");
38+
for (String line : lines) {
39+
XWPFParagraph paragraph = wordDocument.createParagraph();
40+
XWPFRun run = paragraph.createRun();
41+
run.setText(line);
42+
}
43+
44+
// 4. 생성된 Word 문서를 byte 배열로 저장하여 반환합니다.
45+
wordDocument.write(os);
46+
log.info("Markdown to Word 변환 성공 (using Apache POI)");
47+
return os.toByteArray();
48+
}
49+
50+
} catch (Exception e) {
51+
log.error("Markdown to Word 변환 중 오류 발생 (using Apache POI)", e);
52+
throw new RuntimeException("Word 데이터 생성에 실패했습니다.", e);
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)