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;
public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost,
MattermostProperties mattermostProps) {
this.orgMapper = orgMapper;
this.mattermost = mattermost;
this.mattermostProps = mattermostProps;
}
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);
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;
}
}