package kr.itn.itnhub.feed; import kr.itn.itnhub.config.MattermostProperties; import kr.itn.itnhub.mattermost.MattermostClient; import kr.itn.itnhub.mattermost.MattermostException; import kr.itn.itnhub.org.Organization; import kr.itn.itnhub.org.OrganizationMapper; import kr.itn.itnhub.org.OrgNotFoundException; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; /** * 기관관리 상세 화면의 채팅 탭이 쓰는 서비스. 조회뿐 아니라 메시지 전송·공지 등록/해제까지 * 담당한다. Mattermost를 매번 실시간으로 읽고 쓰는 통로이므로 {@code @Transactional}을 * 걸지 않는다 - DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을 * 트랜잭션 안에 묶어둘 이유가 없다. * *

공지 정책(정책 A - 단일 활성 공지)은 {@link #sendMessage}에서 처리한다: 게시글을 * 올린 뒤, 기존에 고정돼 있던 게시글을 전부 해제하고, 새 게시글을 고정하고, 채널 헤더를 * 공지 요약 + 원문 permalink로 바꾼다. 게시글 전송 자체가 아니라 이 뒤처리(고정/헤더) 중 * 하나라도 실패하면 - 게시글은 이미 채널 멤버에게 전달된 뒤이므로 - 실패로 보고하지 않고 * {@link SendResult#noticeWarning()}에 경고만 담아 돌려준다.

*/ @Service public class ChannelFeedService { /** 게시물 탭 한 번에 보여줄 최근 게시글 수. */ private static final int RECENT_POSTS_PAGE_SIZE = 60; /** 공지 헤더에 넣을 요약문 최대 길이(첫 줄 기준). */ private static final int NOTICE_SUMMARY_MAX_LEN = 150; private final OrganizationMapper orgMapper; private final MattermostClient mattermost; private final MattermostProperties mattermostProps; private final kr.itn.itnhub.stage.StageService stageService; public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost, MattermostProperties mattermostProps, kr.itn.itnhub.stage.StageService stageService) { this.orgMapper = orgMapper; this.mattermost = mattermost; this.mattermostProps = mattermostProps; this.stageService = stageService; } public List posts(Long orgId, String channel) { String channelId = resolveChannelId(orgId, channel); return mattermost.getRecentPosts(channelId, RECENT_POSTS_PAGE_SIZE); } public List files(Long orgId, String channel) { String channelId = resolveChannelId(orgId, channel); return mattermost.collectChannelFiles(channelId); } /** * 메시지를 보내고, {@code notice}가 참이면 정책 A(단일 활성 공지)를 적용한다. * 파일 업로드·게시글 생성이 실패하면 그대로 예외가 전파된다(전송 자체가 실패한 것). */ public SendResult sendMessage(Long orgId, String channel, String message, List files, boolean notice) { String channelId = resolveChannelId(orgId, channel); List fileIds = new ArrayList<>(); for (UploadedFile file : files) { fileIds.add(mattermost.uploadFile(channelId, file.filename(), file.data(), file.contentType())); } String postId = mattermost.createPost(channelId, message, fileIds); PostView post = fetchPost(channelId, postId, message); // 요구사항 [18]: 채널에 자료가 올라가면 ②목록접수로 저절로 넘어간다. // 파일이 실린 게시글만 계기로 본다 - 그냥 대화만 오간 것은 자료 접수가 아니다. if (!fileIds.isEmpty()) { stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED, kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED); } if (!notice) { return new SendResult(post, null); } try { for (PostView pinned : mattermost.getPinnedPosts(channelId)) { mattermost.unpinPost(pinned.id()); } mattermost.pinPost(postId); mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId)); return new SendResult(post, null); } catch (MattermostException e) { return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage()); } } /** 채널의 "현재 공지" = 가장 최근에 고정된, 시스템 메시지가 아닌 게시글. 없으면 null. */ public PostView currentNotice(Long orgId, String channel) { String channelId = resolveChannelId(orgId, channel); return mattermost.getPinnedPosts(channelId).stream() .filter(p -> !p.system()) .findFirst() .orElse(null); } /** 공지 해제: 고정된 게시글을 전부 해제하고 헤더를 비운다. 원본 글은 채팅 기록에 남는다. */ public void clearNotice(Long orgId, String channel) { String channelId = resolveChannelId(orgId, channel); for (PostView pinned : mattermost.getPinnedPosts(channelId)) { mattermost.unpinPost(pinned.id()); } mattermost.updateChannelHeader(channelId, ""); } /** * 방금 만든 게시글을 다시 조회해 표시용 정보(작성자 표시명 등)까지 채워진 {@link PostView}로 * 돌려준다. 조회가 비어 있는 예외적인 경우를 대비해 최소한의 값으로 대체한다. */ private PostView fetchPost(String channelId, String postId, String message) { return mattermost.getRecentPosts(channelId, 1).stream() .filter(p -> p.id().equals(postId)) .findFirst() .orElseGet(() -> new PostView(postId, "", message == null ? "" : message, System.currentTimeMillis(), false, List.of())); } /** * 공지 헤더 문자열: {@code 📢 공지: {요약} — [자세히]({permalink})}. * 요약은 메시지 첫 줄을 150자로 자른 값이고, 메시지가 비어 있으면 첫 첨부파일 이름을 쓴다. */ private String noticeHeader(String message, List files, String postId) { String summary = noticeSummary(message, files); String permalink = mattermostProps.baseUrl() + "/" + mattermostProps.teamName() + "/pl/" + postId; return "📢 공지: " + summary + " — [자세히](" + permalink + ")"; } private String noticeSummary(String message, List files) { String firstLine = message == null ? "" : message.strip().split("\\R", 2)[0]; if (firstLine.isBlank() && !files.isEmpty()) { firstLine = files.get(0).filename(); } if (firstLine.length() > NOTICE_SUMMARY_MAX_LEN) { firstLine = firstLine.substring(0, NOTICE_SUMMARY_MAX_LEN); } return firstLine; } private String resolveChannelId(Long orgId, String channel) { Organization org = orgMapper.findById(orgId); if (org == null) { throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); } String channelId = switch (channel) { case "mj" -> org.getChannelIdMj(); case "law" -> org.getChannelIdLaw(); default -> throw new ChannelNotReadyException("알 수 없는 채널 구분입니다: " + channel); }; if (channelId == null || channelId.isBlank()) { throw new ChannelNotReadyException("해당 채널이 아직 생성되지 않았습니다."); } return channelId; } }