feat: 기관관리 게시물/자료 탭용 Mattermost 채널 조회 API 추가
- MattermostClient에 게시글 조회(getRecentPosts), 채널 파일 수집(collectChannelFiles),
파일 다운로드/정보 조회(downloadFile, fileInfo)를 추가하고 사용자 이름을 캐시해서 반복
조회를 줄인다
- ChannelFeedService/Controller로 기관별 문정원/법률검토 채널의 게시글·자료 목록과
파일 다운로드 프록시(GET /api/orgs/{id}/posts|files, GET /api/files/{fileId})를 제공
- 채널 미생성/잘못된 channel 파라미터는 ChannelNotReadyException(409)으로 구분한다
@8192ef8a87eed448f0e03e16c2d50c03004ae8fc
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
package kr.itn.itnhub.config; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.feed.ChannelNotReadyException; |
|
| 3 | 4 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 4 | 5 |
import kr.itn.itnhub.seed.SeedParseException; |
| 5 | 6 |
import org.springframework.http.HttpStatus; |
... | ... | @@ -29,6 +30,11 @@ |
| 29 | 30 |
* SQL, 파일 경로는 어차피 이 메시지들에 담기지 않으므로 그대로 노출해도 안전하다. |
| 30 | 31 |
* {@code server.error.include-message=NEVER} 기본값은 건드리지 않는다 - 여기서 직접
|
| 31 | 32 |
* {@link ApiError} 본문을 만들어 반환하므로 그 설정과는 무관하게 동작한다.</p>
|
| 33 |
+ * |
|
| 34 |
+ * <p>{@link ChannelNotReadyException}은 {@code ChannelFeedService}가 존재하는 기관의
|
|
| 35 |
+ * 문정원/법률검토 채널이 아직 생성되지 않았거나 channel 파라미터가 잘못됐을 때 던진다 - |
|
| 36 |
+ * 기관은 있고(404 아님) 요청 자체도 잘못되지 않았으니(400 아님) "아직 처리할 수 없는 |
|
| 37 |
+ * 상태"라는 의미로 409를 쓴다.</p> |
|
| 32 | 38 |
* |
| 33 | 39 |
* <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
|
| 34 | 40 |
* 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리 |
... | ... | @@ -63,4 +69,9 @@ |
| 63 | 69 |
public ResponseEntity<ApiError> handleSeedParse(SeedParseException e) {
|
| 64 | 70 |
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ApiError(e.getMessage())); |
| 65 | 71 |
} |
| 72 |
+ |
|
| 73 |
+ @ExceptionHandler(ChannelNotReadyException.class) |
|
| 74 |
+ public ResponseEntity<ApiError> handleChannelNotReady(ChannelNotReadyException e) {
|
|
| 75 |
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(new ApiError(e.getMessage())); |
|
| 76 |
+ } |
|
| 66 | 77 |
} |
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
... | ... | @@ -0,0 +1,71 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 4 | +import org.springframework.http.HttpHeaders; | |
| 5 | +import org.springframework.http.MediaType; | |
| 6 | +import org.springframework.http.ResponseEntity; | |
| 7 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 8 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 9 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 10 | +import org.springframework.web.bind.annotation.RestController; | |
| 11 | + | |
| 12 | +import java.net.URLEncoder; | |
| 13 | +import java.nio.charset.StandardCharsets; | |
| 14 | +import java.util.List; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 기관관리 상세의 게시물/자료 탭이 부르는 읽기 전용 엔드포인트. | |
| 18 | + * 브라우저는 Mattermost를 절대 직접 호출하지 않고 전부 이 컨트롤러를 거친다. | |
| 19 | + */ | |
| 20 | +@RestController | |
| 21 | +public class ChannelFeedController { | |
| 22 | + | |
| 23 | + private final ChannelFeedService feedService; | |
| 24 | + private final MattermostClient mattermost; | |
| 25 | + | |
| 26 | + public ChannelFeedController(ChannelFeedService feedService, MattermostClient mattermost) { | |
| 27 | + this.feedService = feedService; | |
| 28 | + this.mattermost = mattermost; | |
| 29 | + } | |
| 30 | + | |
| 31 | + @GetMapping("/api/orgs/{id}/posts") | |
| 32 | + public List<PostView> posts(@PathVariable Long id, @RequestParam String channel) { | |
| 33 | + return feedService.posts(id, channel); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @GetMapping("/api/orgs/{id}/files") | |
| 37 | + public List<FileView> files(@PathVariable Long id, @RequestParam String channel) { | |
| 38 | + return feedService.files(id, channel); | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * 한글 파일명이 대부분이라 {@code Content-Disposition}은 반드시 RFC 5987의 | |
| 43 | + * {@code filename*=UTF-8''...} 형식으로 인코딩해야 한다. 일반 {@code filename="..."} | |
| 44 | + * 만 쓰면 브라우저마다 한글이 깨진다. | |
| 45 | + */ | |
| 46 | + @GetMapping("/api/files/{fileId}") | |
| 47 | + public ResponseEntity<byte[]> download(@PathVariable String fileId) { | |
| 48 | + FileRef info = mattermost.fileInfo(fileId); | |
| 49 | + byte[] content = mattermost.downloadFile(fileId); | |
| 50 | + | |
| 51 | + String encodedName = URLEncoder.encode(info.name(), StandardCharsets.UTF_8) | |
| 52 | + .replace("+", "%20"); | |
| 53 | + MediaType contentType = resolveContentType(info.mimeType()); | |
| 54 | + | |
| 55 | + return ResponseEntity.ok() | |
| 56 | + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName) | |
| 57 | + .contentType(contentType) | |
| 58 | + .body(content); | |
| 59 | + } | |
| 60 | + | |
| 61 | + private MediaType resolveContentType(String mimeType) { | |
| 62 | + if (mimeType == null || mimeType.isBlank()) { | |
| 63 | + return MediaType.APPLICATION_OCTET_STREAM; | |
| 64 | + } | |
| 65 | + try { | |
| 66 | + return MediaType.parseMediaType(mimeType); | |
| 67 | + } catch (org.springframework.util.InvalidMimeTypeException e) { | |
| 68 | + return MediaType.APPLICATION_OCTET_STREAM; | |
| 69 | + } | |
| 70 | + } | |
| 71 | +} |
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
... | ... | @@ -0,0 +1,58 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 7 | +import org.springframework.stereotype.Service; | |
| 8 | + | |
| 9 | +import java.util.List; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 기관관리 상세 화면의 게시물/자료 탭이 쓰는 조회 전용 서비스. | |
| 13 | + * Mattermost를 매번 실시간으로 읽어오는 뷰이므로 {@code @Transactional}을 걸지 않는다 - | |
| 14 | + * DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을 트랜잭션 | |
| 15 | + * 안에 묶어둘 이유가 없다. | |
| 16 | + */ | |
| 17 | +@Service | |
| 18 | +public class ChannelFeedService { | |
| 19 | + | |
| 20 | + /** 게시물 탭 한 번에 보여줄 최근 게시글 수. */ | |
| 21 | + private static final int RECENT_POSTS_PAGE_SIZE = 60; | |
| 22 | + | |
| 23 | + private final OrganizationMapper orgMapper; | |
| 24 | + private final MattermostClient mattermost; | |
| 25 | + | |
| 26 | + public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost) { | |
| 27 | + this.orgMapper = orgMapper; | |
| 28 | + this.mattermost = mattermost; | |
| 29 | + } | |
| 30 | + | |
| 31 | + public List<PostView> posts(Long orgId, String channel) { | |
| 32 | + String channelId = resolveChannelId(orgId, channel); | |
| 33 | + return mattermost.getRecentPosts(channelId, RECENT_POSTS_PAGE_SIZE); | |
| 34 | + } | |
| 35 | + | |
| 36 | + public List<FileView> files(Long orgId, String channel) { | |
| 37 | + String channelId = resolveChannelId(orgId, channel); | |
| 38 | + return mattermost.collectChannelFiles(channelId); | |
| 39 | + } | |
| 40 | + | |
| 41 | + private String resolveChannelId(Long orgId, String channel) { | |
| 42 | + Organization org = orgMapper.findById(orgId); | |
| 43 | + if (org == null) { | |
| 44 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 45 | + } | |
| 46 | + | |
| 47 | + String channelId = switch (channel) { | |
| 48 | + case "mj" -> org.getChannelIdMj(); | |
| 49 | + case "law" -> org.getChannelIdLaw(); | |
| 50 | + default -> throw new ChannelNotReadyException("알 수 없는 채널 구분입니다: " + channel); | |
| 51 | + }; | |
| 52 | + | |
| 53 | + if (channelId == null || channelId.isBlank()) { | |
| 54 | + throw new ChannelNotReadyException("해당 채널이 아직 생성되지 않았습니다."); | |
| 55 | + } | |
| 56 | + return channelId; | |
| 57 | + } | |
| 58 | +} |
+++ src/main/java/kr/itn/itnhub/feed/ChannelNotReadyException.java
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 기관은 존재하지만 요청한 채널(문정원/법률검토)이 아직 생성되지 않았거나, channel 파라미터가 | |
| 5 | + * "mj"/"law" 둘 중 하나가 아닐 때 던진다. 기관 자체는 있으므로 404(찾을 수 없음)가 아니고, | |
| 6 | + * 요청 형식이 틀린 것도 아니므로(존재하는 기관 id + 유효해 보이는 채널 구분) 400도 아니다 - | |
| 7 | + * "지금은 아직 처리할 수 없는 상태"라는 의미로 409(CONFLICT)를 쓴다. | |
| 8 | + */ | |
| 9 | +public class ChannelNotReadyException extends RuntimeException { | |
| 10 | + public ChannelNotReadyException(String message) { | |
| 11 | + super(message); | |
| 12 | + } | |
| 13 | +} |
+++ src/main/java/kr/itn/itnhub/feed/FileRef.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 게시글 첨부파일 하나를 가리키는 최소 정보. {@code mimeType}은 원래 스펙 문서의 3필드 | |
| 5 | + * 초안({@code id, name, size})에는 없었지만, 다운로드 프록시({@code ChannelFeedController})가 | |
| 6 | + * {@code Content-Type} 응답 헤더를 확장자 추측이 아니라 Mattermost가 실제로 알려준 값으로 | |
| 7 | + * 채우려면 {@code /files/{id}/info}의 {@code mime_type}을 어딘가에 담아야 해서 추가했다. | |
| 8 | + * 게시글 메타데이터의 첨부파일 항목도 동일한 필드를 내려주므로 값 채우기에 문제가 없고, | |
| 9 | + * 프론트엔드 {@code FileRef} 타입은 필요한 필드(name, size)만 읽으므로 영향이 없다. | |
| 10 | + */ | |
| 11 | +public record FileRef(String id, String name, long size, String mimeType) { | |
| 12 | +} |
+++ src/main/java/kr/itn/itnhub/feed/FileView.java
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +/** 자료실 목록 한 줄. 채널 전체를 훑어 모은 첨부파일 + 올린 사람/시각. */ | |
| 4 | +public record FileView(String id, String name, long size, String mimeType, | |
| 5 | + long createAt, String uploader) { | |
| 6 | +} |
+++ src/main/java/kr/itn/itnhub/feed/PostView.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 채널 게시글 하나를 화면에 보여주기 위한 뷰 모델. {@code user}는 원본 user_id가 아니라 | |
| 7 | + * 표시용 이름(닉네임 우선, 없으면 아이디)으로 이미 치환된 값이다. | |
| 8 | + */ | |
| 9 | +public record PostView(String id, String user, String message, long createAt, | |
| 10 | + boolean system, List<FileRef> files) { | |
| 11 | +} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
... | ... | @@ -1,5 +1,10 @@ |
| 1 | 1 |
package kr.itn.itnhub.mattermost; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.feed.FileRef; |
|
| 4 |
+import kr.itn.itnhub.feed.FileView; |
|
| 5 |
+import kr.itn.itnhub.feed.PostView; |
|
| 6 |
+ |
|
| 7 |
+import java.util.List; |
|
| 3 | 8 |
import java.util.Optional; |
| 4 | 9 |
|
| 5 | 10 |
public interface MattermostClient {
|
... | ... | @@ -9,4 +14,14 @@ |
| 9 | 14 |
Optional<String> findChannelIdByDisplayName(String displayName); |
| 10 | 15 |
|
| 11 | 16 |
String createPrivateChannel(String internalName, String displayName); |
| 17 |
+ |
|
| 18 |
+ /** 채널의 최근 게시글 한 페이지를 오래된순(화면 표시 순서)으로 돌려준다. */ |
|
| 19 |
+ List<PostView> getRecentPosts(String channelId, int perPage); |
|
| 20 |
+ |
|
| 21 |
+ /** 채널 전체를 페이지네이션하며 첨부파일을 전부 모아 최신순으로 돌려준다. */ |
|
| 22 |
+ List<FileView> collectChannelFiles(String channelId); |
|
| 23 |
+ |
|
| 24 |
+ byte[] downloadFile(String fileId); |
|
| 25 |
+ |
|
| 26 |
+ FileRef fileInfo(String fileId); |
|
| 12 | 27 |
} |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -2,21 +2,36 @@ |
| 2 | 2 |
|
| 3 | 3 |
import com.fasterxml.jackson.databind.JsonNode; |
| 4 | 4 |
import kr.itn.itnhub.config.MattermostProperties; |
| 5 |
+import kr.itn.itnhub.feed.FileRef; |
|
| 6 |
+import kr.itn.itnhub.feed.FileView; |
|
| 7 |
+import kr.itn.itnhub.feed.PostView; |
|
| 5 | 8 |
import org.springframework.beans.factory.annotation.Autowired; |
| 6 | 9 |
import org.springframework.http.MediaType; |
| 7 | 10 |
import org.springframework.stereotype.Component; |
| 8 | 11 |
import org.springframework.web.client.RestClient; |
| 9 | 12 |
import org.springframework.web.client.RestClientException; |
| 10 | 13 |
|
| 14 |
+import java.util.ArrayList; |
|
| 15 |
+import java.util.Collections; |
|
| 11 | 16 |
import java.util.LinkedHashMap; |
| 17 |
+import java.util.List; |
|
| 12 | 18 |
import java.util.Map; |
| 13 | 19 |
import java.util.Optional; |
| 20 |
+import java.util.concurrent.ConcurrentHashMap; |
|
| 14 | 21 |
|
| 15 | 22 |
@Component |
| 16 | 23 |
public class MattermostRestClient implements MattermostClient {
|
| 17 | 24 |
|
| 18 | 25 |
private final RestClient rest; |
| 19 | 26 |
private final String teamId; |
| 27 |
+ |
|
| 28 |
+ /** |
|
| 29 |
+ * 사용자 id → 표시용 이름(닉네임 우선, 없으면 아이디) 캐시. 게시물/자료 목록 조회 시 |
|
| 30 |
+ * 같은 몇 명이 계속 반복해서 등장하므로, 매 게시글마다 {@code /users/{id}}를 다시
|
|
| 31 |
+ * 부르지 않고 여기서 재사용한다. 조회 실패도 원본 id로 캐시해 전체 목록 조회가 |
|
| 32 |
+ * 실패하지 않게 한다. |
|
| 33 |
+ */ |
|
| 34 |
+ private final Map<String, String> userNameCache = new ConcurrentHashMap<>(); |
|
| 20 | 35 |
|
| 21 | 36 |
@Autowired |
| 22 | 37 |
public MattermostRestClient(MattermostProperties props) {
|
... | ... | @@ -130,6 +145,162 @@ |
| 130 | 145 |
} |
| 131 | 146 |
} |
| 132 | 147 |
|
| 148 |
+ /** {@code collectChannelFiles} 페이지네이션에 쓰는 페이지당 게시글 수. */
|
|
| 149 |
+ private static final int FILES_PAGE_SIZE = 200; |
|
| 150 |
+ |
|
| 151 |
+ /** {@code findChannelIdByDisplayName}과 같은 이유의 무한 루프 방지 상한. */
|
|
| 152 |
+ private static final int FILES_MAX_PAGES = 100; |
|
| 153 |
+ |
|
| 154 |
+ @Override |
|
| 155 |
+ public List<PostView> getRecentPosts(String channelId, int perPage) {
|
|
| 156 |
+ try {
|
|
| 157 |
+ JsonNode body = rest.get() |
|
| 158 |
+ .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts")
|
|
| 159 |
+ .queryParam("per_page", perPage)
|
|
| 160 |
+ .build(channelId)) |
|
| 161 |
+ .retrieve() |
|
| 162 |
+ .body(JsonNode.class); |
|
| 163 |
+ |
|
| 164 |
+ if (body == null) {
|
|
| 165 |
+ return List.of(); |
|
| 166 |
+ } |
|
| 167 |
+ |
|
| 168 |
+ List<PostView> result = new ArrayList<>(); |
|
| 169 |
+ JsonNode posts = body.path("posts");
|
|
| 170 |
+ for (JsonNode idNode : body.path("order")) {
|
|
| 171 |
+ result.add(toPostView(posts.path(idNode.asText()))); |
|
| 172 |
+ } |
|
| 173 |
+ // Mattermost는 newest-first로 내려주지만 화면은 오래된순으로 보여준다. |
|
| 174 |
+ Collections.reverse(result); |
|
| 175 |
+ return result; |
|
| 176 |
+ } catch (RestClientException e) {
|
|
| 177 |
+ throw new MattermostException("게시글 조회 실패: " + channelId, e);
|
|
| 178 |
+ } |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ @Override |
|
| 182 |
+ public List<FileView> collectChannelFiles(String channelId) {
|
|
| 183 |
+ List<FileView> files = new ArrayList<>(); |
|
| 184 |
+ try {
|
|
| 185 |
+ for (int page = 0; page < FILES_MAX_PAGES; page++) {
|
|
| 186 |
+ int currentPage = page; |
|
| 187 |
+ JsonNode body = rest.get() |
|
| 188 |
+ .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts")
|
|
| 189 |
+ .queryParam("per_page", FILES_PAGE_SIZE)
|
|
| 190 |
+ .queryParam("page", currentPage)
|
|
| 191 |
+ .build(channelId)) |
|
| 192 |
+ .retrieve() |
|
| 193 |
+ .body(JsonNode.class); |
|
| 194 |
+ |
|
| 195 |
+ if (body == null) {
|
|
| 196 |
+ return files; |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ JsonNode order = body.path("order");
|
|
| 200 |
+ JsonNode posts = body.path("posts");
|
|
| 201 |
+ int count = 0; |
|
| 202 |
+ for (JsonNode idNode : order) {
|
|
| 203 |
+ count++; |
|
| 204 |
+ JsonNode post = posts.path(idNode.asText()); |
|
| 205 |
+ long createAt = post.path("create_at").asLong();
|
|
| 206 |
+ String uploader = resolveUserName(post.path("user_id").asText());
|
|
| 207 |
+ for (JsonNode f : post.path("metadata").path("files")) {
|
|
| 208 |
+ files.add(new FileView( |
|
| 209 |
+ f.path("id").asText(),
|
|
| 210 |
+ f.path("name").asText(),
|
|
| 211 |
+ f.path("size").asLong(),
|
|
| 212 |
+ f.path("mime_type").asText(""),
|
|
| 213 |
+ createAt, |
|
| 214 |
+ uploader)); |
|
| 215 |
+ } |
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 218 |
+ if (count < FILES_PAGE_SIZE) {
|
|
| 219 |
+ return files; |
|
| 220 |
+ } |
|
| 221 |
+ } |
|
| 222 |
+ throw new MattermostException( |
|
| 223 |
+ "채널 파일 목록 조회 페이지 한도(" + FILES_MAX_PAGES + ") 초과: " + channelId);
|
|
| 224 |
+ } catch (RestClientException e) {
|
|
| 225 |
+ throw new MattermostException("채널 파일 목록 조회 실패: " + channelId, e);
|
|
| 226 |
+ } |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 229 |
+ @Override |
|
| 230 |
+ public byte[] downloadFile(String fileId) {
|
|
| 231 |
+ try {
|
|
| 232 |
+ byte[] body = rest.get() |
|
| 233 |
+ .uri("/api/v4/files/{id}", fileId)
|
|
| 234 |
+ .retrieve() |
|
| 235 |
+ .body(byte[].class); |
|
| 236 |
+ return body == null ? new byte[0] : body; |
|
| 237 |
+ } catch (RestClientException e) {
|
|
| 238 |
+ throw new MattermostException("파일 다운로드 실패: " + fileId, e);
|
|
| 239 |
+ } |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 242 |
+ @Override |
|
| 243 |
+ public FileRef fileInfo(String fileId) {
|
|
| 244 |
+ try {
|
|
| 245 |
+ JsonNode body = rest.get() |
|
| 246 |
+ .uri("/api/v4/files/{id}/info", fileId)
|
|
| 247 |
+ .retrieve() |
|
| 248 |
+ .body(JsonNode.class); |
|
| 249 |
+ |
|
| 250 |
+ if (body == null) {
|
|
| 251 |
+ throw new MattermostException("파일 정보 조회 실패: " + fileId);
|
|
| 252 |
+ } |
|
| 253 |
+ return new FileRef(fileId, |
|
| 254 |
+ body.path("name").asText(""),
|
|
| 255 |
+ body.path("size").asLong(),
|
|
| 256 |
+ body.path("mime_type").asText(""));
|
|
| 257 |
+ } catch (RestClientException e) {
|
|
| 258 |
+ throw new MattermostException("파일 정보 조회 실패: " + fileId, e);
|
|
| 259 |
+ } |
|
| 260 |
+ } |
|
| 261 |
+ |
|
| 262 |
+ private PostView toPostView(JsonNode post) {
|
|
| 263 |
+ String id = post.path("id").asText();
|
|
| 264 |
+ String message = post.path("message").asText("");
|
|
| 265 |
+ long createAt = post.path("create_at").asLong();
|
|
| 266 |
+ boolean system = post.path("type").asText("").startsWith("system_");
|
|
| 267 |
+ |
|
| 268 |
+ List<FileRef> files = new ArrayList<>(); |
|
| 269 |
+ for (JsonNode f : post.path("metadata").path("files")) {
|
|
| 270 |
+ files.add(new FileRef( |
|
| 271 |
+ f.path("id").asText(),
|
|
| 272 |
+ f.path("name").asText(),
|
|
| 273 |
+ f.path("size").asLong(),
|
|
| 274 |
+ f.path("mime_type").asText("")));
|
|
| 275 |
+ } |
|
| 276 |
+ |
|
| 277 |
+ return new PostView(id, resolveUserName(post.path("user_id").asText()), message,
|
|
| 278 |
+ createAt, system, files); |
|
| 279 |
+ } |
|
| 280 |
+ |
|
| 281 |
+ /** 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다. */ |
|
| 282 |
+ private String resolveUserName(String userId) {
|
|
| 283 |
+ return userNameCache.computeIfAbsent(userId, id -> {
|
|
| 284 |
+ try {
|
|
| 285 |
+ JsonNode user = rest.get() |
|
| 286 |
+ .uri("/api/v4/users/{id}", id)
|
|
| 287 |
+ .retrieve() |
|
| 288 |
+ .body(JsonNode.class); |
|
| 289 |
+ if (user == null) {
|
|
| 290 |
+ return id; |
|
| 291 |
+ } |
|
| 292 |
+ String nickname = user.path("nickname").asText("");
|
|
| 293 |
+ if (!nickname.isBlank()) {
|
|
| 294 |
+ return nickname; |
|
| 295 |
+ } |
|
| 296 |
+ String username = user.path("username").asText("");
|
|
| 297 |
+ return username.isBlank() ? id : username; |
|
| 298 |
+ } catch (RestClientException e) {
|
|
| 299 |
+ return id; |
|
| 300 |
+ } |
|
| 301 |
+ }); |
|
| 302 |
+ } |
|
| 303 |
+ |
|
| 133 | 304 |
private Optional<String> idOf(JsonNode node) {
|
| 134 | 305 |
if (node == null) {
|
| 135 | 306 |
return Optional.empty(); |
+++ src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
... | ... | @@ -0,0 +1,134 @@ |
| 1 | +package kr.itn.itnhub.feed; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 12 | +import org.springframework.http.HttpHeaders; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 15 | +import org.springframework.test.web.servlet.MockMvc; | |
| 16 | + | |
| 17 | +import java.util.List; | |
| 18 | + | |
| 19 | +import static org.mockito.ArgumentMatchers.eq; | |
| 20 | +import static org.mockito.Mockito.when; | |
| 21 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; | |
| 23 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 24 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 25 | + | |
| 26 | +@AutoConfigureMockMvc | |
| 27 | +@WithMockUser(roles = "ADMIN") | |
| 28 | +class ChannelFeedControllerTest extends AbstractDbTest { | |
| 29 | + | |
| 30 | + @Autowired | |
| 31 | + MockMvc mvc; | |
| 32 | + | |
| 33 | + @Autowired | |
| 34 | + OrganizationMapper mapper; | |
| 35 | + | |
| 36 | + @Autowired | |
| 37 | + JdbcTemplate jdbc; | |
| 38 | + | |
| 39 | + @MockBean | |
| 40 | + MattermostClient mattermost; | |
| 41 | + | |
| 42 | + private Long orgId; | |
| 43 | + | |
| 44 | + @BeforeEach | |
| 45 | + void setUp() { | |
| 46 | + jdbc.update("delete from organization"); | |
| 47 | + | |
| 48 | + Organization org = new Organization(); | |
| 49 | + org.setOrgNo("001"); | |
| 50 | + org.setOrgName("국제방송교류재단"); | |
| 51 | + org.setChannelSlug("001"); | |
| 52 | + mapper.upsertBySeed(org); | |
| 53 | + | |
| 54 | + orgId = mapper.findAll().get(0).getId(); | |
| 55 | + mapper.updateChannelIdMj(orgId, "chan-mj"); | |
| 56 | + mapper.updateChannelIdLaw(orgId, "chan-law"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + void 게시물_조회는_문정원_채널로_라우팅한다() throws Exception { | |
| 61 | + when(mattermost.getRecentPosts(eq("chan-mj"), eq(60))).thenReturn(List.of( | |
| 62 | + new PostView("p1", "송민지", "안녕하세요", 100L, false, List.of()))); | |
| 63 | + | |
| 64 | + mvc.perform(get("/api/orgs/{id}/posts", orgId).param("channel", "mj")) | |
| 65 | + .andExpect(status().isOk()) | |
| 66 | + .andExpect(jsonPath("$[0].id").value("p1")) | |
| 67 | + .andExpect(jsonPath("$[0].user").value("송민지")) | |
| 68 | + .andExpect(jsonPath("$[0].message").value("안녕하세요")); | |
| 69 | + } | |
| 70 | + | |
| 71 | + @Test | |
| 72 | + void 게시물_조회는_law_파라미터면_법률검토_채널로_라우팅한다() throws Exception { | |
| 73 | + when(mattermost.getRecentPosts(eq("chan-law"), eq(60))).thenReturn(List.of( | |
| 74 | + new PostView("p2", "변호사", "검토합니다", 200L, false, List.of()))); | |
| 75 | + | |
| 76 | + mvc.perform(get("/api/orgs/{id}/posts", orgId).param("channel", "law")) | |
| 77 | + .andExpect(status().isOk()) | |
| 78 | + .andExpect(jsonPath("$[0].id").value("p2")); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + void 자료_조회는_문정원_채널로_라우팅한다() throws Exception { | |
| 83 | + when(mattermost.collectChannelFiles(eq("chan-mj"))).thenReturn(List.of( | |
| 84 | + new FileView("f1", "보고서.pdf", 1234L, "application/pdf", 100L, "송민지"))); | |
| 85 | + | |
| 86 | + mvc.perform(get("/api/orgs/{id}/files", orgId).param("channel", "mj")) | |
| 87 | + .andExpect(status().isOk()) | |
| 88 | + .andExpect(jsonPath("$[0].id").value("f1")) | |
| 89 | + .andExpect(jsonPath("$[0].name").value("보고서.pdf")) | |
| 90 | + .andExpect(jsonPath("$[0].uploader").value("송민지")); | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + void 채널이_아직_생성되지_않았으면_409다() throws Exception { | |
| 95 | + jdbc.update("delete from organization"); | |
| 96 | + Organization org = new Organization(); | |
| 97 | + org.setOrgNo("002"); | |
| 98 | + org.setOrgName("생성전기관"); | |
| 99 | + org.setChannelSlug("002"); | |
| 100 | + mapper.upsertBySeed(org); | |
| 101 | + Long freshOrgId = mapper.findAll().get(0).getId(); | |
| 102 | + | |
| 103 | + mvc.perform(get("/api/orgs/{id}/posts", freshOrgId).param("channel", "mj")) | |
| 104 | + .andExpect(status().isConflict()) | |
| 105 | + .andExpect(jsonPath("$.message").value("해당 채널이 아직 생성되지 않았습니다.")); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + void 존재하지_않는_기관이면_404다() throws Exception { | |
| 110 | + long missingId = orgId + 999999L; | |
| 111 | + | |
| 112 | + mvc.perform(get("/api/orgs/{id}/posts", missingId).param("channel", "mj")) | |
| 113 | + .andExpect(status().isNotFound()); | |
| 114 | + } | |
| 115 | + | |
| 116 | + @Test | |
| 117 | + void 알수없는_채널_구분이면_409다() throws Exception { | |
| 118 | + mvc.perform(get("/api/orgs/{id}/posts", orgId).param("channel", "etc")) | |
| 119 | + .andExpect(status().isConflict()) | |
| 120 | + .andExpect(jsonPath("$.message").value("알 수 없는 채널 구분입니다: etc")); | |
| 121 | + } | |
| 122 | + | |
| 123 | + @Test | |
| 124 | + void 파일_다운로드는_한글파일명을_UTF8로_인코딩한_Content_Disposition을_설정한다() throws Exception { | |
| 125 | + when(mattermost.fileInfo(eq("f1"))) | |
| 126 | + .thenReturn(new FileRef("f1", "보고서.pdf", 1234L, "application/pdf")); | |
| 127 | + when(mattermost.downloadFile(eq("f1"))).thenReturn("hello".getBytes()); | |
| 128 | + | |
| 129 | + mvc.perform(get("/api/files/{fileId}", "f1")) | |
| 130 | + .andExpect(status().isOk()) | |
| 131 | + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, | |
| 132 | + "attachment; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.pdf")); | |
| 133 | + } | |
| 134 | +} |
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
... | ... | @@ -2,6 +2,9 @@ |
| 2 | 2 |
|
| 3 | 3 |
import com.github.tomakehurst.wiremock.WireMockServer; |
| 4 | 4 |
import kr.itn.itnhub.config.MattermostProperties; |
| 5 |
+import kr.itn.itnhub.feed.FileRef; |
|
| 6 |
+import kr.itn.itnhub.feed.FileView; |
|
| 7 |
+import kr.itn.itnhub.feed.PostView; |
|
| 5 | 8 |
import org.junit.jupiter.api.AfterEach; |
| 6 | 9 |
import org.junit.jupiter.api.BeforeEach; |
| 7 | 10 |
import org.junit.jupiter.api.Test; |
... | ... | @@ -9,6 +12,7 @@ |
| 9 | 12 |
import org.springframework.web.client.RestClient; |
| 10 | 13 |
|
| 11 | 14 |
import java.net.http.HttpClient; |
| 15 |
+import java.util.List; |
|
| 12 | 16 |
|
| 13 | 17 |
import static com.github.tomakehurst.wiremock.client.WireMock.*; |
| 14 | 18 |
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; |
... | ... | @@ -185,4 +189,171 @@ |
| 185 | 189 |
assertThatThrownBy(() -> client.createPrivateChannel("org-003-mj", "003_기관 (문정원)"))
|
| 186 | 190 |
.isInstanceOf(MattermostException.class); |
| 187 | 191 |
} |
| 192 |
+ |
|
| 193 |
+ @Test |
|
| 194 |
+ void 게시글_조회는_최신순_응답을_오래된순으로_뒤집어_돌려준다() {
|
|
| 195 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 196 |
+ .withQueryParam("per_page", equalTo("60"))
|
|
| 197 |
+ .willReturn(okJson("""
|
|
| 198 |
+ {
|
|
| 199 |
+ "order": ["p2", "p1"], |
|
| 200 |
+ "posts": {
|
|
| 201 |
+ "p1": {"id":"p1","user_id":"u1","message":"먼저 씀","create_at":100,"type":""},
|
|
| 202 |
+ "p2": {"id":"p2","user_id":"u1","message":"나중에 씀","create_at":200,"type":""}
|
|
| 203 |
+ } |
|
| 204 |
+ } |
|
| 205 |
+ """))); |
|
| 206 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
|
|
| 207 |
+ .willReturn(okJson("{\"username\":\"user1\",\"nickname\":\"\"}")));
|
|
| 208 |
+ |
|
| 209 |
+ List<PostView> posts = client.getRecentPosts("chan001mj", 60);
|
|
| 210 |
+ |
|
| 211 |
+ assertThat(posts).extracting(PostView::id).containsExactly("p1", "p2");
|
|
| 212 |
+ assertThat(posts).extracting(PostView::message).containsExactly("먼저 씀", "나중에 씀");
|
|
| 213 |
+ } |
|
| 214 |
+ |
|
| 215 |
+ @Test |
|
| 216 |
+ void 시스템_메시지는_system_플래그가_참이다() {
|
|
| 217 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 218 |
+ .withQueryParam("per_page", equalTo("60"))
|
|
| 219 |
+ .willReturn(okJson("""
|
|
| 220 |
+ {
|
|
| 221 |
+ "order": ["p1"], |
|
| 222 |
+ "posts": {
|
|
| 223 |
+ "p1": {"id":"p1","user_id":"u1","message":"님이 입장했습니다.","create_at":100,"type":"system_join_channel"}
|
|
| 224 |
+ } |
|
| 225 |
+ } |
|
| 226 |
+ """))); |
|
| 227 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
|
|
| 228 |
+ .willReturn(okJson("{\"username\":\"user1\"}")));
|
|
| 229 |
+ |
|
| 230 |
+ List<PostView> posts = client.getRecentPosts("chan001mj", 60);
|
|
| 231 |
+ |
|
| 232 |
+ assertThat(posts).hasSize(1); |
|
| 233 |
+ assertThat(posts.get(0).system()).isTrue(); |
|
| 234 |
+ } |
|
| 235 |
+ |
|
| 236 |
+ @Test |
|
| 237 |
+ void 게시글의_첨부파일_메타데이터를_읽는다() {
|
|
| 238 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 239 |
+ .withQueryParam("per_page", equalTo("60"))
|
|
| 240 |
+ .willReturn(okJson("""
|
|
| 241 |
+ {
|
|
| 242 |
+ "order": ["p1"], |
|
| 243 |
+ "posts": {
|
|
| 244 |
+ "p1": {
|
|
| 245 |
+ "id":"p1","user_id":"u1","message":"자료 첨부","create_at":100,"type":"", |
|
| 246 |
+ "metadata": {
|
|
| 247 |
+ "files": [ |
|
| 248 |
+ {"id":"f1","name":"보고서.pdf","size":1234,"mime_type":"application/pdf"}
|
|
| 249 |
+ ] |
|
| 250 |
+ } |
|
| 251 |
+ } |
|
| 252 |
+ } |
|
| 253 |
+ } |
|
| 254 |
+ """))); |
|
| 255 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
|
|
| 256 |
+ .willReturn(okJson("{\"username\":\"user1\"}")));
|
|
| 257 |
+ |
|
| 258 |
+ List<PostView> posts = client.getRecentPosts("chan001mj", 60);
|
|
| 259 |
+ |
|
| 260 |
+ assertThat(posts.get(0).files()).hasSize(1); |
|
| 261 |
+ FileRef file = posts.get(0).files().get(0); |
|
| 262 |
+ assertThat(file.id()).isEqualTo("f1");
|
|
| 263 |
+ assertThat(file.name()).isEqualTo("보고서.pdf");
|
|
| 264 |
+ assertThat(file.size()).isEqualTo(1234L); |
|
| 265 |
+ } |
|
| 266 |
+ |
|
| 267 |
+ @Test |
|
| 268 |
+ void 같은_작성자의_게시글_두개면_사용자조회는_한번만_한다() {
|
|
| 269 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 270 |
+ .withQueryParam("per_page", equalTo("60"))
|
|
| 271 |
+ .willReturn(okJson("""
|
|
| 272 |
+ {
|
|
| 273 |
+ "order": ["p2", "p1"], |
|
| 274 |
+ "posts": {
|
|
| 275 |
+ "p1": {"id":"p1","user_id":"u1","message":"첫번째","create_at":100,"type":""},
|
|
| 276 |
+ "p2": {"id":"p2","user_id":"u1","message":"두번째","create_at":200,"type":""}
|
|
| 277 |
+ } |
|
| 278 |
+ } |
|
| 279 |
+ """))); |
|
| 280 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
|
|
| 281 |
+ .willReturn(okJson("{\"username\":\"user1\",\"nickname\":\"유저원\"}")));
|
|
| 282 |
+ |
|
| 283 |
+ List<PostView> posts = client.getRecentPosts("chan001mj", 60);
|
|
| 284 |
+ |
|
| 285 |
+ assertThat(posts).extracting(PostView::user).containsExactly("유저원", "유저원");
|
|
| 286 |
+ server.verify(1, getRequestedFor(urlPathEqualTo("/api/v4/users/u1")));
|
|
| 287 |
+ } |
|
| 288 |
+ |
|
| 289 |
+ @Test |
|
| 290 |
+ void 파일목록_조회는_두번째_페이지까지_모아_최신순으로_돌려준다() {
|
|
| 291 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 292 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 293 |
+ .withQueryParam("page", equalTo("0"))
|
|
| 294 |
+ .willReturn(okJson(postsPageJson(200, "p0", true)))); |
|
| 295 |
+ |
|
| 296 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 297 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 298 |
+ .withQueryParam("page", equalTo("1"))
|
|
| 299 |
+ .willReturn(okJson(postsPageJson(5, "p1", true)))); |
|
| 300 |
+ |
|
| 301 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/uploader"))
|
|
| 302 |
+ .willReturn(okJson("{\"username\":\"uploader\"}")));
|
|
| 303 |
+ |
|
| 304 |
+ List<FileView> files = client.collectChannelFiles("chan001mj");
|
|
| 305 |
+ |
|
| 306 |
+ assertThat(files).extracting(FileView::name).containsExactly("p0-file.txt", "p1-file.txt");
|
|
| 307 |
+ } |
|
| 308 |
+ |
|
| 309 |
+ /** |
|
| 310 |
+ * 채널 게시글 목록 한 페이지를 흉내낸 JSON을 만든다. 마지막 게시글에 첨부파일을 하나 붙여 |
|
| 311 |
+ * 페이지네이션과 파일 수집을 동시에 검증할 수 있게 한다. |
|
| 312 |
+ */ |
|
| 313 |
+ private static String postsPageJson(int count, String idPrefix, boolean withFile) {
|
|
| 314 |
+ StringBuilder order = new StringBuilder("[");
|
|
| 315 |
+ StringBuilder posts = new StringBuilder("{");
|
|
| 316 |
+ for (int i = 0; i < count; i++) {
|
|
| 317 |
+ String id = idPrefix + "-" + i; |
|
| 318 |
+ if (i > 0) {
|
|
| 319 |
+ order.append(",");
|
|
| 320 |
+ posts.append(",");
|
|
| 321 |
+ } |
|
| 322 |
+ order.append("\"").append(id).append("\"");
|
|
| 323 |
+ posts.append("\"").append(id).append("\":{\"id\":\"").append(id)
|
|
| 324 |
+ .append("\",\"user_id\":\"uploader\",\"message\":\"\",\"create_at\":1,\"type\":\"\"");
|
|
| 325 |
+ if (withFile && i == count - 1) {
|
|
| 326 |
+ posts.append(",\"metadata\":{\"files\":[{\"id\":\"").append(id)
|
|
| 327 |
+ .append("-file\",\"name\":\"").append(idPrefix)
|
|
| 328 |
+ .append("-file.txt\",\"size\":10,\"mime_type\":\"text/plain\"}]}");
|
|
| 329 |
+ } |
|
| 330 |
+ posts.append("}");
|
|
| 331 |
+ } |
|
| 332 |
+ order.append("]");
|
|
| 333 |
+ posts.append("}");
|
|
| 334 |
+ return "{\"order\":" + order + ",\"posts\":" + posts + "}";
|
|
| 335 |
+ } |
|
| 336 |
+ |
|
| 337 |
+ @Test |
|
| 338 |
+ void 파일을_다운로드하면_바이트를_그대로_돌려준다() {
|
|
| 339 |
+ server.stubFor(get(urlEqualTo("/api/v4/files/f1"))
|
|
| 340 |
+ .willReturn(aResponse().withStatus(200).withBody("binary-content")));
|
|
| 341 |
+ |
|
| 342 |
+ byte[] bytes = client.downloadFile("f1");
|
|
| 343 |
+ |
|
| 344 |
+ assertThat(new String(bytes)).isEqualTo("binary-content");
|
|
| 345 |
+ } |
|
| 346 |
+ |
|
| 347 |
+ @Test |
|
| 348 |
+ void 파일_정보를_조회하면_이름과_크기와_MIME타입을_돌려준다() {
|
|
| 349 |
+ server.stubFor(get(urlEqualTo("/api/v4/files/f1/info"))
|
|
| 350 |
+ .willReturn(okJson("{\"name\":\"보고서.pdf\",\"size\":1234,\"mime_type\":\"application/pdf\"}")));
|
|
| 351 |
+ |
|
| 352 |
+ FileRef info = client.fileInfo("f1");
|
|
| 353 |
+ |
|
| 354 |
+ assertThat(info.id()).isEqualTo("f1");
|
|
| 355 |
+ assertThat(info.name()).isEqualTo("보고서.pdf");
|
|
| 356 |
+ assertThat(info.size()).isEqualTo(1234L); |
|
| 357 |
+ assertThat(info.mimeType()).isEqualTo("application/pdf");
|
|
| 358 |
+ } |
|
| 188 | 359 |
} |
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?