feat: 채팅 메시지 전송·파일 업로드·공지(핀+헤더) 백엔드 추가
@28107e3ff32401321587118546be4f19eb1ac63d
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
package kr.itn.itnhub.config; |
| 2 | 2 |
|
| 3 | 3 |
import kr.itn.itnhub.feed.ChannelNotReadyException; |
| 4 |
+import kr.itn.itnhub.feed.InvalidMessageException; |
|
| 4 | 5 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 5 | 6 |
import kr.itn.itnhub.seed.SeedParseException; |
| 6 | 7 |
import org.springframework.http.HttpStatus; |
... | ... | @@ -35,6 +36,9 @@ |
| 35 | 36 |
* 문정원/법률검토 채널이 아직 생성되지 않았거나 channel 파라미터가 잘못됐을 때 던진다 - |
| 36 | 37 |
* 기관은 있고(404 아님) 요청 자체도 잘못되지 않았으니(400 아님) "아직 처리할 수 없는 |
| 37 | 38 |
* 상태"라는 의미로 409를 쓴다.</p> |
| 39 |
+ * |
|
| 40 |
+ * <p>{@link InvalidMessageException}은 채팅 탭에서 메시지 본문과 첨부파일이 둘 다
|
|
| 41 |
+ * 없는 채로 전송을 시도할 때 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p> |
|
| 38 | 42 |
* |
| 39 | 43 |
* <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
|
| 40 | 44 |
* 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리 |
... | ... | @@ -76,4 +80,9 @@ |
| 76 | 80 |
public ResponseEntity<ApiError> handleChannelNotReady(ChannelNotReadyException e) {
|
| 77 | 81 |
return ResponseEntity.status(HttpStatus.CONFLICT).body(new ApiError(e.getMessage())); |
| 78 | 82 |
} |
| 83 |
+ |
|
| 84 |
+ @ExceptionHandler(InvalidMessageException.class) |
|
| 85 |
+ public ResponseEntity<ApiError> handleInvalidMessage(InvalidMessageException e) {
|
|
| 86 |
+ return ResponseEntity.badRequest().body(new ApiError(e.getMessage())); |
|
| 87 |
+ } |
|
| 79 | 88 |
} |
--- src/main/java/kr/itn/itnhub/config/MattermostProperties.java
+++ src/main/java/kr/itn/itnhub/config/MattermostProperties.java
... | ... | @@ -9,5 +9,6 @@ |
| 9 | 9 |
String token, |
| 10 | 10 |
String teamId, |
| 11 | 11 |
@DefaultValue("문정원") String channelNameMj,
|
| 12 |
- @DefaultValue("법률검토") String channelNameLaw) {
|
|
| 12 |
+ @DefaultValue("법률검토") String channelNameLaw,
|
|
| 13 |
+ @DefaultValue("itn-hub") String teamName) {
|
|
| 13 | 14 |
} |
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
... | ... | @@ -2,15 +2,22 @@ |
| 2 | 2 |
|
| 3 | 3 |
import kr.itn.itnhub.mattermost.MattermostClient; |
| 4 | 4 |
import org.springframework.http.HttpHeaders; |
| 5 |
+import org.springframework.http.HttpStatus; |
|
| 5 | 6 |
import org.springframework.http.MediaType; |
| 6 | 7 |
import org.springframework.http.ResponseEntity; |
| 8 |
+import org.springframework.web.bind.annotation.DeleteMapping; |
|
| 7 | 9 |
import org.springframework.web.bind.annotation.GetMapping; |
| 8 | 10 |
import org.springframework.web.bind.annotation.PathVariable; |
| 11 |
+import org.springframework.web.bind.annotation.PostMapping; |
|
| 9 | 12 |
import org.springframework.web.bind.annotation.RequestParam; |
| 13 |
+import org.springframework.web.bind.annotation.ResponseStatus; |
|
| 10 | 14 |
import org.springframework.web.bind.annotation.RestController; |
| 15 |
+import org.springframework.web.multipart.MultipartFile; |
|
| 11 | 16 |
|
| 17 |
+import java.io.IOException; |
|
| 12 | 18 |
import java.net.URLEncoder; |
| 13 | 19 |
import java.nio.charset.StandardCharsets; |
| 20 |
+import java.util.ArrayList; |
|
| 14 | 21 |
import java.util.List; |
| 15 | 22 |
|
| 16 | 23 |
/** |
... | ... | @@ -39,6 +46,54 @@ |
| 39 | 46 |
} |
| 40 | 47 |
|
| 41 | 48 |
/** |
| 49 |
+ * 메시지를 보낸다. {@code message}와 {@code files} 둘 다 비어 있으면 400을 돌려준다.
|
|
| 50 |
+ * {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다 - 자세한 내용은
|
|
| 51 |
+ * {@link ChannelFeedService#sendMessage}를 참고.
|
|
| 52 |
+ */ |
|
| 53 |
+ @PostMapping("/api/orgs/{id}/messages")
|
|
| 54 |
+ public SendResult sendMessage( |
|
| 55 |
+ @PathVariable Long id, |
|
| 56 |
+ @RequestParam String channel, |
|
| 57 |
+ @RequestParam(required = false, defaultValue = "") String message, |
|
| 58 |
+ @RequestParam(required = false, defaultValue = "false") boolean notice, |
|
| 59 |
+ @RequestParam(required = false) List<MultipartFile> files) {
|
|
| 60 |
+ |
|
| 61 |
+ List<MultipartFile> submitted = files == null ? List.of() : files; |
|
| 62 |
+ boolean anyFile = submitted.stream().anyMatch(f -> !f.isEmpty()); |
|
| 63 |
+ if (message.isBlank() && !anyFile) {
|
|
| 64 |
+ throw new InvalidMessageException("메시지 내용이나 첨부파일 중 하나는 있어야 합니다.");
|
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ List<UploadedFile> uploaded = new ArrayList<>(); |
|
| 68 |
+ for (MultipartFile file : submitted) {
|
|
| 69 |
+ if (file.isEmpty()) {
|
|
| 70 |
+ continue; |
|
| 71 |
+ } |
|
| 72 |
+ try {
|
|
| 73 |
+ uploaded.add(new UploadedFile(file.getOriginalFilename(), file.getBytes(), file.getContentType())); |
|
| 74 |
+ } catch (IOException e) {
|
|
| 75 |
+ throw new InvalidMessageException("첨부파일을 읽지 못했습니다: " + file.getOriginalFilename());
|
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ return feedService.sendMessage(id, channel, message, uploaded, notice); |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ /** 채널의 현재 공지(가장 최근에 고정된 게시글). 없으면 204를 돌려준다. */ |
|
| 83 |
+ @GetMapping("/api/orgs/{id}/notice")
|
|
| 84 |
+ public ResponseEntity<PostView> notice(@PathVariable Long id, @RequestParam String channel) {
|
|
| 85 |
+ PostView notice = feedService.currentNotice(id, channel); |
|
| 86 |
+ return notice == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(notice); |
|
| 87 |
+ } |
|
| 88 |
+ |
|
| 89 |
+ /** 공지 해제: 고정을 전부 풀고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */ |
|
| 90 |
+ @ResponseStatus(HttpStatus.NO_CONTENT) |
|
| 91 |
+ @DeleteMapping("/api/orgs/{id}/notice")
|
|
| 92 |
+ public void clearNotice(@PathVariable Long id, @RequestParam String channel) {
|
|
| 93 |
+ feedService.clearNotice(id, channel); |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ /** |
|
| 42 | 97 |
* 한글 파일명이 대부분이라 {@code Content-Disposition}은 반드시 RFC 5987의
|
| 43 | 98 |
* {@code filename*=UTF-8''...} 형식으로 인코딩해야 한다. 일반 {@code filename="..."}
|
| 44 | 99 |
* 만 쓰면 브라우저마다 한글이 깨진다. |
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
... | ... | @@ -1,18 +1,27 @@ |
| 1 | 1 |
package kr.itn.itnhub.feed; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.config.MattermostProperties; |
|
| 3 | 4 |
import kr.itn.itnhub.mattermost.MattermostClient; |
| 5 |
+import kr.itn.itnhub.mattermost.MattermostException; |
|
| 4 | 6 |
import kr.itn.itnhub.org.Organization; |
| 5 | 7 |
import kr.itn.itnhub.org.OrganizationMapper; |
| 6 | 8 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 7 | 9 |
import org.springframework.stereotype.Service; |
| 8 | 10 |
|
| 11 |
+import java.util.ArrayList; |
|
| 9 | 12 |
import java.util.List; |
| 10 | 13 |
|
| 11 | 14 |
/** |
| 12 |
- * 기관관리 상세 화면의 게시물/자료 탭이 쓰는 조회 전용 서비스. |
|
| 13 |
- * Mattermost를 매번 실시간으로 읽어오는 뷰이므로 {@code @Transactional}을 걸지 않는다 -
|
|
| 14 |
- * DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을 트랜잭션 |
|
| 15 |
- * 안에 묶어둘 이유가 없다. |
|
| 15 |
+ * 기관관리 상세 화면의 채팅 탭이 쓰는 서비스. 조회뿐 아니라 메시지 전송·공지 등록/해제까지 |
|
| 16 |
+ * 담당한다. Mattermost를 매번 실시간으로 읽고 쓰는 통로이므로 {@code @Transactional}을
|
|
| 17 |
+ * 걸지 않는다 - DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을 |
|
| 18 |
+ * 트랜잭션 안에 묶어둘 이유가 없다. |
|
| 19 |
+ * |
|
| 20 |
+ * <p>공지 정책(정책 A - 단일 활성 공지)은 {@link #sendMessage}에서 처리한다: 게시글을
|
|
| 21 |
+ * 올린 뒤, 기존에 고정돼 있던 게시글을 전부 해제하고, 새 게시글을 고정하고, 채널 헤더를 |
|
| 22 |
+ * 공지 요약 + 원문 permalink로 바꾼다. 게시글 전송 자체가 아니라 이 뒤처리(고정/헤더) 중 |
|
| 23 |
+ * 하나라도 실패하면 - 게시글은 이미 채널 멤버에게 전달된 뒤이므로 - 실패로 보고하지 않고 |
|
| 24 |
+ * {@link SendResult#noticeWarning()}에 경고만 담아 돌려준다.</p>
|
|
| 16 | 25 |
*/ |
| 17 | 26 |
@Service |
| 18 | 27 |
public class ChannelFeedService {
|
... | ... | @@ -20,12 +29,18 @@ |
| 20 | 29 |
/** 게시물 탭 한 번에 보여줄 최근 게시글 수. */ |
| 21 | 30 |
private static final int RECENT_POSTS_PAGE_SIZE = 60; |
| 22 | 31 |
|
| 32 |
+ /** 공지 헤더에 넣을 요약문 최대 길이(첫 줄 기준). */ |
|
| 33 |
+ private static final int NOTICE_SUMMARY_MAX_LEN = 150; |
|
| 34 |
+ |
|
| 23 | 35 |
private final OrganizationMapper orgMapper; |
| 24 | 36 |
private final MattermostClient mattermost; |
| 37 |
+ private final MattermostProperties mattermostProps; |
|
| 25 | 38 |
|
| 26 |
- public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost) {
|
|
| 39 |
+ public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost, |
|
| 40 |
+ MattermostProperties mattermostProps) {
|
|
| 27 | 41 |
this.orgMapper = orgMapper; |
| 28 | 42 |
this.mattermost = mattermost; |
| 43 |
+ this.mattermostProps = mattermostProps; |
|
| 29 | 44 |
} |
| 30 | 45 |
|
| 31 | 46 |
public List<PostView> posts(Long orgId, String channel) {
|
... | ... | @@ -38,6 +53,89 @@ |
| 38 | 53 |
return mattermost.collectChannelFiles(channelId); |
| 39 | 54 |
} |
| 40 | 55 |
|
| 56 |
+ /** |
|
| 57 |
+ * 메시지를 보내고, {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다.
|
|
| 58 |
+ * 파일 업로드·게시글 생성이 실패하면 그대로 예외가 전파된다(전송 자체가 실패한 것). |
|
| 59 |
+ */ |
|
| 60 |
+ public SendResult sendMessage(Long orgId, String channel, String message, |
|
| 61 |
+ List<UploadedFile> files, boolean notice) {
|
|
| 62 |
+ String channelId = resolveChannelId(orgId, channel); |
|
| 63 |
+ |
|
| 64 |
+ List<String> fileIds = new ArrayList<>(); |
|
| 65 |
+ for (UploadedFile file : files) {
|
|
| 66 |
+ fileIds.add(mattermost.uploadFile(channelId, file.filename(), file.data(), file.contentType())); |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ String postId = mattermost.createPost(channelId, message, fileIds); |
|
| 70 |
+ PostView post = fetchPost(channelId, postId, message); |
|
| 71 |
+ |
|
| 72 |
+ if (!notice) {
|
|
| 73 |
+ return new SendResult(post, null); |
|
| 74 |
+ } |
|
| 75 |
+ |
|
| 76 |
+ try {
|
|
| 77 |
+ for (PostView pinned : mattermost.getPinnedPosts(channelId)) {
|
|
| 78 |
+ mattermost.unpinPost(pinned.id()); |
|
| 79 |
+ } |
|
| 80 |
+ mattermost.pinPost(postId); |
|
| 81 |
+ mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId)); |
|
| 82 |
+ return new SendResult(post, null); |
|
| 83 |
+ } catch (MattermostException e) {
|
|
| 84 |
+ return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage()); |
|
| 85 |
+ } |
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ /** 채널의 "현재 공지" = 가장 최근에 고정된, 시스템 메시지가 아닌 게시글. 없으면 null. */ |
|
| 89 |
+ public PostView currentNotice(Long orgId, String channel) {
|
|
| 90 |
+ String channelId = resolveChannelId(orgId, channel); |
|
| 91 |
+ return mattermost.getPinnedPosts(channelId).stream() |
|
| 92 |
+ .filter(p -> !p.system()) |
|
| 93 |
+ .findFirst() |
|
| 94 |
+ .orElse(null); |
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ /** 공지 해제: 고정된 게시글을 전부 해제하고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */ |
|
| 98 |
+ public void clearNotice(Long orgId, String channel) {
|
|
| 99 |
+ String channelId = resolveChannelId(orgId, channel); |
|
| 100 |
+ for (PostView pinned : mattermost.getPinnedPosts(channelId)) {
|
|
| 101 |
+ mattermost.unpinPost(pinned.id()); |
|
| 102 |
+ } |
|
| 103 |
+ mattermost.updateChannelHeader(channelId, ""); |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ /** |
|
| 107 |
+ * 방금 만든 게시글을 다시 조회해 표시용 정보(작성자 표시명 등)까지 채워진 {@link PostView}로
|
|
| 108 |
+ * 돌려준다. 조회가 비어 있는 예외적인 경우를 대비해 최소한의 값으로 대체한다. |
|
| 109 |
+ */ |
|
| 110 |
+ private PostView fetchPost(String channelId, String postId, String message) {
|
|
| 111 |
+ return mattermost.getRecentPosts(channelId, 1).stream() |
|
| 112 |
+ .filter(p -> p.id().equals(postId)) |
|
| 113 |
+ .findFirst() |
|
| 114 |
+ .orElseGet(() -> new PostView(postId, "", message == null ? "" : message, |
|
| 115 |
+ System.currentTimeMillis(), false, List.of())); |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ /** |
|
| 119 |
+ * 공지 헤더 문자열: {@code 📢 공지: {요약} — [자세히]({permalink})}.
|
|
| 120 |
+ * 요약은 메시지 첫 줄을 150자로 자른 값이고, 메시지가 비어 있으면 첫 첨부파일 이름을 쓴다. |
|
| 121 |
+ */ |
|
| 122 |
+ private String noticeHeader(String message, List<UploadedFile> files, String postId) {
|
|
| 123 |
+ String summary = noticeSummary(message, files); |
|
| 124 |
+ String permalink = mattermostProps.baseUrl() + "/" + mattermostProps.teamName() + "/pl/" + postId; |
|
| 125 |
+ return "📢 공지: " + summary + " — [자세히](" + permalink + ")";
|
|
| 126 |
+ } |
|
| 127 |
+ |
|
| 128 |
+ private String noticeSummary(String message, List<UploadedFile> files) {
|
|
| 129 |
+ String firstLine = message == null ? "" : message.strip().split("\\R", 2)[0];
|
|
| 130 |
+ if (firstLine.isBlank() && !files.isEmpty()) {
|
|
| 131 |
+ firstLine = files.get(0).filename(); |
|
| 132 |
+ } |
|
| 133 |
+ if (firstLine.length() > NOTICE_SUMMARY_MAX_LEN) {
|
|
| 134 |
+ firstLine = firstLine.substring(0, NOTICE_SUMMARY_MAX_LEN); |
|
| 135 |
+ } |
|
| 136 |
+ return firstLine; |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 41 | 139 |
private String resolveChannelId(Long orgId, String channel) {
|
| 42 | 140 |
Organization org = orgMapper.findById(orgId); |
| 43 | 141 |
if (org == null) {
|
+++ src/main/java/kr/itn/itnhub/feed/InvalidMessageException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 채널에 보낼 메시지에 본문도 없고 첨부파일도 없을 때 던진다. 흔한 사용자 실수(빈 채로 | |
| 5 | + * 보내기 버튼을 누름)이므로 400과 함께 무엇이 잘못됐는지 알려준다. | |
| 6 | + */ | |
| 7 | +public class InvalidMessageException extends RuntimeException { | |
| 8 | + public InvalidMessageException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/feed/SendResult.java
... | ... | @@ -0,0 +1,10 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 메시지 전송 결과. {@code post}는 항상 채워진다 - 공지 등록 부가 처리(고정/헤더 변경)가 | |
| 5 | + * 게시글이 이미 만들어진 뒤에 실패하더라도, 메시지 자체는 정상 전송된 것이므로 실패로 | |
| 6 | + * 보이면 안 된다. 그 경우 {@code noticeWarning}에 무엇이 실패했는지 담아 알려준다. | |
| 7 | + * 완전히 성공하면 {@code noticeWarning}은 {@code null}이다. | |
| 8 | + */ | |
| 9 | +public record SendResult(PostView post, String noticeWarning) { | |
| 10 | +} |
+++ src/main/java/kr/itn/itnhub/feed/UploadedFile.java
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 메시지에 첨부할 파일 하나의 원본 바이트. 서블릿 타입({@code MultipartFile})을 서비스 | |
| 5 | + * 계층으로 들여오지 않기 위한 최소 전달 객체 - 컨트롤러가 {@code MultipartFile}을 읽어 | |
| 6 | + * 이 레코드로 변환한 뒤 {@code ChannelFeedService}에 넘긴다. | |
| 7 | + */ | |
| 8 | +public record UploadedFile(String filename, byte[] data, String contentType) { | |
| 9 | +} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
... | ... | @@ -24,4 +24,19 @@ |
| 24 | 24 |
byte[] downloadFile(String fileId); |
| 25 | 25 |
|
| 26 | 26 |
FileRef fileInfo(String fileId); |
| 27 |
+ |
|
| 28 |
+ /** 파일을 채널에 업로드하고 file id를 돌려준다. 아직 어떤 게시글에도 첨부되지 않은 상태다. */ |
|
| 29 |
+ String uploadFile(String channelId, String filename, byte[] data, String contentType); |
|
| 30 |
+ |
|
| 31 |
+ /** 채널에 게시글을 올리고 post id를 돌려준다. {@code fileIds}는 비어 있어도 된다. */
|
|
| 32 |
+ String createPost(String channelId, String message, List<String> fileIds); |
|
| 33 |
+ |
|
| 34 |
+ void pinPost(String postId); |
|
| 35 |
+ |
|
| 36 |
+ void unpinPost(String postId); |
|
| 37 |
+ |
|
| 38 |
+ /** 채널의 고정된 게시글을 최신순으로 돌려준다. */ |
|
| 39 |
+ List<PostView> getPinnedPosts(String channelId); |
|
| 40 |
+ |
|
| 41 |
+ void updateChannelHeader(String channelId, String header); |
|
| 27 | 42 |
} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
import org.springframework.beans.factory.annotation.Autowired; |
| 9 | 9 |
import org.springframework.http.MediaType; |
| 10 | 10 |
import org.springframework.stereotype.Component; |
| 11 |
+import org.springframework.util.InvalidMimeTypeException; |
|
| 11 | 12 |
import org.springframework.web.client.RestClient; |
| 12 | 13 |
import org.springframework.web.client.RestClientException; |
| 13 | 14 |
|
... | ... | @@ -17,6 +18,7 @@ |
| 17 | 18 |
import java.util.List; |
| 18 | 19 |
import java.util.Map; |
| 19 | 20 |
import java.util.Optional; |
| 21 |
+import java.util.UUID; |
|
| 20 | 22 |
import java.util.concurrent.ConcurrentHashMap; |
| 21 | 23 |
|
| 22 | 24 |
@Component |
... | ... | @@ -259,6 +261,175 @@ |
| 259 | 261 |
} |
| 260 | 262 |
} |
| 261 | 263 |
|
| 264 |
+ private static final String MULTIPART_CRLF = "\r\n"; |
|
| 265 |
+ |
|
| 266 |
+ /** |
|
| 267 |
+ * multipart/form-data 본문을 직접 바이트로 구성해서 보낸다. {@code MultipartBodyBuilder}는
|
|
| 268 |
+ * 편리하지만 이 프로젝트의 클래스패스에는 없는 {@code org.reactivestreams.Publisher}를
|
|
| 269 |
+ * 내부적으로 필요로 해(RestClient의 multipart 기록 경로가 파트마다 Publisher인지 검사한다) |
|
| 270 |
+ * NoClassDefFoundError가 난다 - 새 의존성을 추가하는 대신 바이트를 직접 조립한다. |
|
| 271 |
+ * 한글 파일명도 헤더 문자열 인코딩 제약(ISO-8859-1 등) 없이 UTF-8 바이트로 그대로 실린다. |
|
| 272 |
+ */ |
|
| 273 |
+ @Override |
|
| 274 |
+ public String uploadFile(String channelId, String filename, byte[] data, String contentType) {
|
|
| 275 |
+ String boundary = "----ItnHubBoundary" + UUID.randomUUID(); |
|
| 276 |
+ |
|
| 277 |
+ try {
|
|
| 278 |
+ byte[] body = buildMultipartBody(boundary, channelId, filename, data, contentType); |
|
| 279 |
+ |
|
| 280 |
+ JsonNode responseBody = rest.post() |
|
| 281 |
+ .uri("/api/v4/files")
|
|
| 282 |
+ .header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
|
| 283 |
+ .body(body) |
|
| 284 |
+ .retrieve() |
|
| 285 |
+ .body(JsonNode.class); |
|
| 286 |
+ |
|
| 287 |
+ if (responseBody == null) {
|
|
| 288 |
+ throw new MattermostException("파일 업로드 응답이 없습니다: " + filename);
|
|
| 289 |
+ } |
|
| 290 |
+ JsonNode fileInfos = responseBody.path("file_infos");
|
|
| 291 |
+ if (!fileInfos.isArray() || fileInfos.isEmpty()) {
|
|
| 292 |
+ throw new MattermostException("파일 업로드 응답에 file_infos가 없습니다: " + filename);
|
|
| 293 |
+ } |
|
| 294 |
+ return idOf(fileInfos.get(0)).orElseThrow(() -> |
|
| 295 |
+ new MattermostException("파일 업로드 응답에 id가 없습니다: " + filename));
|
|
| 296 |
+ } catch (RestClientException e) {
|
|
| 297 |
+ throw new MattermostException("파일 업로드 실패: " + filename, e);
|
|
| 298 |
+ } |
|
| 299 |
+ } |
|
| 300 |
+ |
|
| 301 |
+ private byte[] buildMultipartBody(String boundary, String channelId, String filename, |
|
| 302 |
+ byte[] data, String contentType) {
|
|
| 303 |
+ try {
|
|
| 304 |
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); |
|
| 305 |
+ |
|
| 306 |
+ writeAscii(out, "--" + boundary + MULTIPART_CRLF); |
|
| 307 |
+ writeAscii(out, "Content-Disposition: form-data; name=\"channel_id\"" + MULTIPART_CRLF + MULTIPART_CRLF); |
|
| 308 |
+ out.write(channelId.getBytes(java.nio.charset.StandardCharsets.UTF_8)); |
|
| 309 |
+ writeAscii(out, MULTIPART_CRLF); |
|
| 310 |
+ |
|
| 311 |
+ writeAscii(out, "--" + boundary + MULTIPART_CRLF); |
|
| 312 |
+ writeAscii(out, "Content-Disposition: form-data; name=\"files\"; filename=\"" |
|
| 313 |
+ + filename + "\"" + MULTIPART_CRLF); |
|
| 314 |
+ writeAscii(out, "Content-Type: " + resolveContentType(contentType) + MULTIPART_CRLF + MULTIPART_CRLF); |
|
| 315 |
+ out.write(data); |
|
| 316 |
+ writeAscii(out, MULTIPART_CRLF); |
|
| 317 |
+ |
|
| 318 |
+ writeAscii(out, "--" + boundary + "--" + MULTIPART_CRLF); |
|
| 319 |
+ return out.toByteArray(); |
|
| 320 |
+ } catch (java.io.IOException e) {
|
|
| 321 |
+ throw new MattermostException("파일 업로드 본문 구성 실패: " + filename, e);
|
|
| 322 |
+ } |
|
| 323 |
+ } |
|
| 324 |
+ |
|
| 325 |
+ private void writeAscii(java.io.ByteArrayOutputStream out, String text) throws java.io.IOException {
|
|
| 326 |
+ out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); |
|
| 327 |
+ } |
|
| 328 |
+ |
|
| 329 |
+ private String resolveContentType(String contentType) {
|
|
| 330 |
+ if (contentType == null || contentType.isBlank()) {
|
|
| 331 |
+ return MediaType.APPLICATION_OCTET_STREAM_VALUE; |
|
| 332 |
+ } |
|
| 333 |
+ try {
|
|
| 334 |
+ return MediaType.parseMediaType(contentType).toString(); |
|
| 335 |
+ } catch (InvalidMimeTypeException e) {
|
|
| 336 |
+ return MediaType.APPLICATION_OCTET_STREAM_VALUE; |
|
| 337 |
+ } |
|
| 338 |
+ } |
|
| 339 |
+ |
|
| 340 |
+ @Override |
|
| 341 |
+ public String createPost(String channelId, String message, List<String> fileIds) {
|
|
| 342 |
+ Map<String, Object> body = new LinkedHashMap<>(); |
|
| 343 |
+ body.put("channel_id", channelId);
|
|
| 344 |
+ body.put("message", message == null ? "" : message);
|
|
| 345 |
+ if (fileIds != null && !fileIds.isEmpty()) {
|
|
| 346 |
+ body.put("file_ids", fileIds);
|
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 349 |
+ try {
|
|
| 350 |
+ JsonNode created = rest.post() |
|
| 351 |
+ .uri("/api/v4/posts")
|
|
| 352 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 353 |
+ .body(body) |
|
| 354 |
+ .retrieve() |
|
| 355 |
+ .body(JsonNode.class); |
|
| 356 |
+ |
|
| 357 |
+ return idOf(created).orElseThrow(() -> |
|
| 358 |
+ new MattermostException("게시글 생성 응답에 id가 없습니다: " + channelId));
|
|
| 359 |
+ } catch (RestClientException e) {
|
|
| 360 |
+ throw new MattermostException("게시글 생성 실패: " + channelId, e);
|
|
| 361 |
+ } |
|
| 362 |
+ } |
|
| 363 |
+ |
|
| 364 |
+ @Override |
|
| 365 |
+ public void pinPost(String postId) {
|
|
| 366 |
+ try {
|
|
| 367 |
+ rest.post() |
|
| 368 |
+ .uri("/api/v4/posts/{postId}/pin", postId)
|
|
| 369 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 370 |
+ .body(Map.of()) |
|
| 371 |
+ .retrieve() |
|
| 372 |
+ .toBodilessEntity(); |
|
| 373 |
+ } catch (RestClientException e) {
|
|
| 374 |
+ throw new MattermostException("게시글 고정 실패: " + postId, e);
|
|
| 375 |
+ } |
|
| 376 |
+ } |
|
| 377 |
+ |
|
| 378 |
+ @Override |
|
| 379 |
+ public void unpinPost(String postId) {
|
|
| 380 |
+ try {
|
|
| 381 |
+ rest.post() |
|
| 382 |
+ .uri("/api/v4/posts/{postId}/unpin", postId)
|
|
| 383 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 384 |
+ .body(Map.of()) |
|
| 385 |
+ .retrieve() |
|
| 386 |
+ .toBodilessEntity(); |
|
| 387 |
+ } catch (RestClientException e) {
|
|
| 388 |
+ throw new MattermostException("게시글 고정 해제 실패: " + postId, e);
|
|
| 389 |
+ } |
|
| 390 |
+ } |
|
| 391 |
+ |
|
| 392 |
+ @Override |
|
| 393 |
+ public List<PostView> getPinnedPosts(String channelId) {
|
|
| 394 |
+ try {
|
|
| 395 |
+ JsonNode body = rest.get() |
|
| 396 |
+ .uri("/api/v4/channels/{channelId}/pinned", channelId)
|
|
| 397 |
+ .retrieve() |
|
| 398 |
+ .body(JsonNode.class); |
|
| 399 |
+ |
|
| 400 |
+ if (body == null) {
|
|
| 401 |
+ return List.of(); |
|
| 402 |
+ } |
|
| 403 |
+ |
|
| 404 |
+ List<PostView> result = new ArrayList<>(); |
|
| 405 |
+ JsonNode posts = body.path("posts");
|
|
| 406 |
+ // Mattermost가 이미 newest-first(order)로 내려주므로 뒤집지 않는다. |
|
| 407 |
+ for (JsonNode idNode : body.path("order")) {
|
|
| 408 |
+ result.add(toPostView(posts.path(idNode.asText()))); |
|
| 409 |
+ } |
|
| 410 |
+ return result; |
|
| 411 |
+ } catch (RestClientException e) {
|
|
| 412 |
+ throw new MattermostException("고정 게시글 조회 실패: " + channelId, e);
|
|
| 413 |
+ } |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ @Override |
|
| 417 |
+ public void updateChannelHeader(String channelId, String header) {
|
|
| 418 |
+ Map<String, String> body = new LinkedHashMap<>(); |
|
| 419 |
+ body.put("header", header == null ? "" : header);
|
|
| 420 |
+ |
|
| 421 |
+ try {
|
|
| 422 |
+ rest.put() |
|
| 423 |
+ .uri("/api/v4/channels/{channelId}/patch", channelId)
|
|
| 424 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 425 |
+ .body(body) |
|
| 426 |
+ .retrieve() |
|
| 427 |
+ .toBodilessEntity(); |
|
| 428 |
+ } catch (RestClientException e) {
|
|
| 429 |
+ throw new MattermostException("채널 헤더 변경 실패: " + channelId, e);
|
|
| 430 |
+ } |
|
| 431 |
+ } |
|
| 432 |
+ |
|
| 262 | 433 |
private PostView toPostView(JsonNode post) {
|
| 263 | 434 |
String id = post.path("id").asText();
|
| 264 | 435 |
String message = post.path("message").asText("");
|
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -33,6 +33,7 @@ |
| 33 | 33 |
base-url: ${MATTERMOST_URL}
|
| 34 | 34 |
token: ${MATTERMOST_TOKEN}
|
| 35 | 35 |
team-id: ${MATTERMOST_TEAM_ID}
|
| 36 |
+ team-name: ${MATTERMOST_TEAM_NAME:itn-hub}
|
|
| 36 | 37 |
|
| 37 | 38 |
app: |
| 38 | 39 |
admin: |
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
... | ... | @@ -34,7 +34,8 @@ |
| 34 | 34 |
"test-token", |
| 35 | 35 |
"team123", |
| 36 | 36 |
"문정원", |
| 37 |
- "법률검토"); |
|
| 37 |
+ "법률검토", |
|
| 38 |
+ "itn-hub"); |
|
| 38 | 39 |
|
| 39 | 40 |
// 이 HTTP/1.1 고정은 테스트 하네스(WireMock/Jetty)만의 제약이다: JDK 21 HttpClient의 |
| 40 | 41 |
// 기본 h2c 업그레이드 협상이 WireMock 상대로 POST 요청에서 간헐적으로 RST_STREAM을 |
... | ... | @@ -385,4 +386,87 @@ |
| 385 | 386 |
assertThat(info.size()).isEqualTo(1234L); |
| 386 | 387 |
assertThat(info.mimeType()).isEqualTo("application/pdf");
|
| 387 | 388 |
} |
| 389 |
+ |
|
| 390 |
+ @Test |
|
| 391 |
+ void 파일을_업로드하면_file_id를_돌려준다() {
|
|
| 392 |
+ server.stubFor(post(urlEqualTo("/api/v4/files"))
|
|
| 393 |
+ .willReturn(okJson("""
|
|
| 394 |
+ {"file_infos":[{"id":"f-new","name":"공지자료.pdf"}]}
|
|
| 395 |
+ """))); |
|
| 396 |
+ |
|
| 397 |
+ String fileId = client.uploadFile("chan001mj", "공지자료.pdf", "hello".getBytes(), "application/pdf");
|
|
| 398 |
+ |
|
| 399 |
+ assertThat(fileId).isEqualTo("f-new");
|
|
| 400 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/files"))
|
|
| 401 |
+ .withRequestBodyPart(aMultipart().withName("channel_id").withBody(equalTo("chan001mj")).build())
|
|
| 402 |
+ .withRequestBodyPart(aMultipart().withName("files").withBody(equalTo("hello")).build()));
|
|
| 403 |
+ } |
|
| 404 |
+ |
|
| 405 |
+ @Test |
|
| 406 |
+ void 게시글을_생성하면_post_id를_돌려준다() {
|
|
| 407 |
+ server.stubFor(post(urlEqualTo("/api/v4/posts"))
|
|
| 408 |
+ .willReturn(okJson("{\"id\":\"post-new\"}")));
|
|
| 409 |
+ |
|
| 410 |
+ String postId = client.createPost("chan001mj", "안녕하세요", List.of("f1", "f2"));
|
|
| 411 |
+ |
|
| 412 |
+ assertThat(postId).isEqualTo("post-new");
|
|
| 413 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/posts"))
|
|
| 414 |
+ .withRequestBody(matchingJsonPath("$.channel_id", equalTo("chan001mj")))
|
|
| 415 |
+ .withRequestBody(matchingJsonPath("$.message", equalTo("안녕하세요")))
|
|
| 416 |
+ .withRequestBody(matchingJsonPath("$.file_ids[0]", equalTo("f1")))
|
|
| 417 |
+ .withRequestBody(matchingJsonPath("$.file_ids[1]", equalTo("f2"))));
|
|
| 418 |
+ } |
|
| 419 |
+ |
|
| 420 |
+ @Test |
|
| 421 |
+ void 게시글을_고정한다() {
|
|
| 422 |
+ server.stubFor(post(urlEqualTo("/api/v4/posts/post1/pin"))
|
|
| 423 |
+ .willReturn(okJson("{\"status\":\"OK\"}")));
|
|
| 424 |
+ |
|
| 425 |
+ client.pinPost("post1");
|
|
| 426 |
+ |
|
| 427 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/posts/post1/pin")));
|
|
| 428 |
+ } |
|
| 429 |
+ |
|
| 430 |
+ @Test |
|
| 431 |
+ void 게시글_고정을_해제한다() {
|
|
| 432 |
+ server.stubFor(post(urlEqualTo("/api/v4/posts/post1/unpin"))
|
|
| 433 |
+ .willReturn(okJson("{\"status\":\"OK\"}")));
|
|
| 434 |
+ |
|
| 435 |
+ client.unpinPost("post1");
|
|
| 436 |
+ |
|
| 437 |
+ server.verify(postRequestedFor(urlEqualTo("/api/v4/posts/post1/unpin")));
|
|
| 438 |
+ } |
|
| 439 |
+ |
|
| 440 |
+ @Test |
|
| 441 |
+ void 고정된_게시글_목록을_최신순으로_돌려준다() {
|
|
| 442 |
+ server.stubFor(get(urlEqualTo("/api/v4/channels/chan001mj/pinned"))
|
|
| 443 |
+ .willReturn(okJson("""
|
|
| 444 |
+ {
|
|
| 445 |
+ "order": ["p2", "p1"], |
|
| 446 |
+ "posts": {
|
|
| 447 |
+ "p1": {"id":"p1","user_id":"u1","message":"먼저 고정","create_at":100,"type":""},
|
|
| 448 |
+ "p2": {"id":"p2","user_id":"u1","message":"나중 고정","create_at":200,"type":""}
|
|
| 449 |
+ } |
|
| 450 |
+ } |
|
| 451 |
+ """))); |
|
| 452 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
|
|
| 453 |
+ .willReturn(okJson("{\"username\":\"user1\"}")));
|
|
| 454 |
+ |
|
| 455 |
+ List<PostView> pinned = client.getPinnedPosts("chan001mj");
|
|
| 456 |
+ |
|
| 457 |
+ // Mattermost가 이미 newest-first로 내려주므로 뒤집지 않고 그대로다. |
|
| 458 |
+ assertThat(pinned).extracting(PostView::id).containsExactly("p2", "p1");
|
|
| 459 |
+ } |
|
| 460 |
+ |
|
| 461 |
+ @Test |
|
| 462 |
+ void 채널_헤더를_변경한다() {
|
|
| 463 |
+ server.stubFor(put(urlEqualTo("/api/v4/channels/chan001mj/patch"))
|
|
| 464 |
+ .willReturn(okJson("{\"id\":\"chan001mj\"}")));
|
|
| 465 |
+ |
|
| 466 |
+ client.updateChannelHeader("chan001mj", "📢 공지: 안녕하세요 — [자세히](http://x/pl/p1)");
|
|
| 467 |
+ |
|
| 468 |
+ server.verify(putRequestedFor(urlEqualTo("/api/v4/channels/chan001mj/patch"))
|
|
| 469 |
+ .withRequestBody(matchingJsonPath("$.header",
|
|
| 470 |
+ equalTo("📢 공지: 안녕하세요 — [자세히](http://x/pl/p1)"))));
|
|
| 471 |
+ } |
|
| 388 | 472 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?