package kr.itn.itnhub.feed; import kr.itn.itnhub.mattermost.MattermostClient; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; /** * 기관관리 상세의 게시물/자료 탭이 부르는 읽기 전용 엔드포인트. * 브라우저는 Mattermost를 절대 직접 호출하지 않고 전부 이 컨트롤러를 거친다. */ @RestController public class ChannelFeedController { private final ChannelFeedService feedService; private final MattermostClient mattermost; public ChannelFeedController(ChannelFeedService feedService, MattermostClient mattermost) { this.feedService = feedService; this.mattermost = mattermost; } @GetMapping("/api/orgs/{id}/posts") public List posts(@PathVariable Long id, @RequestParam String channel) { return feedService.posts(id, channel); } @GetMapping("/api/orgs/{id}/files") public List files(@PathVariable Long id, @RequestParam String channel) { return feedService.files(id, channel); } /** * 메시지를 보낸다. {@code message}와 {@code files} 둘 다 비어 있으면 400을 돌려준다. * {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다 - 자세한 내용은 * {@link ChannelFeedService#sendMessage}를 참고. */ @PostMapping("/api/orgs/{id}/messages") public SendResult sendMessage( @PathVariable Long id, @RequestParam String channel, @RequestParam(required = false, defaultValue = "") String message, @RequestParam(required = false, defaultValue = "false") boolean notice, @RequestParam(required = false) List files) { List submitted = files == null ? List.of() : files; boolean anyFile = submitted.stream().anyMatch(f -> !f.isEmpty()); if (message.isBlank() && !anyFile) { throw new InvalidMessageException("메시지 내용이나 첨부파일 중 하나는 있어야 합니다."); } List uploaded = new ArrayList<>(); for (MultipartFile file : submitted) { if (file.isEmpty()) { continue; } try { uploaded.add(new UploadedFile(file.getOriginalFilename(), file.getBytes(), file.getContentType())); } catch (IOException e) { throw new InvalidMessageException("첨부파일을 읽지 못했습니다: " + file.getOriginalFilename()); } } return feedService.sendMessage(id, channel, message, uploaded, notice); } /** 채널의 현재 공지(가장 최근에 고정된 게시글). 없으면 204를 돌려준다. */ @GetMapping("/api/orgs/{id}/notice") public ResponseEntity notice(@PathVariable Long id, @RequestParam String channel) { PostView notice = feedService.currentNotice(id, channel); return notice == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(notice); } /** 공지 해제: 고정을 전부 풀고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */ @ResponseStatus(HttpStatus.NO_CONTENT) @DeleteMapping("/api/orgs/{id}/notice") public void clearNotice(@PathVariable Long id, @RequestParam String channel) { feedService.clearNotice(id, channel); } /** * 한글 파일명이 대부분이라 {@code Content-Disposition}은 반드시 RFC 5987의 * {@code filename*=UTF-8''...} 형식으로 인코딩해야 한다. 일반 {@code filename="..."} * 만 쓰면 브라우저마다 한글이 깨진다. */ @GetMapping("/api/files/{fileId}") public ResponseEntity download(@PathVariable String fileId) { FileRef info = mattermost.fileInfo(fileId); byte[] content = mattermost.downloadFile(fileId); String encodedName = URLEncoder.encode(info.name(), StandardCharsets.UTF_8) .replace("+", "%20"); MediaType contentType = resolveContentType(info.mimeType()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName) .contentType(contentType) .body(content); } private MediaType resolveContentType(String mimeType) { if (mimeType == null || mimeType.isBlank()) { return MediaType.APPLICATION_OCTET_STREAM; } try { return MediaType.parseMediaType(mimeType); } catch (org.springframework.util.InvalidMimeTypeException e) { return MediaType.APPLICATION_OCTET_STREAM; } } }